Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c1f88ed9c | |||
| 4a49add277 | |||
| 07b8c1ef16 | |||
| 1b561af9e9 | |||
| 1296555f17 | |||
| 7491e70a15 | |||
| 0a1f6b48a2 | |||
| ef73a9cea0 | |||
| d53d3106f2 | |||
| 02ac136e19 | |||
| 084b8764e3 | |||
| 281f0d3782 | |||
| 85e7801407 | |||
| 5206388958 | |||
| 8076d1a949 | |||
| aeaca84cc7 | |||
| 278e57354c | |||
| 9f90d8726f | |||
| 859043f5a5 | |||
| fc22a61a43 | |||
| 0985c38be8 | |||
| af4c64bba7 | |||
| a8743ba7df | |||
| 7b3fd4b65f | |||
| dd11019fdf | |||
| 714c5a9b25 | |||
| 2a10450c3e | |||
| e72d3ad9e5 | |||
| a7461ff83b | |||
| c1d031511a | |||
| 7379437cc3 | |||
| 8bd7d77a8a | |||
| f90ba332bd | |||
| e3aa17acdd | |||
| 1eff0f9337 | |||
| 3cf67f6e2a | |||
| 90d8db4abe | |||
| d1a0eedf03 | |||
| 5dbeb0cb87 | |||
| 30a7857d13 | |||
| 1e79ada6d8 | |||
| be85822cc7 | |||
| 31cffbe1bb |
@@ -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.
|
||||||
@@ -1,36 +1,327 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Finance App
|
||||||
|
|
||||||
## Getting Started
|
Personal finance tracker built on Next.js 16 (App Router), PostgreSQL, and Prisma. Bank statements are ingested automatically from Paperless-NGX via an N8N workflow that uses Gemini to extract structured data from PDF statements.
|
||||||
|
|
||||||
First, run the development server:
|
## Stack
|
||||||
|
|
||||||
```bash
|
- **Frontend**: Next.js 16 App Router, TypeScript, Tailwind CSS, Recharts
|
||||||
npm run dev
|
- **Backend**: Next.js API routes, raw PostgreSQL via `pg` + `@prisma/adapter-pg`
|
||||||
# or
|
- **Database**: PostgreSQL (`postgres-personal` container)
|
||||||
yarn dev
|
- **Auth**: `X-Forwarded-User` header (email) set by Traefik forward-auth → mapped to `participants.email`
|
||||||
# or
|
- **Ingestion**: N8N workflow → Gemini 2.5 Flash (PDF parsing) → PostgreSQL
|
||||||
pnpm dev
|
|
||||||
# or
|
---
|
||||||
bun dev
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### `statements`
|
||||||
|
The top-level document, one row per billing period per account.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | int | Primary key |
|
||||||
|
| `bank_name` | text | Normalised bank name (e.g. "American Express") |
|
||||||
|
| `card_name` | text | Product name (e.g. "Rewards Travel Adventures") |
|
||||||
|
| `account_number` | text | Account/card number (spaces stripped) |
|
||||||
|
| `account_type` | text | Raw account type string from statement |
|
||||||
|
| `statement_type` | text | Normalised type: `Credit Card`, `Business Card`, `multi-currency account`, etc. |
|
||||||
|
| `account_holder_name` | text | Name on the account if extracted |
|
||||||
|
| `billing_start_date` | date | Period start |
|
||||||
|
| `billing_end_date` | date | Period end — used as the deduplication anchor |
|
||||||
|
| `opening_balance` | numeric | Balance at start of period |
|
||||||
|
| `closing_balance` | numeric | Balance at end of period |
|
||||||
|
| `total_credits` | numeric | Sum of all credits in period |
|
||||||
|
| `total_debits` | numeric | Sum of all debits in period |
|
||||||
|
| `total_amount_due` | numeric | Amount due (credit cards) |
|
||||||
|
| `minimum_amount_due` | numeric | Minimum payment due (credit cards) |
|
||||||
|
| `payment_due_date` | date | Payment due date (credit cards) |
|
||||||
|
| `credit_limit` | numeric | Credit limit (credit cards) |
|
||||||
|
| `available_credit` | numeric | Available credit at statement date |
|
||||||
|
| `interest_charged` | numeric | Interest charged this period (from statement summary) |
|
||||||
|
| `fees_charged` | numeric | Fees charged this period (from statement summary) |
|
||||||
|
| `currency` | text | Statement currency (e.g. `AUD`, `USD`) |
|
||||||
|
| `exchange_rate_to_aud` | numeric | FX rate at ingestion time (live from open.er-api.com) |
|
||||||
|
| `owner_id` | int FK → `participants` | Which person owns this statement |
|
||||||
|
| `paperless_doc_id` | int | Paperless-NGX document ID — deduplication key |
|
||||||
|
| `tier_used` | text | AI model used for extraction (e.g. `gemini-2.5-flash`) |
|
||||||
|
| `event_created` | bool | Whether a Google Calendar reminder was created for payment due date |
|
||||||
|
|
||||||
|
**Deduplication**: unique index on `(bank_name, account_number, billing_end_date)` prevents re-ingestion of the same period. `paperless_doc_id` has a separate unique index for Paperless-linked documents.
|
||||||
|
|
||||||
|
**Credit card detection**: `statement_type ILIKE '%card%'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `transactions`
|
||||||
|
One row per line item within a statement. Cascade-deleted when the parent statement is deleted.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | int | Primary key |
|
||||||
|
| `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 |
|
||||||
|
| `amount_aud` | numeric | AUD-converted amount (= amount if already AUD) |
|
||||||
|
| `transaction_type` | text | `debit`, `credit`, `payment`, `refund`, `fee`, `interest`, `transfer` |
|
||||||
|
| `merchant_name` | text | Raw merchant name extracted by Gemini |
|
||||||
|
| `merchant_normalized` | text | Cleaned/normalised merchant name (Gemini) |
|
||||||
|
| `location` | text | Location if present on statement |
|
||||||
|
| `foreign_currency_amount` | numeric | Original foreign amount if this was an FX transaction |
|
||||||
|
| `foreign_currency_code` | text | Foreign currency code (e.g. `USD`) |
|
||||||
|
| `category` | text | AI-assigned category (see category taxonomy below) |
|
||||||
|
| `row_index` | int | Position in statement — used for deduplication |
|
||||||
|
|
||||||
|
**Deduplication**: unique index on `(statement_id, transaction_date, description, amount, row_index)`.
|
||||||
|
|
||||||
|
**Analytics**: all spend queries use `amount_aud` for cross-currency consistency. Split-adjusted queries apply `amount_aud * share_percent / 100` where a split exists for the current user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `transaction_overrides`
|
||||||
|
User corrections to AI-extracted data. Stored separately to preserve the original extraction.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `transaction_id` | int FK → `transactions` (unique) | One override per transaction |
|
||||||
|
| `merchant_normalized` | text | User-corrected merchant name |
|
||||||
|
| `category_override` | text | User-corrected category |
|
||||||
|
| `notes` | text | Free-text notes |
|
||||||
|
|
||||||
|
All analytics queries use `COALESCE(o.category_override, t.category)` and `COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name)` to prefer overrides over AI values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `transaction_splits`
|
||||||
|
Shared expense tracking — records that a transaction was split between participants.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `transaction_id` | int FK → `transactions` | The transaction being split |
|
||||||
|
| `participant_id` | int FK → `participants` | Who shares in this transaction |
|
||||||
|
| `share_percent` | numeric(5,2) | Their percentage (1–100) |
|
||||||
|
| `settled` | bool | Whether this share has been settled |
|
||||||
|
| `settled_at` | timestamptz | When it was settled |
|
||||||
|
|
||||||
|
A transaction can be split across multiple participants. The statement owner's own share is implicit (`100 - SUM(other shares)`). Analytics queries LEFT JOIN `transaction_splits` on `participant_id = current_user.id` — if no split row exists, the full amount belongs to the owner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `transaction_tags`
|
||||||
|
Many-to-many join between transactions and tags.
|
||||||
|
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| `transaction_id` | int FK → `transactions` |
|
||||||
|
| `tag_id` | int FK → `tags` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `tags`
|
||||||
|
User-defined coloured labels for ad-hoc transaction grouping beyond the fixed category taxonomy.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | int | Primary key |
|
||||||
|
| `name` | text (unique) | Tag name |
|
||||||
|
| `color` | text | Hex colour (default `#6366f1`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `participants`
|
||||||
|
People who own statements or share expenses.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | int | Primary key |
|
||||||
|
| `name` | text (unique) | Display name |
|
||||||
|
| `email` | text (unique) | Login identity — matched against `X-Forwarded-User` header |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `account_owner_mappings`
|
||||||
|
Persists `(bank, account_number) → owner` assignments so future ingestion auto-assigns the correct owner without manual intervention.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `bank_name` | text | |
|
||||||
|
| `account_number` | text | |
|
||||||
|
| `owner_id` | int FK → `participants` | |
|
||||||
|
|
||||||
|
Written when a user reassigns a statement owner in the UI. Consulted by the N8N workflow on every new statement insert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `rules`
|
||||||
|
Saved auto-categorisation rules. Applied in bulk via the Rules page.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `owner_id` | int FK → `participants` | Rule belongs to this user |
|
||||||
|
| `name` | text | Rule label |
|
||||||
|
| `conditions` | jsonb | Array of `{field, operator, value}` — AND logic |
|
||||||
|
| `actions` | jsonb | `{set_category, add_tag_ids, set_merchant}` |
|
||||||
|
| `enabled` | bool | |
|
||||||
|
| `priority` | int | Higher priority rules run first |
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `budgets`
|
||||||
|
Monthly spend targets per category. Stored but currently unused in the UI (replaced by the analytics/insights views).
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `owner_id` | int FK → `participants` | |
|
||||||
|
| `category` | text | Category name |
|
||||||
|
| `month` | date | Always first of month (e.g. `2026-03-01`) |
|
||||||
|
| `amount_limit` | numeric | Spend target for that category/month |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Category Taxonomy
|
||||||
|
|
||||||
|
Fixed set defined in `src/lib/categories.ts`. Applied by Gemini at ingestion and overridable by the user or rules engine:
|
||||||
|
|
||||||
|
`groceries` · `dining` · `transport` · `fuel` · `shopping` · `utilities` · `entertainment` · `travel` · `health` · `insurance` · `subscriptions` · `cash_advance` · `government` · `education` · `rent` · `home_goods` · `home_maintenance` · `transfers` · `income` · `investment` · `personal_care` · `pets` · `gifts` · `charity` · `other`
|
||||||
|
|
||||||
|
- **home_goods** — items purchased for the house (appliances, furniture, kitchenware, electronics)
|
||||||
|
- **home_maintenance** — services on the property (cleaning, mowing, repairs)
|
||||||
|
|
||||||
|
**Committed spend** (Insights page): `rent`, `utilities`, `insurance`, `subscriptions`
|
||||||
|
**Excluded from spend analytics**: `transfers`, `investment`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
All routes require authentication via `X-Forwarded-User` header (set by Traefik). Responses are always scoped to the authenticated user's `owner_id`.
|
||||||
|
|
||||||
|
| Method | Route | Description |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| GET | `/api/statements` | All statements for current user |
|
||||||
|
| GET / PATCH | `/api/statements/[id]` | Get statement; PATCH to reassign owner (also writes `account_owner_mappings`) |
|
||||||
|
| GET | `/api/transactions` | Paginated transactions with filters: `from`, `to`, `category`, `merchant`, `statement_id`, `search`, `sort`, `dir` |
|
||||||
|
| GET / PATCH | `/api/transactions/[id]` | Get transaction; PATCH to upsert override (category, merchant, notes) |
|
||||||
|
| GET / POST | `/api/transactions/[id]/splits` | List or create splits on a transaction |
|
||||||
|
| GET / POST | `/api/transactions/[id]/tags` | List or apply tags to a transaction |
|
||||||
|
| POST | `/api/transactions/bulk` | Bulk update category/merchant across multiple transactions |
|
||||||
|
| GET | `/api/analytics/monthly` | Split-adjusted monthly spend by category + income + investments. Params: `months` (1–24, default 6) |
|
||||||
|
| GET | `/api/analytics/subscriptions` | Recurring charge detection — merchants with ≥3 occurrences at consistent intervals |
|
||||||
|
| GET | `/api/analytics/fees` | Fees and interest from statement summaries + individual fee/interest transactions |
|
||||||
|
| GET | `/api/shared-transactions` | Transactions that have active splits |
|
||||||
|
| POST | `/api/splits/settle` | Mark a split as settled |
|
||||||
|
| GET / POST | `/api/participants` | List participants; POST to create (with optional `email`) |
|
||||||
|
| GET | `/api/participants/[id]/balance` | Net balance owed by/to a specific participant |
|
||||||
|
| GET | `/api/participants/balances` | All participant balances |
|
||||||
|
| GET / POST | `/api/rules` | List or create rules |
|
||||||
|
| PATCH / DELETE | `/api/rules/[id]` | Update or delete a rule |
|
||||||
|
| POST | `/api/rules/apply` | Run all enabled rules against all transactions; returns `{matched, transactions_affected}` |
|
||||||
|
| GET / POST | `/api/budgets` | List budgets for a month (`?month=YYYY-MM`); upsert budget |
|
||||||
|
| DELETE | `/api/budgets/[id]` | Delete a budget |
|
||||||
|
| GET | `/api/merchants` | Merchant name autocomplete suggestions |
|
||||||
|
| GET | `/api/me` | Current user info derived from `X-Forwarded-User` header |
|
||||||
|
| GET / POST | `/api/tags` | List or create tags |
|
||||||
|
| PATCH / DELETE | `/api/tags/[id]` | Update or delete a tag |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ingestion Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Paperless-NGX
|
||||||
|
└─ documents tagged "Bank Statement" + "Credit Card" (without "cc-processor")
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
N8N workflow — polls every 5 minutes (workflow ID: FysADdFwEtwONQl4)
|
||||||
|
│
|
||||||
|
├─ Duplicate check: SELECT WHERE paperless_doc_id = <id>
|
||||||
|
│ └─ Already processed → skip, mark in Paperless
|
||||||
|
│
|
||||||
|
├─ Download PDF binary from Paperless API
|
||||||
|
│
|
||||||
|
├─ Gemini 2.5 Flash — PDF → structured JSON
|
||||||
|
│ responseSchema: { summary: {...}, transactions: [...] }
|
||||||
|
│ timeout: 180s, retryOnFail: 3×, delay: 30s
|
||||||
|
│
|
||||||
|
├─ Parse & normalise
|
||||||
|
│ account_number: strip spaces
|
||||||
|
│ bank_name: title-case
|
||||||
|
│ FX rate: fetch live from open.er-api.com if non-AUD
|
||||||
|
│
|
||||||
|
├─ Statement exists? (bank + account + billing_end_date)
|
||||||
|
│ └─ Duplicate → skip, mark in Paperless
|
||||||
|
│
|
||||||
|
├─ New bank? → Slack approval gate (human confirms before insert)
|
||||||
|
│
|
||||||
|
├─ Lookup account_owner_mappings → resolve owner_id (default: 1 = "Me")
|
||||||
|
│
|
||||||
|
├─ INSERT statements + transactions
|
||||||
|
│
|
||||||
|
├─ Google Calendar reminder for payment_due_date (credit cards)
|
||||||
|
│
|
||||||
|
└─ Paperless: PATCH document to add "cc-processor" tag
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
N8N workflow JSON: `docker/automation/workflows/cc-statement-processor-paperless.json` in the smarthome repo.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
---
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## Schema Migrations
|
||||||
|
|
||||||
## Learn More
|
Located in `prisma/migrations/`. Applied manually against the running container:
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
```bash
|
||||||
|
docker exec postgres-personal psql -U personal -d personal \
|
||||||
|
< prisma/migrations/<migration>/migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| Migration | What it adds |
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|-----------|-------------|
|
||||||
|
| `0001_init` | `statements`, `transactions`, `participants` |
|
||||||
|
| `0002_splits` | `transaction_splits` |
|
||||||
|
| `0003_owner_segregation` | `owner_id` on statements, `account_owner_mappings`, `email` on participants |
|
||||||
|
| `0004_tags` | `tags`, `transaction_tags` |
|
||||||
|
| `0005_rules` | `rules` |
|
||||||
|
| `0006_budgets` | `budgets` |
|
||||||
|
| `0007_cashflow` | `amount_aud`, `exchange_rate_to_aud` on transactions; `exchange_rate_to_aud` on statements |
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
> `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.
|
||||||
|
|
||||||
## Deploy on Vercel
|
---
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
## Known Gaps / TODOs
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Runs as a Docker container alongside the rest of the home lab stack. Build and deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
The container uses Next.js standalone output. `@prisma/adapter-pg` and `pg` are listed in `serverExternalPackages` in `next.config.ts` to ensure they are included in the standalone bundle.
|
||||||
|
|||||||
Generated
+1644
-7
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -6,7 +6,12 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run --config vitest.config.ts",
|
||||||
|
"test:watch": "vitest --config vitest.config.ts",
|
||||||
|
"test:setup": "bash scripts/setup-test-db.sh",
|
||||||
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"test:all": "npm test && npm run test:integration"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
@@ -16,7 +21,8 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -24,9 +30,12 @@
|
|||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS participants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
INSERT INTO participants (name) VALUES ('Me') ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transaction_splits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||||
|
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||||
|
share_percent NUMERIC(5,2) NOT NULL CHECK (share_percent > 0 AND share_percent <= 100),
|
||||||
|
settled BOOLEAN DEFAULT FALSE,
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE (transaction_id, participant_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_splits_txn ON transaction_splits(transaction_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_splits_participant ON transaction_splits(participant_id);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Add email to participants for OAuth identity mapping
|
||||||
|
ALTER TABLE participants ADD COLUMN IF NOT EXISTS email TEXT UNIQUE;
|
||||||
|
|
||||||
|
-- Add owner_id and account_holder_name to statements
|
||||||
|
ALTER TABLE statements ADD COLUMN IF NOT EXISTS owner_id INTEGER NOT NULL DEFAULT 1 REFERENCES participants(id);
|
||||||
|
ALTER TABLE statements ADD COLUMN IF NOT EXISTS account_holder_name TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_statements_owner_id ON statements(owner_id);
|
||||||
|
|
||||||
|
-- Auto-assignment mapping table: (bank_name, account_number) -> owner
|
||||||
|
CREATE TABLE IF NOT EXISTS account_owner_mappings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bank_name TEXT NOT NULL,
|
||||||
|
account_number TEXT NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(bank_name, account_number)
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS rules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
conditions JSONB NOT NULL DEFAULT '[]',
|
||||||
|
actions JSONB NOT NULL DEFAULT '{}',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rules_owner ON rules(owner_id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add FX conversion support
|
||||||
|
ALTER TABLE statements ADD COLUMN IF NOT EXISTS exchange_rate_to_aud NUMERIC(10,6);
|
||||||
|
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS amount_aud NUMERIC(12,2);
|
||||||
|
|
||||||
|
-- Backfill: all existing data is AUD
|
||||||
|
UPDATE transactions SET amount_aud = amount WHERE amount_aud IS NULL;
|
||||||
|
UPDATE statements SET exchange_rate_to_aud = 1.000000 WHERE exchange_rate_to_aud IS NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "transaction_overrides" ADD COLUMN "my_share_percent" DECIMAL(5,2);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE split_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
from_participant_id INTEGER NOT NULL REFERENCES participants(id),
|
||||||
|
to_participant_id INTEGER NOT NULL REFERENCES participants(id),
|
||||||
|
amount DECIMAL(10,2) NOT NULL CHECK (amount > 0),
|
||||||
|
payment_date DATE NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
linked_transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_split_payments_from ON split_payments(from_participant_id);
|
||||||
|
CREATE INDEX idx_split_payments_to ON split_payments(to_participant_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE transactions ADD COLUMN reconciled_with_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX idx_transactions_reconciled ON transactions(reconciled_with_id) WHERE reconciled_with_id IS NOT NULL;
|
||||||
@@ -13,6 +13,7 @@ model transaction_overrides {
|
|||||||
merchant_normalized String?
|
merchant_normalized String?
|
||||||
category_override String?
|
category_override String?
|
||||||
notes String?
|
notes String?
|
||||||
|
my_share_percent Decimal? @db.Decimal(5, 2)
|
||||||
updated_at DateTime @default(now()) @updatedAt
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ model participants {
|
|||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
splits transaction_splits[]
|
splits transaction_splits[]
|
||||||
account_owner_mappings account_owner_mappings[]
|
account_owner_mappings account_owner_mappings[]
|
||||||
|
payments_sent split_payments[] @relation("payments_from")
|
||||||
|
payments_received split_payments[] @relation("payments_to")
|
||||||
}
|
}
|
||||||
|
|
||||||
model account_owner_mappings {
|
model account_owner_mappings {
|
||||||
@@ -49,6 +52,19 @@ model transaction_splits {
|
|||||||
@@unique([transaction_id, participant_id])
|
@@unique([transaction_id, participant_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model split_payments {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
from_participant_id Int
|
||||||
|
to_participant_id Int
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
payment_date DateTime @db.Date
|
||||||
|
notes String?
|
||||||
|
linked_transaction_id Int?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
|
||||||
|
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
model tags {
|
model tags {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
@@ -66,6 +82,18 @@ model transaction_tags {
|
|||||||
@@id([transaction_id, tag_id])
|
@@id([transaction_id, tag_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model rules {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
owner_id Int
|
||||||
|
name String
|
||||||
|
conditions Json @default("[]")
|
||||||
|
actions Json @default("{}")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
priority Int @default(0)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model budgets {
|
model budgets {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
owner_id Int
|
owner_id Int
|
||||||
|
|||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Creates the personal_test database and writes .env.test
|
||||||
|
# Run once before integration tests: npm run test:setup
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PG_CONTAINER=postgres-personal
|
||||||
|
PG_USER=personal
|
||||||
|
PG_PASS=personalpassword123
|
||||||
|
TEST_DB=personal_test
|
||||||
|
|
||||||
|
# Discover the container's bridge IP (accessible from the host on Linux)
|
||||||
|
PG_HOST=$(docker inspect "$PG_CONTAINER" \
|
||||||
|
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$PG_HOST" ]; then
|
||||||
|
echo "ERROR: Could not find container '$PG_CONTAINER'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Postgres container at $PG_HOST:5432"
|
||||||
|
|
||||||
|
# Create test database (ignore error if it already exists)
|
||||||
|
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \
|
||||||
|
-c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wipe and rebuild schema from production (schema only, no data)
|
||||||
|
echo "Copying schema from $PG_USER to $TEST_DB..."
|
||||||
|
docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \
|
||||||
|
| docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d "$TEST_DB" -q
|
||||||
|
|
||||||
|
echo "Schema ready."
|
||||||
|
|
||||||
|
# Write .env.test
|
||||||
|
cat > "$(dirname "$0")/../.env.test" << EOF
|
||||||
|
DATABASE_URL=postgresql://$PG_USER:$PG_PASS@$PG_HOST:5432/$TEST_DB
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ".env.test written — integration tests can now run with: npm run test:integration"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Pool } from "pg";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
export function createPool() {
|
||||||
|
return new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the app's Prisma-based queryRaw with a direct pg call so that
|
||||||
|
// tests don't depend on Prisma's singleton picking up the right DATABASE_URL.
|
||||||
|
// Must be called BEFORE dynamically importing any module that uses @/lib/db.
|
||||||
|
// Uses vi.doMock (not vi.mock) so it is NOT hoisted and CAN close over `p`.
|
||||||
|
export function mockDbWithPool(p: Pool) {
|
||||||
|
vi.resetModules(); // clear module cache so fresh imports pick up the mock
|
||||||
|
vi.doMock("@/lib/db", () => ({
|
||||||
|
queryRaw: async (sql: string, params: unknown[] = []) => {
|
||||||
|
const result = await p.query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
},
|
||||||
|
prisma: p,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wipe all data tables and restart sequences between tests. */
|
||||||
|
export async function resetDB(pool: Pool) {
|
||||||
|
await pool.query(`
|
||||||
|
TRUNCATE
|
||||||
|
split_payments,
|
||||||
|
transaction_splits,
|
||||||
|
transaction_tags,
|
||||||
|
transaction_overrides,
|
||||||
|
rule_apply_runs,
|
||||||
|
rules,
|
||||||
|
budgets,
|
||||||
|
account_owner_mappings,
|
||||||
|
transactions,
|
||||||
|
statements,
|
||||||
|
tags,
|
||||||
|
participants
|
||||||
|
RESTART IDENTITY CASCADE
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed two participants and return their IDs. */
|
||||||
|
export async function seedParticipants(pool: Pool, names: [string, string] = ["Alice", "Bob"]) {
|
||||||
|
const r1 = await pool.query(
|
||||||
|
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||||
|
[names[0]]
|
||||||
|
);
|
||||||
|
const r2 = await pool.query(
|
||||||
|
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||||
|
[names[1]]
|
||||||
|
);
|
||||||
|
return { ownerId: r1.rows[0].id as number, otherId: r2.rows[0].id as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a manual transaction (no statement) and return its id. */
|
||||||
|
export async function insertTransaction(
|
||||||
|
pool: Pool,
|
||||||
|
ownerId: number,
|
||||||
|
overrides: {
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
category?: string;
|
||||||
|
transaction_type?: string;
|
||||||
|
transaction_date?: string;
|
||||||
|
merchant_normalized?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<number> {
|
||||||
|
const r = await pool.query(
|
||||||
|
`INSERT INTO transactions
|
||||||
|
(owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index)
|
||||||
|
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
|
||||||
|
[
|
||||||
|
ownerId,
|
||||||
|
overrides.transaction_date ?? "2024-06-15",
|
||||||
|
overrides.description ?? "Test transaction",
|
||||||
|
overrides.amount ?? 100,
|
||||||
|
overrides.transaction_type ?? "debit",
|
||||||
|
overrides.category ?? "other",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return r.rows[0].id as number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||||
|
import { createPool, resetDB } from "./helpers";
|
||||||
|
|
||||||
|
const pool = createPool();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDB(pool);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// This tests the name-substitution logic applied in /api/participants GET.
|
||||||
|
// The rule: the participant matching the current user's ID gets name "Me";
|
||||||
|
// everyone else keeps their real name.
|
||||||
|
function substituteMe(
|
||||||
|
participants: { id: number; name: string }[],
|
||||||
|
currentUserId: number
|
||||||
|
) {
|
||||||
|
return participants.map((p) =>
|
||||||
|
p.id === currentUserId ? { ...p, name: "Me" } : p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("participant Me substitution", () => {
|
||||||
|
it("replaces the current user's name with Me", async () => {
|
||||||
|
const r = await pool.query(
|
||||||
|
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
|
||||||
|
);
|
||||||
|
const userId = r.rows[0].id;
|
||||||
|
const participants = [
|
||||||
|
{ id: userId, name: "Siddharth" },
|
||||||
|
{ id: userId + 1, name: "Sonu" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = substituteMe(participants, userId);
|
||||||
|
expect(result.find((p) => p.id === userId)?.name).toBe("Me");
|
||||||
|
expect(result.find((p) => p.id === userId + 1)?.name).toBe("Sonu");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves all names unchanged when currentUserId does not match", () => {
|
||||||
|
const participants = [
|
||||||
|
{ id: 1, name: "Siddharth" },
|
||||||
|
{ id: 2, name: "Sonu" },
|
||||||
|
];
|
||||||
|
const result = substituteMe(participants, 999);
|
||||||
|
expect(result).toEqual(participants);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Sonu sees Me for herself and Siddharth for the primary user", async () => {
|
||||||
|
const r1 = await pool.query(
|
||||||
|
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
|
||||||
|
);
|
||||||
|
const r2 = await pool.query(
|
||||||
|
`INSERT INTO participants (name, email) VALUES ('Sonu', 'sonu@example.com') RETURNING id`
|
||||||
|
);
|
||||||
|
const siddharthId = r1.rows[0].id;
|
||||||
|
const sonuId = r2.rows[0].id;
|
||||||
|
|
||||||
|
const rawParticipants = [
|
||||||
|
{ id: siddharthId, name: "Siddharth" },
|
||||||
|
{ id: sonuId, name: "Sonu" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Siddharth's view
|
||||||
|
const siddharthView = substituteMe(rawParticipants, siddharthId);
|
||||||
|
expect(siddharthView.find((p) => p.id === siddharthId)?.name).toBe("Me");
|
||||||
|
expect(siddharthView.find((p) => p.id === sonuId)?.name).toBe("Sonu");
|
||||||
|
|
||||||
|
// Sonu's view
|
||||||
|
const sonuView = substituteMe(rawParticipants, sonuId);
|
||||||
|
expect(sonuView.find((p) => p.id === siddharthId)?.name).toBe("Siddharth");
|
||||||
|
expect(sonuView.find((p) => p.id === sonuId)?.name).toBe("Me");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
||||||
|
import { createPool, mockDbWithPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
||||||
|
|
||||||
|
// Create a pool and mock @/lib/db BEFORE any dynamic imports that use it.
|
||||||
|
// vi.doMock is NOT hoisted so it can close over the pool instance.
|
||||||
|
const pool = createPool();
|
||||||
|
mockDbWithPool(pool);
|
||||||
|
|
||||||
|
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
||||||
|
// use the test pool rather than Prisma's singleton.
|
||||||
|
const { getTransactions, getParticipantBalances } = await import("@/lib/queries");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDB(pool);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getTransactions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getTransactions — owner scoping", () => {
|
||||||
|
it("returns only the owner's transactions", async () => {
|
||||||
|
const { ownerId, otherId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId, { description: "Alice groceries" });
|
||||||
|
await insertTransaction(pool, otherId, { description: "Bob petrol" });
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(data[0].description).toBe("Alice groceries");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes transactions where owner is a split participant", async () => {
|
||||||
|
const { ownerId, otherId } = await seedParticipants(pool);
|
||||||
|
const txId = await insertTransaction(pool, otherId, { description: "Shared dinner" });
|
||||||
|
|
||||||
|
// Add Alice as a split participant
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||||
|
[txId, ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||||
|
expect(data.some((t) => t.description === "Shared dinner")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct total count", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId, { description: "tx1" });
|
||||||
|
await insertTransaction(pool, ownerId, { description: "tx2" });
|
||||||
|
await insertTransaction(pool, ownerId, { description: "tx3" });
|
||||||
|
|
||||||
|
const { total } = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
||||||
|
expect(total).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTransactions — date filters", () => {
|
||||||
|
it("filters by from date", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
||||||
|
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { from: "2024-02-01", limit: 50, offset: 0 });
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(data[0].description).toBe("new tx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by to date", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
||||||
|
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { to: "2024-01-31", limit: 50, offset: 0 });
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(data[0].description).toBe("old tx");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTransactions — category filter", () => {
|
||||||
|
it("filters by category", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" });
|
||||||
|
await insertTransaction(pool, ownerId, { description: "Dinner out", category: "dining" });
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(data[0].description).toBe("Grocery run");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("category override takes precedence over raw category", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
const txId = await insertTransaction(pool, ownerId, { category: "dining" });
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'groceries')`,
|
||||||
|
[txId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: dining } = await getTransactions(ownerId, { categories: ["dining"], limit: 50, offset: 0 });
|
||||||
|
const { data: groceries } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
||||||
|
expect(dining).toHaveLength(0); // override hides original
|
||||||
|
expect(groceries).toHaveLength(1); // override exposes new category
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTransactions — search filter", () => {
|
||||||
|
it("searches description case-insensitively", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId, { description: "COLES WYNDHAM" });
|
||||||
|
await insertTransaction(pool, ownerId, { description: "ALDI POINT COOK" });
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { search: "coles", limit: 50, offset: 0 });
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(data[0].description).toBe("COLES WYNDHAM");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTransactions — amount filters", () => {
|
||||||
|
it("filters by amount_min", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId, { amount: 20 });
|
||||||
|
await insertTransaction(pool, ownerId, { amount: 200 });
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { amount_min: 100, limit: 50, offset: 0 });
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(Number(data[0].amount)).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by amount_max", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId, { amount: 20 });
|
||||||
|
await insertTransaction(pool, ownerId, { amount: 200 });
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { amount_max: 50, limit: 50, offset: 0 });
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(Number(data[0].amount)).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTransactions — pagination", () => {
|
||||||
|
it("respects limit and offset", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await insertTransaction(pool, ownerId, { description: `tx-${i}`, transaction_date: `2024-0${i + 1}-01` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page1 = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
||||||
|
const page2 = await getTransactions(ownerId, { limit: 2, offset: 2 });
|
||||||
|
expect(page1.data).toHaveLength(2);
|
||||||
|
expect(page2.data).toHaveLength(2);
|
||||||
|
expect(page1.data[0].description).not.toBe(page2.data[0].description);
|
||||||
|
expect(page1.total).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTransactions — splits and tags attached", () => {
|
||||||
|
it("attaches empty arrays when no splits or tags", async () => {
|
||||||
|
const { ownerId } = await seedParticipants(pool);
|
||||||
|
await insertTransaction(pool, ownerId);
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||||
|
expect(data[0].splits).toEqual([]);
|
||||||
|
expect(data[0].tags).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches split participants", async () => {
|
||||||
|
const { ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]);
|
||||||
|
const txId = await insertTransaction(pool, ownerId);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||||
|
[txId, otherId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||||
|
expect(data[0].splits).toHaveLength(1);
|
||||||
|
expect(data[0].splits[0].name).toBe("Bob");
|
||||||
|
expect(Number(data[0].splits[0].share_percent)).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getParticipantBalances ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getParticipantBalances", () => {
|
||||||
|
it("shows zero balance when no splits", async () => {
|
||||||
|
const { ownerId, otherId } = await seedParticipants(pool);
|
||||||
|
void otherId;
|
||||||
|
const balances = await getParticipantBalances(ownerId);
|
||||||
|
expect(balances.every((b) => Number(b.total_owed) === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates positive balance when participant owes owner", async () => {
|
||||||
|
const { ownerId, otherId } = await seedParticipants(pool);
|
||||||
|
// Alice pays $100, Bob owes 50%
|
||||||
|
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||||
|
[txId, otherId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const balances = await getParticipantBalances(ownerId);
|
||||||
|
const bobBalance = balances.find((b) => b.id === otherId);
|
||||||
|
expect(bobBalance).toBeDefined();
|
||||||
|
expect(Number(bobBalance!.total_owed)).toBeCloseTo(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduces balance after recording a payment", async () => {
|
||||||
|
const { ownerId, otherId } = await seedParticipants(pool);
|
||||||
|
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||||
|
[txId, otherId]
|
||||||
|
);
|
||||||
|
// Bob pays Alice $30
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date)
|
||||||
|
VALUES ($1, $2, 30, '2024-06-20')`,
|
||||||
|
[otherId, ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const balances = await getParticipantBalances(ownerId);
|
||||||
|
const bobBalance = balances.find((b) => b.id === otherId);
|
||||||
|
expect(Number(bobBalance!.total_owed)).toBeCloseTo(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows negative balance when owner owes participant", async () => {
|
||||||
|
const { ownerId, otherId } = await seedParticipants(pool);
|
||||||
|
// Bob pays $100 for a shared expense, Alice owes 50%
|
||||||
|
const txId = await insertTransaction(pool, otherId, { amount: 100 });
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||||
|
[txId, ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const balances = await getParticipantBalances(ownerId);
|
||||||
|
const bobBalance = balances.find((b) => b.id === otherId);
|
||||||
|
expect(Number(bobBalance!.total_owed)).toBeCloseTo(-50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsettled_count reflects open splits", async () => {
|
||||||
|
const { ownerId, otherId } = await seedParticipants(pool);
|
||||||
|
const tx1 = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||||
|
const tx2 = await insertTransaction(pool, ownerId, { amount: 80 });
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||||
|
VALUES ($1, $2, 50), ($3, $4, 50)`,
|
||||||
|
[tx1, otherId, tx2, otherId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const balances = await getParticipantBalances(ownerId);
|
||||||
|
const bobBalance = balances.find((b) => b.id === otherId);
|
||||||
|
expect(bobBalance!.unsettled_count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
describe("formatCategory", () => {
|
||||||
|
it("capitalises single word", () => {
|
||||||
|
expect(formatCategory("groceries")).toBe("Groceries");
|
||||||
|
});
|
||||||
|
it("capitalises and spaces underscore-separated words", () => {
|
||||||
|
expect(formatCategory("home_goods")).toBe("Home Goods");
|
||||||
|
});
|
||||||
|
it("handles three-word categories", () => {
|
||||||
|
expect(formatCategory("home_maintenance")).toBe("Home Maintenance");
|
||||||
|
});
|
||||||
|
it("handles cash_advance", () => {
|
||||||
|
expect(formatCategory("cash_advance")).toBe("Cash Advance");
|
||||||
|
});
|
||||||
|
it("handles personal_care", () => {
|
||||||
|
expect(formatCategory("personal_care")).toBe("Personal Care");
|
||||||
|
});
|
||||||
|
it("handles single-word categories without underscores", () => {
|
||||||
|
expect(formatCategory("travel")).toBe("Travel");
|
||||||
|
expect(formatCategory("fees")).toBe("Fees");
|
||||||
|
expect(formatCategory("other")).toBe("Other");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CATEGORIES", () => {
|
||||||
|
it("contains expected core categories", () => {
|
||||||
|
const cats = CATEGORIES as readonly string[];
|
||||||
|
expect(cats).toContain("groceries");
|
||||||
|
expect(cats).toContain("dining");
|
||||||
|
expect(cats).toContain("transport");
|
||||||
|
expect(cats).toContain("health");
|
||||||
|
expect(cats).toContain("other");
|
||||||
|
});
|
||||||
|
it("has no duplicates", () => {
|
||||||
|
const cats = CATEGORIES as readonly string[];
|
||||||
|
expect(new Set(cats).size).toBe(cats.length);
|
||||||
|
});
|
||||||
|
it("all entries are lowercase with only letters and underscores", () => {
|
||||||
|
for (const cat of CATEGORIES) {
|
||||||
|
expect(cat).toMatch(/^[a-z_]+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("formatCategory produces unique display names", () => {
|
||||||
|
const formatted = CATEGORIES.map(formatCategory);
|
||||||
|
expect(new Set(formatted).size).toBe(formatted.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { evaluateCondition, type Condition, type TxFields } from "@/lib/rules";
|
||||||
|
|
||||||
|
function tx(overrides: Partial<TxFields> = {}): TxFields {
|
||||||
|
return {
|
||||||
|
effective_category: "groceries",
|
||||||
|
effective_merchant: "Coles",
|
||||||
|
description: "COLES WYNDHAM VALE",
|
||||||
|
bank_name: "ANZ",
|
||||||
|
amount: 42.5,
|
||||||
|
transaction_type: "debit",
|
||||||
|
tags: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cond(field: Condition["field"], operator: Condition["operator"], value: string): Condition {
|
||||||
|
return { field, operator, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── String fields ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("merchant_normalized", () => {
|
||||||
|
it("contains — matches substring", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("contains — case-insensitive", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "contains", "COLES"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("contains — no match", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "contains", "woolworths"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("equals — exact match (case-insensitive)", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "equals", "coles"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("equals — no match", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "equals", "cole"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("starts_with — matches prefix", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "starts_with", "col"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("starts_with — no match", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "starts_with", "oles"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("not_equals — different value", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "not_equals", "woolworths"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("not_equals — same value", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "not_equals", "coles"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("empty merchant falls back to empty string", () => {
|
||||||
|
expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx({ effective_merchant: "" }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("description", () => {
|
||||||
|
it("contains — matches", () => {
|
||||||
|
expect(evaluateCondition(cond("description", "contains", "wyndham"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("equals — exact (case-insensitive)", () => {
|
||||||
|
expect(evaluateCondition(cond("description", "equals", "coles wyndham vale"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("starts_with", () => {
|
||||||
|
expect(evaluateCondition(cond("description", "starts_with", "coles"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("category", () => {
|
||||||
|
it("equals category", () => {
|
||||||
|
expect(evaluateCondition(cond("category", "equals", "groceries"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("not_equals different category", () => {
|
||||||
|
expect(evaluateCondition(cond("category", "not_equals", "dining"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("contains partial", () => {
|
||||||
|
expect(evaluateCondition(cond("category", "contains", "grocer"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bank_name", () => {
|
||||||
|
it("equals bank", () => {
|
||||||
|
expect(evaluateCondition(cond("bank_name", "equals", "anz"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("not_equals different bank", () => {
|
||||||
|
expect(evaluateCondition(cond("bank_name", "not_equals", "nab"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transaction_type", () => {
|
||||||
|
it("equals debit", () => {
|
||||||
|
expect(evaluateCondition(cond("transaction_type", "equals", "debit"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("not_equals credit", () => {
|
||||||
|
expect(evaluateCondition(cond("transaction_type", "not_equals", "credit"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("equals credit — no match on debit tx", () => {
|
||||||
|
expect(evaluateCondition(cond("transaction_type", "equals", "credit"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Amount field ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("amount", () => {
|
||||||
|
it("equals exact amount", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "equals", "42.5"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("equals wrong amount", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "equals", "42"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("not_equals different amount", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "not_equals", "100"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("gt — amount is greater", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "gt", "40"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("gt — amount is equal (not strictly greater)", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "gt", "42.5"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("gt — amount is less", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "gt", "50"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("lt — amount is less", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "lt", "50"), tx())).toBe(true);
|
||||||
|
});
|
||||||
|
it("lt — amount is equal (not strictly less)", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "lt", "42.5"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("lt — amount is greater", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "lt", "40"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
it("unsupported operator (contains) returns false", () => {
|
||||||
|
expect(evaluateCondition(cond("amount", "contains", "42"), tx())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tag field ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("tag", () => {
|
||||||
|
it("equals — tag present", () => {
|
||||||
|
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(true);
|
||||||
|
});
|
||||||
|
it("equals — tag absent", () => {
|
||||||
|
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [] }))).toBe(false);
|
||||||
|
});
|
||||||
|
it("equals — different tag", () => {
|
||||||
|
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 7 }] }))).toBe(false);
|
||||||
|
});
|
||||||
|
it("not_equals — tag absent", () => {
|
||||||
|
expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [] }))).toBe(true);
|
||||||
|
});
|
||||||
|
it("not_equals — tag present", () => {
|
||||||
|
expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(false);
|
||||||
|
});
|
||||||
|
it("matches one of multiple tags", () => {
|
||||||
|
expect(evaluateCondition(cond("tag", "equals", "3"), tx({ tags: [{ id: 1 }, { id: 3 }] }))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unknown field ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("unknown field", () => {
|
||||||
|
it("returns false for unrecognised field", () => {
|
||||||
|
// @ts-expect-error intentional invalid field for regression guard
|
||||||
|
expect(evaluateCondition({ field: "nonexistent", operator: "equals", value: "x" }, tx())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
// Statement-level fees and interest (aggregated by Gemini from the PDF)
|
||||||
|
const stmtRows = await queryRaw<{
|
||||||
|
bank_name: string;
|
||||||
|
fees: string;
|
||||||
|
interest: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
bank_name,
|
||||||
|
SUM(COALESCE(fees_charged, 0))::numeric(12,2) AS fees,
|
||||||
|
SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest
|
||||||
|
FROM statements
|
||||||
|
WHERE owner_id = $1
|
||||||
|
GROUP BY bank_name
|
||||||
|
HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0
|
||||||
|
ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Transaction-level fee and interest line items (split-adjusted)
|
||||||
|
const txnRows = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
merchant_name: string | null;
|
||||||
|
transaction_type: string;
|
||||||
|
my_amount: string;
|
||||||
|
bank_name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
t.id,
|
||||||
|
t.transaction_date,
|
||||||
|
t.description,
|
||||||
|
t.merchant_name,
|
||||||
|
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
|
||||||
|
AND t.transaction_type IN ('fee', 'interest')
|
||||||
|
ORDER BY t.transaction_date DESC`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const by_bank = stmtRows.map((r) => ({
|
||||||
|
bank_name: r.bank_name,
|
||||||
|
fees: Number(r.fees),
|
||||||
|
interest: Number(r.interest),
|
||||||
|
total: Number(r.fees) + Number(r.interest),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const transactions = txnRows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
my_amount: Number(r.my_amount),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Totals from statement-level data (more complete — Gemini reads the statement summary)
|
||||||
|
const total_fees = by_bank.reduce((s, r) => s + r.fees, 0);
|
||||||
|
const total_interest = by_bank.reduce((s, r) => s + r.interest, 0);
|
||||||
|
|
||||||
|
return NextResponse.json({ by_bank, transactions, total_fees, total_interest });
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ merchant: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { merchant } = await params;
|
||||||
|
const decoded = decodeURIComponent(merchant);
|
||||||
|
|
||||||
|
const transactions = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
amount_aud: number | null;
|
||||||
|
my_amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
category: string;
|
||||||
|
bank_name: string;
|
||||||
|
statement_id: number;
|
||||||
|
}>(`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.transaction_date::text,
|
||||||
|
t.description,
|
||||||
|
t.amount,
|
||||||
|
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 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 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,
|
||||||
|
s.bank_name,
|
||||||
|
t.statement_id
|
||||||
|
FROM transactions t
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
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
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||||
|
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
|
||||||
|
ORDER BY t.transaction_date DESC
|
||||||
|
LIMIT 500
|
||||||
|
`, [user.id, decoded]);
|
||||||
|
|
||||||
|
return NextResponse.json({ transactions });
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
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 -(${MY_AMOUNT})
|
||||||
|
ELSE (${MY_AMOUNT})
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const months = Math.min(24, Math.max(1, Number(searchParams.get("months") || "12")));
|
||||||
|
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setMonth(cutoff.getMonth() - months);
|
||||||
|
const fromDate = cutoff.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Merchant aggregates — net spend (debits + fees - refunds/credits)
|
||||||
|
const rows = await queryRaw<{
|
||||||
|
merchant: string;
|
||||||
|
category: string;
|
||||||
|
debit_count: number;
|
||||||
|
refund_count: number;
|
||||||
|
gross_spend: number;
|
||||||
|
total_refunds: number;
|
||||||
|
net_spend: number;
|
||||||
|
avg_debit: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
months_active: number;
|
||||||
|
}>(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
|
||||||
|
MODE() WITHIN GROUP (ORDER BY COALESCE(o.category_override, t.category)) as category,
|
||||||
|
COUNT(*) FILTER (WHERE t.transaction_type IN ('debit', 'fee', 'interest'))::int as debit_count,
|
||||||
|
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
|
||||||
|
${MY_AMOUNT}
|
||||||
|
ELSE 0 END
|
||||||
|
), 0)::numeric(12,2) as gross_spend,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||||
|
${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
|
||||||
|
${MY_AMOUNT}
|
||||||
|
END
|
||||||
|
)::numeric(10,2) as avg_debit,
|
||||||
|
MIN(t.transaction_date)::text as first_seen,
|
||||||
|
MAX(t.transaction_date)::text as last_seen,
|
||||||
|
COUNT(DISTINCT TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM'))::int as months_active
|
||||||
|
FROM transactions t
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
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
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||||
|
AND t.transaction_date >= $2
|
||||||
|
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||||
|
GROUP BY 1
|
||||||
|
HAVING SUM(${SPEND_EXPR}) > 0
|
||||||
|
ORDER BY net_spend DESC
|
||||||
|
LIMIT 200
|
||||||
|
`, [user.id, fromDate]);
|
||||||
|
|
||||||
|
// Monthly net trend per merchant (top 50 by net spend)
|
||||||
|
const topMerchants = rows.slice(0, 50).map((r) => r.merchant);
|
||||||
|
|
||||||
|
interface TrendRow { merchant: string; month: string; total: number }
|
||||||
|
let trendRows: TrendRow[] = [];
|
||||||
|
if (topMerchants.length > 0) {
|
||||||
|
trendRows = await queryRaw<TrendRow>(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
|
||||||
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
|
SUM(${SPEND_EXPR})::numeric(10,2) as total
|
||||||
|
FROM transactions t
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
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
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||||
|
AND t.transaction_date >= $2
|
||||||
|
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
|
||||||
|
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY 1, 2
|
||||||
|
`, [user.id, fromDate, topMerchants]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendByMerchant: Record<string, Record<string, number>> = {};
|
||||||
|
for (const tr of trendRows) {
|
||||||
|
if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {};
|
||||||
|
trendByMerchant[tr.merchant][tr.month] = Number(tr.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merchants = rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
debit_count: Number(r.debit_count),
|
||||||
|
refund_count: Number(r.refund_count),
|
||||||
|
gross_spend: Number(r.gross_spend),
|
||||||
|
total_refunds: Number(r.total_refunds),
|
||||||
|
net_spend: Number(r.net_spend),
|
||||||
|
avg_debit: Number(r.avg_debit),
|
||||||
|
months_active: Number(r.months_active),
|
||||||
|
monthly_trend: trendByMerchant[r.merchant] || {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ merchants, months });
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const startStr = startDate.toISOString().slice(0, 10);
|
const startStr = startDate.toISOString().slice(0, 10);
|
||||||
const endStr = endDate.toISOString().slice(0, 10);
|
const endStr = endDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Expenses: debits excluding transfers and investments, split-adjusted
|
||||||
const spendRows = await queryRaw<{
|
const spendRows = await queryRaw<{
|
||||||
month: string;
|
month: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -25,13 +26,21 @@ export async function GET(req: NextRequest) {
|
|||||||
`SELECT
|
`SELECT
|
||||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
COALESCE(o.category_override, t.category) as category,
|
COALESCE(o.category_override, t.category) as category,
|
||||||
SUM(t.amount)::numeric(12,2) as total_spent,
|
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,
|
||||||
COUNT(*)::int as transaction_count
|
COUNT(*)::int as transaction_count
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
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
|
JOIN statements s ON s.id = t.statement_id
|
||||||
WHERE s.owner_id = $1
|
WHERE s.owner_id = $1
|
||||||
AND t.transaction_type = 'debit'
|
AND t.transaction_type IN ('debit', 'fee', 'interest')
|
||||||
|
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||||
AND t.transaction_date >= $2
|
AND t.transaction_date >= $2
|
||||||
AND t.transaction_date < $3
|
AND t.transaction_date < $3
|
||||||
GROUP BY 1, 2
|
GROUP BY 1, 2
|
||||||
@@ -39,10 +48,48 @@ export async function GET(req: NextRequest) {
|
|||||||
[user.id, startStr, endStr]
|
[user.id, startStr, endStr]
|
||||||
);
|
);
|
||||||
|
|
||||||
const budgetRows = await queryRaw<{ month: string; category: string; amount_limit: number }>(
|
// Income: credits/payments categorised as income
|
||||||
`SELECT TO_CHAR(month, 'YYYY-MM') as month, category, amount_limit::numeric
|
const incomeRows = await queryRaw<{
|
||||||
FROM budgets
|
month: string;
|
||||||
WHERE owner_id = $1 AND month >= $2::date AND month < $3::date`,
|
total_income: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
|
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_income,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND t.transaction_type IN ('credit', 'payment')
|
||||||
|
AND COALESCE(o.category_override, t.category) = 'income'
|
||||||
|
AND t.transaction_date >= $2
|
||||||
|
AND t.transaction_date < $3
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 DESC`,
|
||||||
|
[user.id, startStr, endStr]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Investments: any transaction categorised as investment
|
||||||
|
const investmentRows = await queryRaw<{
|
||||||
|
month: string;
|
||||||
|
total_invested: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
|
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_invested,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND COALESCE(o.category_override, t.category) = 'investment'
|
||||||
|
AND t.transaction_date >= $2
|
||||||
|
AND t.transaction_date < $3
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 DESC`,
|
||||||
[user.id, startStr, endStr]
|
[user.id, startStr, endStr]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,50 +103,46 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const spendMap = new Map<string, number>();
|
const spendMap = new Map<string, number>();
|
||||||
const countMap = new Map<string, number>();
|
const countMap = new Map<string, number>();
|
||||||
const budgetMap = new Map<string, number>();
|
const incomeMap = new Map<string, number>();
|
||||||
|
const investMap = new Map<string, number>();
|
||||||
|
|
||||||
for (const r of spendRows) {
|
for (const r of spendRows) {
|
||||||
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
||||||
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
||||||
}
|
}
|
||||||
for (const r of budgetRows) {
|
for (const r of incomeRows) incomeMap.set(r.month, Number(r.total_income));
|
||||||
budgetMap.set(`${r.category}:${r.month}`, Number(r.amount_limit));
|
for (const r of investmentRows) investMap.set(r.month, Number(r.total_invested));
|
||||||
}
|
|
||||||
|
|
||||||
const allCategories = new Set<string>();
|
const allCategories = new Set<string>();
|
||||||
for (const r of spendRows) allCategories.add(r.category);
|
for (const r of spendRows) allCategories.add(r.category);
|
||||||
for (const r of budgetRows) allCategories.add(r.category);
|
|
||||||
|
|
||||||
const rows = Array.from(allCategories)
|
const rows = Array.from(allCategories)
|
||||||
.sort()
|
.sort()
|
||||||
.map((cat) => {
|
.map((cat) => {
|
||||||
const spent: Record<string, number> = {};
|
const spent: Record<string, number> = {};
|
||||||
const budget: Record<string, number> = {};
|
|
||||||
const txCount: Record<string, number> = {};
|
const txCount: Record<string, number> = {};
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
const s = spendMap.get(`${cat}:${m}`);
|
const s = spendMap.get(`${cat}:${m}`);
|
||||||
const b = budgetMap.get(`${cat}:${m}`);
|
|
||||||
const c = countMap.get(`${cat}:${m}`);
|
const c = countMap.get(`${cat}:${m}`);
|
||||||
if (s !== undefined) spent[m] = s;
|
if (s !== undefined) spent[m] = s;
|
||||||
if (b !== undefined) budget[m] = b;
|
|
||||||
if (c !== undefined) txCount[m] = c;
|
if (c !== undefined) txCount[m] = c;
|
||||||
}
|
}
|
||||||
return { category: cat, spent, budget, txCount };
|
return { category: cat, spent, txCount };
|
||||||
});
|
});
|
||||||
|
|
||||||
const totals: Record<string, { spent: number; budget: number }> = {};
|
const totals: Record<string, { spent: number; income: number; investments: number; net: number }> = {};
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
let s = 0;
|
let spent = 0;
|
||||||
let b = 0;
|
for (const row of rows) spent += row.spent[m] || 0;
|
||||||
for (const row of rows) {
|
const income = incomeMap.get(m) || 0;
|
||||||
s += row.spent[m] || 0;
|
const investments = investMap.get(m) || 0;
|
||||||
b += row.budget[m] || 0;
|
|
||||||
}
|
|
||||||
totals[m] = {
|
totals[m] = {
|
||||||
spent: Math.round(s * 100) / 100,
|
spent: Math.round(spent * 100) / 100,
|
||||||
budget: Math.round(b * 100) / 100,
|
income: Math.round(income * 100) / 100,
|
||||||
|
investments: Math.round(investments * 100) / 100,
|
||||||
|
net: Math.round((income - spent - investments) * 100) / 100,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ months, rows, totals });
|
return NextResponse.json({ months, rows, income: Object.fromEntries(incomeMap), investments: Object.fromEntries(investMap), totals });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const rows = await queryRaw<{
|
||||||
|
merchant: string;
|
||||||
|
category: string;
|
||||||
|
occurrences: number;
|
||||||
|
avg_amount: string;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
total_paid: string;
|
||||||
|
median_interval: string;
|
||||||
|
frequency: string | null;
|
||||||
|
}>(
|
||||||
|
`WITH merchant_txns AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) AS merchant,
|
||||||
|
COALESCE(o.category_override, t.category) AS category,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
AND t.transaction_type IN ('debit', 'fee')
|
||||||
|
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL
|
||||||
|
),
|
||||||
|
merchant_with_lag AS (
|
||||||
|
SELECT
|
||||||
|
merchant,
|
||||||
|
category,
|
||||||
|
transaction_date,
|
||||||
|
my_amount,
|
||||||
|
LAG(transaction_date) OVER (PARTITION BY merchant ORDER BY transaction_date) AS prev_date
|
||||||
|
FROM merchant_txns
|
||||||
|
),
|
||||||
|
merchant_stats AS (
|
||||||
|
SELECT
|
||||||
|
merchant,
|
||||||
|
MODE() WITHIN GROUP (ORDER BY category) AS category,
|
||||||
|
COUNT(*) + 1 AS occurrences,
|
||||||
|
AVG(my_amount)::numeric(12,2) AS avg_amount,
|
||||||
|
MIN(transaction_date) AS first_seen,
|
||||||
|
MAX(transaction_date) AS last_seen,
|
||||||
|
SUM(my_amount)::numeric(12,2) AS total_paid,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
||||||
|
ORDER BY (transaction_date - prev_date)::int
|
||||||
|
) AS median_interval,
|
||||||
|
STDDEV((transaction_date - prev_date)::int) AS stddev_interval
|
||||||
|
FROM merchant_with_lag
|
||||||
|
WHERE prev_date IS NOT NULL
|
||||||
|
GROUP BY merchant
|
||||||
|
HAVING COUNT(*) >= 2
|
||||||
|
),
|
||||||
|
classified AS (
|
||||||
|
SELECT *,
|
||||||
|
CASE
|
||||||
|
WHEN median_interval BETWEEN 6 AND 8 THEN 'weekly'
|
||||||
|
WHEN median_interval BETWEEN 13 AND 16 THEN 'fortnightly'
|
||||||
|
WHEN median_interval BETWEEN 27 AND 35 THEN 'monthly'
|
||||||
|
WHEN median_interval BETWEEN 85 AND 95 THEN 'quarterly'
|
||||||
|
WHEN median_interval BETWEEN 350 AND 380 THEN 'annual'
|
||||||
|
ELSE NULL
|
||||||
|
END AS frequency
|
||||||
|
FROM merchant_stats
|
||||||
|
WHERE stddev_interval < median_interval * 0.4
|
||||||
|
AND (
|
||||||
|
median_interval BETWEEN 6 AND 8 OR
|
||||||
|
median_interval BETWEEN 13 AND 16 OR
|
||||||
|
median_interval BETWEEN 27 AND 35 OR
|
||||||
|
median_interval BETWEEN 85 AND 95 OR
|
||||||
|
median_interval BETWEEN 350 AND 380
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT merchant, category, occurrences, avg_amount, first_seen, last_seen, total_paid,
|
||||||
|
median_interval::numeric(8,1), frequency
|
||||||
|
FROM classified
|
||||||
|
WHERE frequency IS NOT NULL
|
||||||
|
ORDER BY
|
||||||
|
CASE frequency
|
||||||
|
WHEN 'weekly' THEN avg_amount * 4.33
|
||||||
|
WHEN 'fortnightly' THEN avg_amount * 2.17
|
||||||
|
WHEN 'monthly' THEN avg_amount
|
||||||
|
WHEN 'quarterly' THEN avg_amount / 3
|
||||||
|
WHEN 'annual' THEN avg_amount / 12
|
||||||
|
END DESC NULLS LAST`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const subscriptions = rows.map((r) => {
|
||||||
|
const lastSeen = new Date(r.last_seen);
|
||||||
|
const daysSinceLast = Math.floor((today.getTime() - lastSeen.getTime()) / 86400000);
|
||||||
|
const medianInterval = Number(r.median_interval);
|
||||||
|
const is_active = daysSinceLast < medianInterval * 1.5;
|
||||||
|
|
||||||
|
const avg = Number(r.avg_amount);
|
||||||
|
const monthly_equiv =
|
||||||
|
r.frequency === "weekly" ? avg * 4.33 :
|
||||||
|
r.frequency === "fortnightly" ? avg * 2.17 :
|
||||||
|
r.frequency === "quarterly" ? avg / 3 :
|
||||||
|
r.frequency === "annual" ? avg / 12 :
|
||||||
|
avg;
|
||||||
|
|
||||||
|
return {
|
||||||
|
merchant: r.merchant,
|
||||||
|
category: r.category,
|
||||||
|
frequency: r.frequency,
|
||||||
|
avg_amount: avg,
|
||||||
|
monthly_equiv: Math.round(monthly_equiv * 100) / 100,
|
||||||
|
first_seen: r.first_seen,
|
||||||
|
last_seen: r.last_seen,
|
||||||
|
occurrences: r.occurrences,
|
||||||
|
total_paid: Number(r.total_paid),
|
||||||
|
is_active,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const total_monthly_equiv = subscriptions
|
||||||
|
.filter((s) => s.is_active)
|
||||||
|
.reduce((sum, s) => sum + s.monthly_equiv, 0);
|
||||||
|
|
||||||
|
return NextResponse.json({ subscriptions, total_monthly_equiv: Math.round(total_monthly_equiv * 100) / 100 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { ensureTag, batchInsertCSVTransactions } from "@/lib/queries";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
bank_name: string;
|
||||||
|
transactions: {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
merchant_name?: string;
|
||||||
|
foreign_currency_amount?: number;
|
||||||
|
foreign_currency_code?: string;
|
||||||
|
category?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(body.transactions) || body.transactions.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No transactions provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagId = await ensureTag("csv-import", "#8b5cf6");
|
||||||
|
const inserted = await batchInsertCSVTransactions(user.id, body.transactions, tagId);
|
||||||
|
|
||||||
|
return NextResponse.json({ inserted }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return NextResponse.json(user);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
if (type === "banks") {
|
if (type === "banks") {
|
||||||
const banks = await getBankNames();
|
const banks = await getBankNames();
|
||||||
return NextResponse.json(banks.map((b) => b.bank_name));
|
return NextResponse.json(banks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!search) return NextResponse.json([]);
|
if (!search) return NextResponse.json([]);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
interface BalanceRow {
|
||||||
|
participant_id: number;
|
||||||
|
name: string;
|
||||||
|
total_owed: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const rows = await queryRaw<BalanceRow>(
|
||||||
|
`SELECT ts.participant_id, p.name,
|
||||||
|
SUM(t.amount * ts.share_percent / 100)::numeric(12,2) as total_owed,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
WHERE ts.participant_id = $1 AND ts.settled = false
|
||||||
|
GROUP BY ts.participant_id, p.name`,
|
||||||
|
[Number(id)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getParticipantBalances } from "@/lib/queries";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const tagParam = req.nextUrl.searchParams.get("tag_ids");
|
||||||
|
const tagIds = tagParam ? tagParam.split(",").map(Number).filter(Boolean) : undefined;
|
||||||
|
const balances = await getParticipantBalances(user.id, tagIds);
|
||||||
|
return NextResponse.json(balances);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
const participants = await prisma.participants.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
participants.map((p) => (p.id === user.id ? { ...p, name: "Me" } : p))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { name, email } = await req.json();
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const participant = await prisma.participants.create({
|
||||||
|
data: { name: name.trim(), email: email?.trim() || null },
|
||||||
|
});
|
||||||
|
return NextResponse.json(participant, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { getPendingReconciliations } from "@/lib/queries";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const data = await getPendingReconciliations(user.id);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw, prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const existing = await queryRaw<{ id: number }>(
|
||||||
|
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const updated = await prisma.rules.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
...(body.name !== undefined && { name: body.name }),
|
||||||
|
...(body.conditions !== undefined && { conditions: body.conditions }),
|
||||||
|
...(body.actions !== undefined && { actions: body.actions }),
|
||||||
|
...(body.enabled !== undefined && { enabled: body.enabled }),
|
||||||
|
...(body.priority !== undefined && { priority: body.priority }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await queryRaw<{ id: number }>(
|
||||||
|
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.rules.delete({ where: { id: Number(id) } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import { getTransactions } from "@/lib/queries";
|
||||||
|
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
||||||
|
|
||||||
|
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 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 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null; ruleId?: number | null };
|
||||||
|
const splitFrom = body.splitFrom || null;
|
||||||
|
const ruleId = body.ruleId || null;
|
||||||
|
|
||||||
|
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
||||||
|
ruleId
|
||||||
|
? `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND id = $2`
|
||||||
|
: `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
||||||
|
ruleId ? [user.id, ruleId] : [user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||||
|
|
||||||
|
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 { conditions, actions } of parsedRules) {
|
||||||
|
for (const tx of transactions) {
|
||||||
|
const allMatch = conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
||||||
|
if (!allMatch) continue;
|
||||||
|
|
||||||
|
matched++;
|
||||||
|
affectedIds.add(tx.id);
|
||||||
|
|
||||||
|
if (actions.set_category || actions.set_merchant) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (transaction_id) DO UPDATE SET
|
||||||
|
category_override = COALESCE($2, transaction_overrides.category_override),
|
||||||
|
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[tx.id, actions.set_category || null, actions.set_merchant || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.add_tag_ids?.length) {
|
||||||
|
for (const tagId of actions.add_tag_ids) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
[tx.id, tagId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.apply_split?.length) {
|
||||||
|
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(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
[tx.id, s.participant_id, s.share_percent]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw, prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const rows = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
conditions: unknown;
|
||||||
|
actions: unknown;
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name, conditions, actions, enabled, priority, created_at
|
||||||
|
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
|
||||||
|
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||||
|
|
||||||
|
const rule = await prisma.rules.create({
|
||||||
|
data: {
|
||||||
|
owner_id: user.id,
|
||||||
|
name,
|
||||||
|
conditions: conditions ?? [],
|
||||||
|
actions: actions ?? {},
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(rule, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSharedTransactions } from "@/lib/queries";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const tagParam = req.nextUrl.searchParams.get("tag_ids");
|
||||||
|
const rawIds = tagParam ? tagParam.split(",").filter(Boolean) : [];
|
||||||
|
const noTags = rawIds.includes("untagged");
|
||||||
|
const tagIds = rawIds.filter((id) => id !== "untagged").map(Number).filter((n) => !isNaN(n));
|
||||||
|
const transactions = await getSharedTransactions(user.id, tagIds.length ? tagIds : undefined, noTags);
|
||||||
|
return NextResponse.json(transactions);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const sp = req.nextUrl.searchParams;
|
||||||
|
const participantId = sp.get("participant_id");
|
||||||
|
|
||||||
|
// Return payment history between current user and a participant
|
||||||
|
const rows = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
from_participant_id: number;
|
||||||
|
from_name: string;
|
||||||
|
to_participant_id: number;
|
||||||
|
to_name: string;
|
||||||
|
amount: number;
|
||||||
|
payment_date: string;
|
||||||
|
notes: string | null;
|
||||||
|
linked_transaction_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`SELECT sp.id, sp.from_participant_id, pf.name as from_name,
|
||||||
|
sp.to_participant_id, pt.name as to_name,
|
||||||
|
sp.amount, sp.payment_date, sp.notes,
|
||||||
|
sp.linked_transaction_id, sp.created_at
|
||||||
|
FROM split_payments sp
|
||||||
|
JOIN participants pf ON pf.id = sp.from_participant_id
|
||||||
|
JOIN participants pt ON pt.id = sp.to_participant_id
|
||||||
|
WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1)
|
||||||
|
AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2)
|
||||||
|
ORDER BY sp.payment_date DESC, sp.created_at DESC`,
|
||||||
|
[user.id, participantId ? Number(participantId) : user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const body = await req.json() as {
|
||||||
|
from_participant_id: number;
|
||||||
|
to_participant_id: number;
|
||||||
|
amount: number;
|
||||||
|
payment_date: string;
|
||||||
|
notes?: string;
|
||||||
|
linked_transaction_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id } = body;
|
||||||
|
|
||||||
|
if (!from_participant_id || !to_participant_id || !amount || !payment_date) {
|
||||||
|
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (amount <= 0) {
|
||||||
|
return NextResponse.json({ error: "Amount must be positive" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await prisma.split_payments.create({
|
||||||
|
data: {
|
||||||
|
from_participant_id,
|
||||||
|
to_participant_id,
|
||||||
|
amount,
|
||||||
|
payment_date: new Date(payment_date),
|
||||||
|
notes: notes || null,
|
||||||
|
linked_transaction_id: linked_transaction_id || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const sp = req.nextUrl.searchParams;
|
||||||
|
const id = Number(sp.get("id"));
|
||||||
|
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||||
|
|
||||||
|
await prisma.split_payments.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json();
|
||||||
|
const { participant_id, split_ids } = body as {
|
||||||
|
participant_id?: number;
|
||||||
|
split_ids?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (participant_id) {
|
||||||
|
const result = await prisma.transaction_splits.updateMany({
|
||||||
|
where: { participant_id, settled: false },
|
||||||
|
data: { settled: true, settled_at: now },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ settled: result.count });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (split_ids?.length) {
|
||||||
|
const result = await prisma.transaction_splits.updateMany({
|
||||||
|
where: { id: { in: split_ids }, settled: false },
|
||||||
|
data: { settled: true, settled_at: now },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ settled: result.count });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "participant_id or split_ids required" }, { status: 400 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getTransactionById } from "@/lib/queries";
|
import { getTransactionById } from "@/lib/queries";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
@@ -20,16 +23,66 @@ export async function PATCH(
|
|||||||
const transactionId = Number(id);
|
const transactionId = Number(id);
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
const { category, merchant_normalized, notes } = body as {
|
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date } = body as {
|
||||||
category?: string;
|
category?: string;
|
||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
notes?: 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)) {
|
||||||
|
return NextResponse.json({ error: "Invalid transaction_type" }, { status: 400 });
|
||||||
|
}
|
||||||
|
await queryRaw(
|
||||||
|
`UPDATE transactions SET transaction_type = $1 WHERE id = $2`,
|
||||||
|
[transaction_type, transactionId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
const data: Record<string, unknown> = { updated_at: new Date() };
|
const data: Record<string, unknown> = { updated_at: new Date() };
|
||||||
if (category !== undefined) data.category_override = category;
|
if (category !== undefined) data.category_override = category;
|
||||||
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
||||||
if (notes !== undefined) data.notes = notes;
|
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({
|
const override = await prisma.transaction_overrides.upsert({
|
||||||
where: { transaction_id: transactionId },
|
where: { transaction_id: transactionId },
|
||||||
@@ -39,6 +92,7 @@ export async function PATCH(
|
|||||||
category_override: category || null,
|
category_override: category || null,
|
||||||
merchant_normalized: merchant_normalized || null,
|
merchant_normalized: merchant_normalized || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
|
my_share_percent: my_share_percent != null ? String(my_share_percent) : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
interface SplitInput {
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitRow {
|
||||||
|
id: number;
|
||||||
|
transaction_id: number;
|
||||||
|
participant_id: number;
|
||||||
|
name: string;
|
||||||
|
share_percent: number;
|
||||||
|
settled: boolean;
|
||||||
|
settled_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const splits = await queryRaw<SplitRow>(
|
||||||
|
`SELECT ts.*, p.name
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
WHERE ts.transaction_id = $1
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[Number(id)]
|
||||||
|
);
|
||||||
|
return NextResponse.json(splits);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const transactionId = Number(id);
|
||||||
|
const { splits } = (await req.json()) as { splits: SplitInput[] };
|
||||||
|
|
||||||
|
if (!splits || !Array.isArray(splits) || splits.length === 0) {
|
||||||
|
return NextResponse.json({ error: "splits array required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = splits.reduce((sum, s) => sum + Number(s.share_percent), 0);
|
||||||
|
if (Math.abs(total - 100) > 0.01) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Shares must sum to 100%, got ${total}%` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace all splits for this transaction atomically
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.transaction_splits.deleteMany({ where: { transaction_id: transactionId } }),
|
||||||
|
...splits.map((s) =>
|
||||||
|
prisma.transaction_splits.create({
|
||||||
|
data: {
|
||||||
|
transaction_id: transactionId,
|
||||||
|
participant_id: s.participant_id,
|
||||||
|
share_percent: s.share_percent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await queryRaw<SplitRow>(
|
||||||
|
`SELECT ts.*, p.name FROM transaction_splits ts
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
WHERE ts.transaction_id = $1 ORDER BY p.name`,
|
||||||
|
[transactionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { prisma, queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
matches: { manual_id: number; statement_tx_id: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(body.matches) || body.matches.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No matches provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all manual_ids belong to this user
|
||||||
|
const manualIds = body.matches.map((m) => m.manual_id);
|
||||||
|
const owned = await queryRaw<{ id: number }>(
|
||||||
|
`SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND owner_id = $2`,
|
||||||
|
[manualIds, user.id]
|
||||||
|
);
|
||||||
|
if (owned.length !== manualIds.length) {
|
||||||
|
return NextResponse.json({ error: "One or more transactions not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let reconciled = 0;
|
||||||
|
|
||||||
|
for (const { manual_id, statement_tx_id } of body.matches) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Copy overrides: manual → statement tx
|
||||||
|
const override = await tx.transaction_overrides.findUnique({
|
||||||
|
where: { transaction_id: manual_id },
|
||||||
|
});
|
||||||
|
if (override) {
|
||||||
|
await tx.transaction_overrides.upsert({
|
||||||
|
where: { transaction_id: statement_tx_id },
|
||||||
|
update: {
|
||||||
|
category_override: override.category_override,
|
||||||
|
merchant_normalized: override.merchant_normalized,
|
||||||
|
notes: override.notes,
|
||||||
|
my_share_percent: override.my_share_percent,
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
transaction_id: statement_tx_id,
|
||||||
|
category_override: override.category_override,
|
||||||
|
merchant_normalized: override.merchant_normalized,
|
||||||
|
notes: override.notes,
|
||||||
|
my_share_percent: override.my_share_percent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy tags: manual → statement tx
|
||||||
|
const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } });
|
||||||
|
if (tags.length) {
|
||||||
|
await tx.transaction_tags.createMany({
|
||||||
|
data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy splits: manual → statement tx
|
||||||
|
const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } });
|
||||||
|
if (splits.length) {
|
||||||
|
await tx.transaction_splits.createMany({
|
||||||
|
data: splits.map((s) => ({
|
||||||
|
transaction_id: statement_tx_id,
|
||||||
|
participant_id: s.participant_id,
|
||||||
|
share_percent: s.share_percent,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark manual tx as reconciled (link to statement tx)
|
||||||
|
await tx.$executeRawUnsafe(
|
||||||
|
`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`,
|
||||||
|
statement_tx_id,
|
||||||
|
manual_id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
reconciled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ reconciled });
|
||||||
|
}
|
||||||
@@ -1,25 +1,82 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { getTransactions } from "@/lib/queries";
|
import { getTransactions } from "@/lib/queries";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
const sp = req.nextUrl.searchParams;
|
const sp = req.nextUrl.searchParams;
|
||||||
|
const parseArr = (key: string) => { const v = sp.get(key); return v ? v.split(",").filter(Boolean) : undefined; };
|
||||||
const result = await getTransactions(user.id, {
|
const result = await getTransactions(user.id, {
|
||||||
from: sp.get("from") || undefined,
|
from: sp.get("from") || undefined,
|
||||||
to: sp.get("to") || undefined,
|
to: sp.get("to") || undefined,
|
||||||
category: sp.get("category") || undefined,
|
categories: parseArr("categories"),
|
||||||
bank_name: sp.get("bank_name") || undefined,
|
bank_names: parseArr("bank_names"),
|
||||||
|
tag_ids: parseArr("tag_ids"),
|
||||||
|
transaction_types: parseArr("transaction_types"),
|
||||||
search: sp.get("search") || undefined,
|
search: sp.get("search") || undefined,
|
||||||
statement_id: sp.get("statement_id") || undefined,
|
statement_id: sp.get("statement_id") || undefined,
|
||||||
tag_id: sp.get("tag_id") || undefined,
|
|
||||||
sort_by: sp.get("sort_by") || undefined,
|
sort_by: sp.get("sort_by") || undefined,
|
||||||
sort_dir: sp.get("sort_dir") || undefined,
|
sort_dir: sp.get("sort_dir") || undefined,
|
||||||
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,
|
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,
|
||||||
offset: sp.get("offset") ? Number(sp.get("offset")) : undefined,
|
offset: sp.get("offset") ? Number(sp.get("offset")) : undefined,
|
||||||
|
amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined,
|
||||||
|
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
|
||||||
|
has_split: sp.get("has_split") || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(result);
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
+558
-305
@@ -1,330 +1,556 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, Fragment, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
useBudgets,
|
ComposedChart,
|
||||||
useUpsertBudget,
|
LineChart,
|
||||||
useDeleteBudget,
|
Bar,
|
||||||
useMonthlyAnalytics,
|
Line,
|
||||||
} from "@/lib/hooks";
|
XAxis,
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
function formatMonth(m: string): string {
|
ResponsiveContainer,
|
||||||
const [year, month] = m.split("-");
|
Cell,
|
||||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
ReferenceLine,
|
||||||
return date.toLocaleString("default", { month: "long", year: "numeric" });
|
Legend,
|
||||||
}
|
} from "recharts";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
function prevMonth(m: string): string {
|
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||||
const [year, month] = m.split("-").map(Number);
|
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||||
const d = new Date(year, month - 2, 1);
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextMonth(m: string): string {
|
|
||||||
const [year, month] = m.split("-").map(Number);
|
|
||||||
const d = new Date(year, month, 1);
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentMonthStr(): string {
|
function currentMonthStr(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
function prevMonth(m: string): string {
|
||||||
function barColor(pct: number): string {
|
const [year, month] = m.split("-").map(Number);
|
||||||
if (pct > 100) return "bg-red-500";
|
const d = new Date(year, month - 2, 1);
|
||||||
if (pct > 80) return "bg-yellow-400";
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
return "bg-emerald-500";
|
}
|
||||||
|
function nextMonth(m: string): string {
|
||||||
|
const [year, month] = m.split("-").map(Number);
|
||||||
|
const d = new Date(year, month, 1);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
function formatMonth(m: string): string {
|
||||||
|
const [year, month] = m.split("-");
|
||||||
|
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
function formatShortMonth(m: string): string {
|
||||||
|
const [year, month] = m.split("-");
|
||||||
|
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" });
|
||||||
|
}
|
||||||
|
function fmt(n: number): string { return `$${n.toFixed(0)}`; }
|
||||||
|
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
|
||||||
|
function deltaColor(n: number): string {
|
||||||
|
if (n > 0) return "text-red-400";
|
||||||
|
if (n < 0) return "text-emerald-400";
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BudgetPage() {
|
const TOOLTIP_STYLE = { background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 };
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
groceries: "#22c55e",
|
||||||
|
dining: "#f97316",
|
||||||
|
transport: "#06b6d4",
|
||||||
|
fuel: "#eab308",
|
||||||
|
shopping: "#ec4899",
|
||||||
|
utilities: "#8b5cf6",
|
||||||
|
entertainment: "#f43f5e",
|
||||||
|
travel: "#0ea5e9",
|
||||||
|
health: "#10b981",
|
||||||
|
insurance: "#64748b",
|
||||||
|
subscriptions: "#a78bfa",
|
||||||
|
cash_advance: "#dc2626",
|
||||||
|
government: "#78716c",
|
||||||
|
education: "#3b82f6",
|
||||||
|
rent: "#d97706",
|
||||||
|
transfers: "#6b7280",
|
||||||
|
income: "#34d399",
|
||||||
|
investment: "#818cf8",
|
||||||
|
personal_care: "#fb7185",
|
||||||
|
pets: "#86efac",
|
||||||
|
gifts: "#fcd34d",
|
||||||
|
charity: "#a3e635",
|
||||||
|
home_goods: "#67e8f9",
|
||||||
|
home_maintenance: "#c084fc",
|
||||||
|
other: "#71717a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tooltips ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TrendTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: string }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const items = payload.filter((p) => p.value > 0.01).sort((a, b) => b.value - a.value);
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-40">
|
||||||
|
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
|
||||||
|
{items.map((p) => (
|
||||||
|
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: p.stroke }} />
|
||||||
|
<span className="text-zinc-400">{formatCategory(p.dataKey.replace("cat_", ""))}:</span>
|
||||||
|
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParetoTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string; spent: number; pct: number; cumulative: number } }[] }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const d = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
||||||
|
<p className="font-medium text-zinc-300 mb-1">{formatCategory(d.category)}</p>
|
||||||
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Spend</span><span>{fmtExact(d.spent)}</span></div>
|
||||||
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Share</span><span>{d.pct}%</span></div>
|
||||||
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Cumulative</span><span>{d.cumulative}%</span></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CumulativeTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: number }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
||||||
|
<p className="text-zinc-400 mb-1 font-medium">Day {label}</p>
|
||||||
|
{payload.map((p) => p.value != null && (
|
||||||
|
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: p.stroke }} />
|
||||||
|
<span className="text-zinc-400">{p.dataKey === "actual" ? "Actual" : "Typical pace"}:</span>
|
||||||
|
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CategoryPanel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CategoryPanel({ category, selectedMonth }: { category: string; selectedMonth: string }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const updateTx = useUpdateTransaction();
|
||||||
|
const from = `${selectedMonth}-01`;
|
||||||
|
const [year, month] = selectedMonth.split("-").map(Number);
|
||||||
|
const nextDate = new Date(year, month, 1);
|
||||||
|
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 });
|
||||||
|
const txns = data?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-xs text-zinc-500 px-6 py-2">Loading...</p>
|
||||||
|
) : txns.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-600 px-6 py-2">No transactions</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-zinc-600">
|
||||||
|
<th className="text-left px-6 py-1 font-normal w-24">Date</th>
|
||||||
|
<th className="text-left px-2 py-1 font-normal">Description</th>
|
||||||
|
<th className="text-right px-2 py-1 font-normal w-24">Amount</th>
|
||||||
|
<th className="text-right px-4 py-1 font-normal w-36">Category</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{txns.map((tx) => (
|
||||||
|
<tr key={tx.id} className="border-t border-zinc-800/30 hover:bg-zinc-800/20">
|
||||||
|
<td className="px-6 py-1.5 text-zinc-500 tabular-nums">{tx.transaction_date.slice(5).replace("-", "/")}</td>
|
||||||
|
<td className="px-2 py-1.5 text-zinc-300 max-w-xs truncate">{tx.effective_merchant || tx.description}</td>
|
||||||
|
<td className="px-2 py-1.5 text-right tabular-nums text-zinc-300">{fmtExact(Number(tx.amount))}</td>
|
||||||
|
<td className="px-4 py-1.5 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 cursor-pointer"
|
||||||
|
defaultValue={tx.effective_category}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateTx.mutate({ id: tx.id, category: e.target.value }, {
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["analytics"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>{formatCategory(cat)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
||||||
const [editingCategory, setEditingCategory] = useState<string | null>(null);
|
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||||||
|
|
||||||
const { data: budgets = [], isLoading: budgetsLoading } = useBudgets(selectedMonth);
|
// Cumulative chart: fetch this month's transactions
|
||||||
const { data: analytics, isLoading: analyticsLoading } = useMonthlyAnalytics(6);
|
const smFrom = `${selectedMonth}-01`;
|
||||||
const upsertBudget = useUpsertBudget();
|
const [smYear, smMonth] = selectedMonth.split("-").map(Number);
|
||||||
const deleteBudget = useDeleteBudget();
|
const smNextDate = new Date(smYear, smMonth, 1);
|
||||||
|
const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 });
|
||||||
|
|
||||||
const budgetMap = new Map(budgets.map((b) => [b.category, b]));
|
const months = useMemo(() => analytics ? [...analytics.months].reverse() : [], [analytics]);
|
||||||
|
|
||||||
// Categories with spend this month or a budget set
|
// Category rows for selected month
|
||||||
const currentMonthRows = analytics?.rows.filter((r) => r.spent[selectedMonth] !== undefined) || [];
|
const categoryRows = useMemo(() => {
|
||||||
const allCategories = new Set<string>([
|
if (!analytics) return [];
|
||||||
...currentMonthRows.map((r) => r.category),
|
return analytics.rows
|
||||||
...budgets.map((b) => b.category),
|
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
|
||||||
]);
|
.map((r) => ({ category: r.category, spent: r.spent[selectedMonth] || 0, txCount: r.txCount[selectedMonth] || 0 }))
|
||||||
|
.sort((a, b) => b.spent - a.spent);
|
||||||
|
}, [analytics, selectedMonth]);
|
||||||
|
|
||||||
const tableRows = Array.from(allCategories)
|
// Trend line data — top 8 categories by total 6-month spend
|
||||||
.sort()
|
const trendData = useMemo(() => {
|
||||||
.map((cat) => {
|
if (!analytics) return { data: [], categories: [] };
|
||||||
const analyticsRow = analytics?.rows.find((r) => r.category === cat);
|
const categoryTotals = analytics.rows
|
||||||
const spent = analyticsRow?.spent[selectedMonth] || 0;
|
.map((r) => ({ category: r.category, total: months.reduce((s, m) => s + (r.spent[m] || 0), 0) }))
|
||||||
const txCount = analyticsRow?.txCount[selectedMonth] || 0;
|
.sort((a, b) => b.total - a.total)
|
||||||
const budget = budgetMap.get(cat);
|
.slice(0, 8)
|
||||||
const limit = budget ? Number(budget.amount_limit) : null;
|
.map((r) => r.category);
|
||||||
const remaining = limit !== null ? limit - spent : null;
|
|
||||||
const pct = limit !== null && limit > 0 ? (spent / limit) * 100 : null;
|
const data = months.map((m) => {
|
||||||
return { cat, spent, txCount, budget, limit, remaining, pct };
|
const entry: Record<string, unknown> = { month: m, label: formatShortMonth(m) };
|
||||||
|
for (const cat of categoryTotals) {
|
||||||
|
const row = analytics.rows.find((r) => r.category === cat);
|
||||||
|
entry[`cat_${cat}`] = row?.spent[m] || 0;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
});
|
});
|
||||||
|
return { data, categories: categoryTotals };
|
||||||
|
}, [analytics, months]);
|
||||||
|
|
||||||
const totalBudgeted = budgets.reduce((s, b) => s + Number(b.amount_limit), 0);
|
// Pareto chart data
|
||||||
const totalSpent = tableRows.reduce((s, r) => s + r.spent, 0);
|
const paretoData = useMemo(() => {
|
||||||
const overBudgetCount = tableRows.filter((r) => r.pct !== null && r.pct > 100).length;
|
const total = categoryRows.reduce((s, r) => s + r.spent, 0);
|
||||||
|
let running = 0;
|
||||||
|
return categoryRows.map((r) => {
|
||||||
|
running += r.spent;
|
||||||
|
return {
|
||||||
|
category: r.category,
|
||||||
|
spent: r.spent,
|
||||||
|
pct: total > 0 ? Math.round((r.spent / total) * 1000) / 10 : 0,
|
||||||
|
cumulative: total > 0 ? Math.round((running / total) * 1000) / 10 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [categoryRows]);
|
||||||
|
|
||||||
async function handleBudgetSave(cat: string) {
|
// Cumulative spend chart data
|
||||||
const val = parseFloat(editValue);
|
const cumulativeData = useMemo(() => {
|
||||||
if (isNaN(val) || val < 0) return;
|
const daysInMonth = new Date(smYear, smMonth, 0).getDate();
|
||||||
await upsertBudget.mutateAsync({ category: cat, month: selectedMonth, amount_limit: val });
|
const isCurrentMonth = selectedMonth === currentMonthStr();
|
||||||
setEditingCategory(null);
|
const today = new Date();
|
||||||
setEditValue("");
|
const lastDay = isCurrentMonth ? today.getDate() : daysInMonth;
|
||||||
|
|
||||||
|
// Daily spend from transactions
|
||||||
|
const daily: Record<number, number> = {};
|
||||||
|
(monthTxData?.data ?? [])
|
||||||
|
.filter((tx) => tx.transaction_type === "debit" && !["transfers", "investment"].includes(tx.effective_category))
|
||||||
|
.forEach((tx) => {
|
||||||
|
const day = new Date(tx.transaction_date).getDate();
|
||||||
|
daily[day] = (daily[day] || 0) + Number(tx.amount_aud ?? tx.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typical pace: avg of prior months (straight-line ramp)
|
||||||
|
const priorMonths = analytics?.months.filter((m) => m !== selectedMonth) ?? [];
|
||||||
|
const priorAvg = priorMonths.length > 0
|
||||||
|
? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let cum = 0;
|
||||||
|
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||||
|
const day = i + 1;
|
||||||
|
if (day <= lastDay) cum += daily[day] || 0;
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
actual: day <= lastDay ? Math.round(cum * 100) / 100 : null,
|
||||||
|
typical: Math.round((priorAvg * day / daysInMonth) * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [monthTxData, analytics, selectedMonth, smYear, smMonth]);
|
||||||
|
|
||||||
|
if (isLoading || !analytics) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold">Analytics</h2>
|
||||||
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||||||
|
const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||||||
|
const spentDelta = totals.spent - lastTotals.spent;
|
||||||
|
const largestCategory = categoryRows[0];
|
||||||
|
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
||||||
|
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
|
||||||
|
|
||||||
|
// Pareto: find where cumulative crosses 80%
|
||||||
|
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Month selector */}
|
|
||||||
|
{/* Header + month selector */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">Budget</h2>
|
<h2 className="text-xl font-semibold">Analytics</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button onClick={() => setSelectedMonth(prevMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none">‹</button>
|
||||||
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
|
|
||||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
|
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
|
||||||
<button
|
<button onClick={() => setSelectedMonth(nextMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none">›</button>
|
||||||
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
|
|
||||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
{hasIncome && (
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Budgeted</p>
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
<p className="text-2xl font-semibold">${totalBudgeted.toFixed(2)}</p>
|
<p className="text-xs text-zinc-500 mb-1">Income</p>
|
||||||
</div>
|
<p className="text-2xl font-semibold text-emerald-400">{fmtExact(totals.income)}</p>
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<p className="text-xs text-zinc-500 mt-1">received</p>
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
|
|
||||||
<p
|
|
||||||
className={`text-2xl font-semibold ${
|
|
||||||
totalBudgeted > 0 && totalSpent > totalBudgeted ? "text-red-400" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
${totalSpent.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
|
||||||
<p className="text-xs text-zinc-500 mb-1">Over Budget</p>
|
|
||||||
<p
|
|
||||||
className={`text-2xl font-semibold ${
|
|
||||||
overBudgetCount > 0 ? "text-red-400" : "text-emerald-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Budget table for current month */}
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-zinc-800">
|
|
||||||
<th className="text-left px-4 py-3 text-xs text-zinc-500 font-medium">Category</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Budget</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Spent</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Remaining</th>
|
|
||||||
<th className="px-4 py-3 text-xs text-zinc-500 font-medium w-36">Progress</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium"># Txns</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{budgetsLoading || analyticsLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
|
||||||
Loading...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : tableRows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
|
||||||
No spending data for this month. Set a budget for any category below.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => (
|
|
||||||
<tr key={cat} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
|
||||||
<td className="px-4 py-3 font-medium">{formatCategory(cat)}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
{editingCategory === cat ? (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleBudgetSave(cat);
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-24 bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-right text-sm"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleBudgetSave(cat)}
|
|
||||||
className="text-xs text-emerald-400 hover:text-emerald-300 px-1"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}}
|
|
||||||
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCategory(cat);
|
|
||||||
setEditValue(limit?.toString() || "");
|
|
||||||
}}
|
|
||||||
className="text-zinc-300 hover:text-white underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
{limit !== null ? (
|
|
||||||
`$${limit.toFixed(2)}`
|
|
||||||
) : (
|
|
||||||
<span className="text-zinc-600 italic">Set...</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{budget && (
|
|
||||||
<button
|
|
||||||
onClick={() => deleteBudget.mutate(budget.id)}
|
|
||||||
className="text-zinc-600 hover:text-red-400 text-xs ml-1"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">${spent.toFixed(2)}</td>
|
|
||||||
<td
|
|
||||||
className={`px-4 py-3 text-right ${
|
|
||||||
remaining !== null
|
|
||||||
? remaining < 0
|
|
||||||
? "text-red-400"
|
|
||||||
: "text-emerald-400"
|
|
||||||
: "text-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{remaining !== null ? `$${remaining.toFixed(2)}` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{pct !== null ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-zinc-800 rounded-full h-2 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${barColor(pct)}`}
|
|
||||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-zinc-400 w-10 text-right">
|
|
||||||
{pct.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-zinc-600 text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-zinc-400">{txCount}</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add budget for any category not yet shown */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-zinc-500 mb-2">Set budget for another category:</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CATEGORIES.filter((c) => !allCategories.has(c)).map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCategory(cat);
|
|
||||||
setEditValue("");
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 text-xs rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
{formatCategory(cat)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{editingCategory && !allCategories.has(editingCategory) && (
|
|
||||||
<div className="flex items-center gap-2 mt-3">
|
|
||||||
<span className="text-sm font-medium">{formatCategory(editingCategory)}</span>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleBudgetSave(editingCategory);
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-28 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
|
||||||
placeholder="Budget amount"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleBudgetSave(editingCategory)}
|
|
||||||
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
|
||||||
|
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
|
||||||
|
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
|
||||||
|
{spentDelta === 0 || lastTotals.spent === 0
|
||||||
|
? <span className="text-zinc-500">split-adjusted</span>
|
||||||
|
: `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{(hasInvestments || totals.investments > 0) && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">Invested</p>
|
||||||
|
<p className="text-2xl font-semibold text-indigo-400">{fmtExact(totals.investments)}</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">shares / ETFs</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
|
||||||
|
{hasIncome ? (
|
||||||
|
<>
|
||||||
|
<p className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||||
|
{totals.net >= 0 ? "+" : ""}{fmtExact(totals.net)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">income − expenses − invested</p>
|
||||||
|
</>
|
||||||
|
) : largestCategory ? (
|
||||||
|
<>
|
||||||
|
<p className="text-2xl font-semibold">{fmtExact(largestCategory.spent)}</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 1. Category trend lines */}
|
||||||
|
{trendData.categories.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-medium mb-4">Category Trends — 6 Months</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<LineChart
|
||||||
|
data={trendData.data}
|
||||||
|
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
|
||||||
|
onClick={(d) => {
|
||||||
|
const month = (d as any)?.activePayload?.[0]?.payload?.month as string | undefined;
|
||||||
|
if (month) setSelectedMonth(month);
|
||||||
|
}}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#71717a", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} width={52} />
|
||||||
|
<Tooltip content={<TrendTooltip />} cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} />
|
||||||
|
{trendData.categories.map((cat) => (
|
||||||
|
<Line
|
||||||
|
key={cat}
|
||||||
|
dataKey={`cat_${cat}`}
|
||||||
|
name={cat}
|
||||||
|
stroke={CATEGORY_COLORS[cat] || "#71717a"}
|
||||||
|
strokeWidth={selectedMonth && trendData.data.some((d) => (d as any).month === selectedMonth) ? 2 : 2}
|
||||||
|
dot={{ fill: CATEGORY_COLORS[cat] || "#71717a", r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-3 pt-3 border-t border-zinc-800">
|
||||||
|
{trendData.categories.map((cat) => (
|
||||||
|
<span key={cat} className="flex items-center gap-1 text-xs text-zinc-500">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[cat] || "#71717a" }} />
|
||||||
|
{formatCategory(cat)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{analytics.rows.length > 8 && (
|
||||||
|
<span className="text-xs text-zinc-600">+ {analytics.rows.length - 8} more in table below</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2. Pareto chart */}
|
||||||
|
{paretoData.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium">Spend Concentration — {formatMonth(selectedMonth)}</h3>
|
||||||
|
{pareto80idx >= 0 && (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Top {pareto80idx + 1} categor{pareto80idx === 0 ? "y" : "ies"} = 80% of spend
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<ComposedChart data={paretoData} margin={{ top: 4, right: 48, bottom: 0, left: 8 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="category"
|
||||||
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={formatCategory}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `$${v}`}
|
||||||
|
width={52}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
domain={[0, 100]}
|
||||||
|
width={36}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ParetoTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||||
|
<ReferenceLine yAxisId="right" y={80} stroke="#52525b" strokeDasharray="4 2" label={{ value: "80%", fill: "#71717a", fontSize: 10, position: "right" }} />
|
||||||
|
<Bar yAxisId="left" dataKey="spent" radius={[3, 3, 0, 0]} maxBarSize={40}>
|
||||||
|
{paretoData.map((entry, i) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.category}
|
||||||
|
fill={i <= pareto80idx ? (CATEGORY_COLORS[entry.category] || "#6366f1") : "#3f3f46"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
dataKey="cumulative"
|
||||||
|
stroke="#fbbf24"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: "#fbbf24", r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex items-center gap-4 mt-2 justify-end">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-0.5 bg-amber-400 inline-block" />Cumulative %</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" />Below 80% threshold</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. Cumulative spend this month */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium">Cumulative Spend — {formatMonth(selectedMonth)}</h3>
|
||||||
|
<span className="text-xs text-zinc-500">vs avg monthly pace</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart data={cumulativeData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
||||||
|
<XAxis dataKey="day" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `${v}`} interval={4} />
|
||||||
|
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(1)}k`} width={44} />
|
||||||
|
<Tooltip content={<CumulativeTooltip />} cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} />
|
||||||
|
<Line dataKey="typical" stroke="#3f3f46" strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="typical" />
|
||||||
|
<Line dataKey="actual" stroke="#6366f1" strokeWidth={2} dot={false} activeDot={{ r: 4 }} connectNulls={false} name="actual" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex items-center gap-4 mt-2 justify-end">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 bg-indigo-500 inline-block" />This month</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 bg-zinc-600 inline-block" style={{ borderTop: "1px dashed #52525b", display: "inline-block" }} />Typical pace</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category breakdown table — expandable rows */}
|
||||||
|
{categoryRows.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-zinc-800">
|
||||||
|
<h3 className="text-sm font-medium">Spending Breakdown — {formatMonth(selectedMonth)}</h3>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categoryRows.map(({ category, spent, txCount }) => {
|
||||||
|
const isExpanded = expandedCategory === category;
|
||||||
|
return (
|
||||||
|
<Fragment key={category}>
|
||||||
|
<tr
|
||||||
|
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"}`}
|
||||||
|
onClick={() => setExpandedCategory(isExpanded ? null : category)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[category] || "#71717a" }} />
|
||||||
|
{formatCategory(category)}
|
||||||
|
<span className="text-zinc-600 text-xs ml-1">{isExpanded ? "▲" : "▼"}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-400">{txCount}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
|
||||||
|
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && <CategoryPanel category={category} selectedMonth={selectedMonth} />}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 6-month trend table */}
|
{/* 6-month trend table */}
|
||||||
{analytics && analytics.months.length > 0 && (
|
{analytics.months.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
||||||
<div className="overflow-x-auto rounded-xl border border-zinc-700">
|
<div className="overflow-x-auto rounded-xl border border-zinc-700">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">
|
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">Category</th>
|
||||||
Category
|
|
||||||
</th>
|
|
||||||
{analytics.months.map((m) => (
|
{analytics.months.map((m) => (
|
||||||
<th
|
<th
|
||||||
key={m}
|
key={m}
|
||||||
className="text-right px-3 py-2 text-zinc-500 font-medium whitespace-nowrap"
|
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${m === selectedMonth ? "text-indigo-400" : "text-zinc-500"}`}
|
||||||
|
onClick={() => setSelectedMonth(m)}
|
||||||
>
|
>
|
||||||
{m}
|
{formatShortMonth(m)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -333,45 +559,72 @@ export default function BudgetPage() {
|
|||||||
{analytics.rows.map((row) => (
|
{analytics.rows.map((row) => (
|
||||||
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
||||||
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
||||||
{formatCategory(row.category)}
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }} />
|
||||||
|
{formatCategory(row.category)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const spent = row.spent[m];
|
const spent = row.spent[m];
|
||||||
const budget = row.budget[m];
|
|
||||||
const overBudget =
|
|
||||||
spent !== undefined && budget !== undefined && spent > budget;
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={m} className={`px-3 py-2 text-right tabular-nums ${spent === undefined ? "text-zinc-700" : "text-zinc-300"} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
||||||
key={m}
|
{spent !== undefined ? fmt(spent) : "—"}
|
||||||
className={`px-3 py-2 text-right tabular-nums ${
|
|
||||||
spent === undefined
|
|
||||||
? "text-zinc-700"
|
|
||||||
: overBudget
|
|
||||||
? "text-red-300 bg-red-950/40"
|
|
||||||
: "text-zinc-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"}
|
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{hasIncome && (
|
||||||
|
<tr className="border-b border-zinc-800/40">
|
||||||
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-emerald-600">Income</td>
|
||||||
|
{analytics.months.map((m) => {
|
||||||
|
const inc = analytics.income[m];
|
||||||
|
return (
|
||||||
|
<td key={m} className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
||||||
|
{inc ? fmt(inc) : "—"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{hasInvestments && (
|
||||||
|
<tr className="border-b border-zinc-800/40">
|
||||||
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-indigo-500">Invested</td>
|
||||||
|
{analytics.months.map((m) => {
|
||||||
|
const inv = analytics.investments[m];
|
||||||
|
return (
|
||||||
|
<td key={m} className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
||||||
|
{inv ? fmt(inv) : "—"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
|
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
|
||||||
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Total</td>
|
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const t = analytics.totals[m];
|
const t = analytics.totals[m];
|
||||||
const over = t && t.budget > 0 && t.spent > t.budget;
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={m} className={`px-3 py-2 text-right tabular-nums ${m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""}`}>
|
||||||
key={m}
|
{fmt(t?.spent || 0)}
|
||||||
className={`px-3 py-2 text-right tabular-nums ${over ? "text-red-300" : ""}`}
|
|
||||||
>
|
|
||||||
${(t?.spent || 0).toFixed(0)}
|
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
|
{hasIncome && (
|
||||||
|
<tr className="font-semibold bg-zinc-900/50">
|
||||||
|
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Net Cash</td>
|
||||||
|
{analytics.months.map((m) => {
|
||||||
|
const t = analytics.totals[m];
|
||||||
|
const net = t?.net || 0;
|
||||||
|
return (
|
||||||
|
<td key={m} className={`px-3 py-2 text-right tabular-nums ${net >= 0 ? "text-emerald-400" : "text-red-400"} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
||||||
|
{net >= 0 ? "+" : ""}{fmt(net)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||||
|
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
function fmtDate(d: string) {
|
||||||
|
return new Date(d).toLocaleDateString("en-AU", { month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
function trend(values: number[]): { pct: number; dir: "up" | "down" | "flat" } {
|
||||||
|
if (values.length < 2) return { pct: 0, dir: "flat" };
|
||||||
|
const recent = values.slice(-3).reduce((a, b) => a + b, 0) / 3;
|
||||||
|
const prior = values.slice(0, 3).reduce((a, b) => a + b, 0) / 3;
|
||||||
|
if (prior === 0) return { pct: 0, dir: "flat" };
|
||||||
|
const pct = Math.round(((recent - prior) / prior) * 100);
|
||||||
|
return { pct: Math.abs(pct), dir: pct > 2 ? "up" : pct < -2 ? "down" : "flat" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FREQ_LABEL: Record<string, string> = {
|
||||||
|
weekly: "Weekly",
|
||||||
|
fortnightly: "Fortnightly",
|
||||||
|
monthly: "Monthly",
|
||||||
|
quarterly: "Quarterly",
|
||||||
|
annual: "Annual",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Section wrapper ────────────────────────────────────────────────
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-200 mb-3">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Custom tooltip ──────────────────────────────────────────────────
|
||||||
|
function RegularTooltip({ active, payload, label }: any) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
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">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({ categories: category ? [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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 regular/occasional chart data
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!analytics) return [];
|
||||||
|
return [...analytics.months].reverse().map((month) => {
|
||||||
|
let regular = 0;
|
||||||
|
let occasional = 0;
|
||||||
|
for (const row of analytics.rows) {
|
||||||
|
const spend = Number(row.spent[month] ?? 0);
|
||||||
|
if (REGULAR_CATEGORIES.has(row.category as any)) regular += spend;
|
||||||
|
else occasional += spend;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
month: month.slice(5) + "/" + month.slice(2, 4),
|
||||||
|
regular: Math.round(regular),
|
||||||
|
occasional: Math.round(occasional),
|
||||||
|
total: Math.round(regular + occasional),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
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 latestRegular = regularValues[regularValues.length - 1] ?? 0;
|
||||||
|
|
||||||
|
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
|
||||||
|
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<h2 className="text-xl font-semibold mb-6">Insights</h2>
|
||||||
|
|
||||||
|
{/* ── 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 — 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 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 ${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>
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<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={<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" />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. 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>
|
||||||
|
) : subData.subscriptions.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">No recurring patterns detected yet — more transaction history needed.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
|
||||||
|
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
|
||||||
|
</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">Merchant</th>
|
||||||
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
|
||||||
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Frequency</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">My $/mo equiv</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Avg charge</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Since</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total paid</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...activeSubscriptions, ...inactiveSubscriptions].map((s) => (
|
||||||
|
<tr key={s.merchant} className={`border-b border-zinc-800/50 ${s.is_active ? "hover:bg-zinc-800/20" : "opacity-40"} transition-colors`}>
|
||||||
|
<td className="px-4 py-3 font-medium">{s.merchant}</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-400 text-xs">{formatCategory(s.category ?? "other")}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${s.is_active ? "bg-indigo-900/40 text-indigo-300" : "bg-zinc-800 text-zinc-500"}`}>
|
||||||
|
{FREQ_LABEL[s.frequency] ?? s.frequency}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-200">{fmtExact(s.monthly_equiv)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.avg_amount)}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-zinc-500 text-xs whitespace-nowrap">{fmtDate(s.first_seen)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.total_paid)}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-zinc-500">{s.occurrences}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── 4. Fees & Interest ── */}
|
||||||
|
<Section title="Fees & Interest">
|
||||||
|
{!feesData ? (
|
||||||
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
|
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">No fees or interest recorded across your statements.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{feesData.by_bank.length > 0 && (
|
||||||
|
<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">Bank</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Fees</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Interest</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{feesData.by_bank.map((r) => (
|
||||||
|
<tr key={r.bank_name} className="border-b border-zinc-800/50">
|
||||||
|
<td className="px-4 py-3 font-medium">{r.bank_name}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.fees > 0 ? fmtExact(r.fees) : <span className="text-zinc-700">—</span>}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.interest > 0 ? fmtExact(r.interest) : <span className="text-zinc-700">—</span>}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-red-400 font-medium">{fmtExact(r.total)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="bg-zinc-900/50">
|
||||||
|
<td className="px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_fees)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_interest)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums text-red-400 font-medium">{fmtExact(feesData.total_fees + feesData.total_interest)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{feesData.transactions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
|
||||||
|
<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">Date</th>
|
||||||
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||||||
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Description</th>
|
||||||
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Type</th>
|
||||||
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{feesData.transactions.map((t) => (
|
||||||
|
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20">
|
||||||
|
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
|
||||||
|
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-zinc-400 text-xs">{t.bank_name}</td>
|
||||||
|
<td className="px-4 py-2.5 text-zinc-300">{t.description}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${t.transaction_type === "interest" ? "bg-orange-900/40 text-orange-300" : "bg-zinc-800 text-zinc-400"}`}>
|
||||||
|
{t.transaction_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">{fmtExact(t.my_amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -32,7 +32,7 @@ export default function RootLayout({
|
|||||||
<Providers>
|
<Providers>
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 p-6 overflow-auto">{children}</main>
|
<main className="flex-1 pt-[calc(3.5rem+1rem)] px-3 pb-3 md:p-6 md:pt-6 overflow-auto">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ScatterChart,
|
||||||
|
Scatter,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
|
import { useMerchants, useMerchantTransactions, MerchantRow } from "@/lib/hooks";
|
||||||
|
import { formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
function fmt(n: number) {
|
||||||
|
return new Intl.NumberFormat("en-AU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "AUD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(n);
|
||||||
|
}
|
||||||
|
function fmtExact(n: number) {
|
||||||
|
return new Intl.NumberFormat("en-AU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "AUD",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(n);
|
||||||
|
}
|
||||||
|
function fmtDate(d: string) {
|
||||||
|
return new Date(d + "T00:00:00").toLocaleDateString("en-AU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
groceries: "#4ade80",
|
||||||
|
dining: "#fb923c",
|
||||||
|
transport: "#60a5fa",
|
||||||
|
fuel: "#facc15",
|
||||||
|
shopping: "#f472b6",
|
||||||
|
utilities: "#a78bfa",
|
||||||
|
entertainment: "#34d399",
|
||||||
|
travel: "#38bdf8",
|
||||||
|
health: "#f87171",
|
||||||
|
insurance: "#94a3b8",
|
||||||
|
subscriptions: "#c084fc",
|
||||||
|
government: "#6b7280",
|
||||||
|
education: "#fbbf24",
|
||||||
|
rent: "#e879f9",
|
||||||
|
home_goods: "#67e8f9",
|
||||||
|
home_maintenance: "#c084fc",
|
||||||
|
personal_care: "#fb7185",
|
||||||
|
pets: "#a3e635",
|
||||||
|
gifts: "#f9a8d4",
|
||||||
|
charity: "#6ee7b7",
|
||||||
|
other: "#71717a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom scatter tooltip
|
||||||
|
function ScatterTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ payload: MerchantRow }>;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const d = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-800 border border-zinc-700 rounded-lg p-3 text-sm shadow-xl max-w-48">
|
||||||
|
<p className="font-semibold text-white truncate">{d.merchant}</p>
|
||||||
|
<p className="text-zinc-400 text-xs mt-0.5">{formatCategory(d.category)}</p>
|
||||||
|
<div className="mt-2 space-y-1 text-zinc-300">
|
||||||
|
<p>{fmt(d.net_spend)} net</p>
|
||||||
|
{d.refund_count > 0 && (
|
||||||
|
<p className="text-emerald-400 text-xs">{d.refund_count} refund(s) −{fmt(d.total_refunds)}</p>
|
||||||
|
)}
|
||||||
|
<p>{d.debit_count}× transactions</p>
|
||||||
|
<p>{fmtExact(d.avg_debit)} avg</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quadrant labels
|
||||||
|
function QuadrantLabels({ medianX, medianY }: { medianX: number; medianY: number }) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 pointer-events-none select-none">
|
||||||
|
<div className="absolute top-2 right-4 text-xs text-zinc-600 text-right">
|
||||||
|
high cost · frequent
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-2 left-16 text-xs text-zinc-600">
|
||||||
|
high cost · rare
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-8 right-4 text-xs text-zinc-600 text-right">
|
||||||
|
low cost · frequent
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-8 left-16 text-xs text-zinc-600">
|
||||||
|
low cost · rare
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merchant profile drawer
|
||||||
|
function MerchantProfile({
|
||||||
|
merchant,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
merchant: MerchantRow;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { data } = useMerchantTransactions(merchant.merchant);
|
||||||
|
const transactions = data?.transactions ?? [];
|
||||||
|
|
||||||
|
// Build trend chart data from monthly_trend
|
||||||
|
const trendData = useMemo(() => {
|
||||||
|
const months = Object.keys(merchant.monthly_trend).sort();
|
||||||
|
return months.map((m) => ({
|
||||||
|
month: m,
|
||||||
|
label: new Date(m + "-01").toLocaleDateString("en-AU", { month: "short", year: "2-digit" }),
|
||||||
|
amount: merchant.monthly_trend[m],
|
||||||
|
}));
|
||||||
|
}, [merchant.monthly_trend]);
|
||||||
|
|
||||||
|
const color = CATEGORY_COLORS[merchant.category] || "#6366f1";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex justify-end">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||||
|
{/* drawer */}
|
||||||
|
<div className="relative w-full max-w-lg bg-zinc-900 border-l border-zinc-700 h-full overflow-y-auto flex flex-col shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between p-5 border-b border-zinc-800">
|
||||||
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
|
<h2 className="text-lg font-semibold text-white truncate">{merchant.merchant}</h2>
|
||||||
|
<span
|
||||||
|
className="inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
style={{ background: color + "33", color }}
|
||||||
|
>
|
||||||
|
{formatCategory(merchant.category)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-zinc-400 hover:text-white text-xl leading-none mt-0.5"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 p-5 border-b border-zinc-800">
|
||||||
|
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-zinc-400 mb-1">Net Spent</p>
|
||||||
|
<p className="text-white font-semibold">{fmt(merchant.net_spend)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-zinc-400 mb-1">Avg per Visit</p>
|
||||||
|
<p className="text-white font-semibold">{fmtExact(merchant.avg_debit)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-zinc-400 mb-1">Visits</p>
|
||||||
|
<p className="text-white font-semibold">{merchant.debit_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Refund callout — only show if there are refunds */}
|
||||||
|
{merchant.refund_count > 0 && (
|
||||||
|
<div className="mx-5 mt-3 px-3 py-2 rounded-lg bg-emerald-900/20 border border-emerald-800/40 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-emerald-400">
|
||||||
|
{merchant.refund_count} refund{merchant.refund_count > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-300">
|
||||||
|
<span className="text-zinc-500 mr-2">gross {fmt(merchant.gross_spend)} − refunds</span>
|
||||||
|
<span className="text-emerald-400 font-medium">−{fmt(merchant.total_refunds)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trend chart */}
|
||||||
|
{trendData.length > 1 && (
|
||||||
|
<div className="p-5 border-b border-zinc-800">
|
||||||
|
<p className="text-sm font-medium text-zinc-300 mb-3">Monthly Spend</p>
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
|
<LineChart data={trendData}>
|
||||||
|
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: "#71717a", fontSize: 10 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: "#71717a", fontSize: 10 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `$${Math.round(v)}`}
|
||||||
|
width={45}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: "8px" }}
|
||||||
|
labelStyle={{ color: "#a1a1aa" }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="amount"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: color, r: 3 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transaction history */}
|
||||||
|
<div className="p-5 flex-1">
|
||||||
|
<p className="text-sm font-medium text-zinc-300 mb-3">
|
||||||
|
Transactions{" "}
|
||||||
|
<span className="text-zinc-500 font-normal">({transactions.length})</span>
|
||||||
|
</p>
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<div
|
||||||
|
key={tx.id}
|
||||||
|
className="flex items-center justify-between py-2 border-b border-zinc-800 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 pr-3">
|
||||||
|
<p className="text-zinc-300 truncate">{tx.description}</p>
|
||||||
|
<p className="text-zinc-500 text-xs mt-0.5">
|
||||||
|
{fmtDate(tx.transaction_date)} · {tx.bank_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-white font-medium whitespace-nowrap">
|
||||||
|
{fmtExact(tx.my_amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MerchantsPage() {
|
||||||
|
const [months, setMonths] = useState(12);
|
||||||
|
const [selected, setSelected] = useState<MerchantRow | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading } = useMerchants(months);
|
||||||
|
const merchants = data?.merchants ?? [];
|
||||||
|
|
||||||
|
// Stats for median lines
|
||||||
|
const { medianX, medianY } = useMemo(() => {
|
||||||
|
if (merchants.length === 0) return { medianX: 0, medianY: 0 };
|
||||||
|
const counts = [...merchants].sort((a, b) => a.debit_count - b.debit_count);
|
||||||
|
const spends = [...merchants].sort((a, b) => a.net_spend - b.net_spend);
|
||||||
|
const mid = Math.floor(merchants.length / 2);
|
||||||
|
return {
|
||||||
|
medianX: counts[mid]?.debit_count ?? 0,
|
||||||
|
medianY: spends[mid]?.net_spend ?? 0,
|
||||||
|
};
|
||||||
|
}, [merchants]);
|
||||||
|
|
||||||
|
// Filtered merchants for the table
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search.trim()) return merchants;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return merchants.filter((m) => m.merchant.toLowerCase().includes(q));
|
||||||
|
}, [merchants, search]);
|
||||||
|
|
||||||
|
// Top merchants for the scatter (max 150 for performance)
|
||||||
|
const scatterData = useMemo(() => merchants.slice(0, 150), [merchants]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-zinc-400">Loading merchant data…</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-6xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Merchants</h1>
|
||||||
|
<p className="text-zinc-400 text-sm mt-0.5">{merchants.length} merchants · last {months} months</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={months}
|
||||||
|
onChange={(e) => setMonths(Number(e.target.value))}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<option value={3}>3 months</option>
|
||||||
|
<option value={6}>6 months</option>
|
||||||
|
<option value={12}>12 months</option>
|
||||||
|
<option value={24}>24 months</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scatter plot */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||||
|
<h2 className="text-sm font-medium text-zinc-300 mb-1">Spend vs Frequency</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mb-4">
|
||||||
|
Each dot = one merchant. Click to open profile.
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<QuadrantLabels medianX={medianX} medianY={medianY} />
|
||||||
|
<ResponsiveContainer width="100%" height={360}>
|
||||||
|
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
|
||||||
|
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="debit_count"
|
||||||
|
name="Transactions"
|
||||||
|
type="number"
|
||||||
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#52525b", fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="net_spend"
|
||||||
|
name="Net Spend"
|
||||||
|
type="number"
|
||||||
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `$${Math.round(v / 1000)}k`}
|
||||||
|
width={48}
|
||||||
|
label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#52525b", fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} />
|
||||||
|
<Scatter
|
||||||
|
data={scatterData}
|
||||||
|
onClick={(d) => setSelected(d as unknown as MerchantRow)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{scatterData.map((entry, idx) => (
|
||||||
|
<Cell
|
||||||
|
key={idx}
|
||||||
|
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
|
||||||
|
fillOpacity={0.75}
|
||||||
|
stroke={selected?.merchant === entry.merchant ? "#fff" : "transparent"}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Scatter>
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{/* Category legend */}
|
||||||
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{Object.entries(CATEGORY_COLORS)
|
||||||
|
.filter(([cat]) => merchants.some((m) => m.category === cat))
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(([cat, color]) => (
|
||||||
|
<span key={cat} className="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||||
|
<span className="w-2 h-2 rounded-full inline-block" style={{ background: color }} />
|
||||||
|
{formatCategory(cat)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merchant table */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||||
|
<h2 className="text-sm font-medium text-zinc-300">All Merchants</h2>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 placeholder-zinc-500 px-3 py-1.5 w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-zinc-500 text-xs border-b border-zinc-800">
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Merchant</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Category</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium">Total</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium">Count</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium">Avg</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium hidden sm:table-cell">Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((m) => {
|
||||||
|
const color = CATEGORY_COLORS[m.category] || "#6366f1";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={m.merchant}
|
||||||
|
onClick={() => setSelected(m)}
|
||||||
|
className="border-b border-zinc-800 hover:bg-zinc-800 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 text-zinc-200 max-w-[200px]">
|
||||||
|
<span className="truncate block">{m.merchant}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style={{ background: color + "22", color }}
|
||||||
|
>
|
||||||
|
{formatCategory(m.category)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-white font-medium">
|
||||||
|
{fmt(m.net_spend)}
|
||||||
|
{m.refund_count > 0 && (
|
||||||
|
<span className="ml-1.5 text-emerald-400 text-xs">↩{m.refund_count}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-400">{m.debit_count}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-400">
|
||||||
|
{fmtExact(m.avg_debit)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-500 text-xs hidden sm:table-cell">
|
||||||
|
{fmtDate(m.last_seen)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merchant profile drawer */}
|
||||||
|
{selected && (
|
||||||
|
<MerchantProfile merchant={selected} onClose={() => setSelected(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { usePendingReconciliations, useReconcile } from "@/lib/hooks";
|
||||||
|
import type { ManualTxWithMatches, PotentialMatch } from "@/lib/queries";
|
||||||
|
|
||||||
|
function formatDate(d: string | Date) {
|
||||||
|
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmt(amount: number, type: string) {
|
||||||
|
const f = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(amount);
|
||||||
|
return ["debit", "fee", "interest"].includes(type) ? f : `+${f}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
debit: "bg-red-900/30 text-red-400",
|
||||||
|
credit: "bg-green-900/30 text-green-400",
|
||||||
|
payment: "bg-blue-900/30 text-blue-400",
|
||||||
|
refund: "bg-emerald-900/30 text-emerald-400",
|
||||||
|
fee: "bg-yellow-900/30 text-yellow-400",
|
||||||
|
interest: "bg-orange-900/30 text-orange-400",
|
||||||
|
transfer: "bg-zinc-800 text-zinc-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selections: manual_id → statement_tx_id or null (skip)
|
||||||
|
type Selections = Record<number, number | null>;
|
||||||
|
|
||||||
|
export default function ReconcilePage() {
|
||||||
|
const { data: pending = [], isLoading, refetch } = usePendingReconciliations();
|
||||||
|
const reconcile = useReconcile();
|
||||||
|
|
||||||
|
const [selections, setSelections] = useState<Selections>({});
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [done, setDone] = useState<{ reconciled: number } | null>(null);
|
||||||
|
|
||||||
|
const withMatches = pending.filter((tx) => tx.matches.length > 0);
|
||||||
|
const noMatches = pending.filter((tx) => tx.matches.length === 0);
|
||||||
|
|
||||||
|
// Statement tx IDs already chosen in another row this session
|
||||||
|
const usedStatementIds = new Set(Object.values(selections).filter((v): v is number => v !== null));
|
||||||
|
|
||||||
|
function selectMatch(manualId: number, matchId: number) {
|
||||||
|
setSelections((prev) => {
|
||||||
|
// If this matchId was previously selected for a different manual tx, clear that
|
||||||
|
const updated: Selections = { ...prev };
|
||||||
|
for (const [k, v] of Object.entries(updated)) {
|
||||||
|
if (v === matchId && Number(k) !== manualId) delete updated[Number(k)];
|
||||||
|
}
|
||||||
|
updated[manualId] = matchId;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipManual(manualId: number) {
|
||||||
|
setSelections((prev) => ({ ...prev, [manualId]: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedMatches = Object.entries(selections)
|
||||||
|
.filter(([, v]) => v !== null)
|
||||||
|
.map(([k, v]) => ({ manual_id: Number(k), statement_tx_id: v as number }));
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!confirmedMatches.length) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await reconcile.mutateAsync(confirmedMatches);
|
||||||
|
setDone(result);
|
||||||
|
setSelections({});
|
||||||
|
refetch();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Reconcile failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-6 text-zinc-500 text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-lg">
|
||||||
|
<div className="bg-emerald-900/20 border border-emerald-700/50 rounded-xl p-6 text-center space-y-2">
|
||||||
|
<p className="text-emerald-400 font-semibold text-lg">✓ {done.reconciled} transaction{done.reconciled !== 1 ? "s" : ""} reconciled</p>
|
||||||
|
<p className="text-zinc-400 text-sm">Overrides, tags, and splits copied to statement transactions.</p>
|
||||||
|
<button onClick={() => setDone(null)} className="mt-3 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Continue reconciling
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Reconcile</h2>
|
||||||
|
<p className="text-zinc-500 text-sm">No unreconciled manual transactions. Import a CSV to get started.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Reconcile</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "}
|
||||||
|
{withMatches.length} with potential matches
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{confirmedMatches.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={reconcile.isPending}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reconcile.isPending ? "Reconciling..." : `Apply ${confirmedMatches.length} match${confirmedMatches.length !== 1 ? "es" : ""}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
{/* Transactions with matches */}
|
||||||
|
{withMatches.map((tx) => (
|
||||||
|
<ReconcileRow
|
||||||
|
key={tx.id}
|
||||||
|
tx={tx}
|
||||||
|
selection={selections[tx.id]}
|
||||||
|
usedStatementIds={usedStatementIds}
|
||||||
|
onSelect={(matchId) => selectMatch(tx.id, matchId)}
|
||||||
|
onSkip={() => skipManual(tx.id)}
|
||||||
|
onClear={() => setSelections((prev) => { const n = { ...prev }; delete n[tx.id]; return n; })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Transactions with no matches */}
|
||||||
|
{noMatches.length > 0 && (
|
||||||
|
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800">
|
||||||
|
<p className="text-sm font-medium text-zinc-400">No statement matches found ({noMatches.length})</p>
|
||||||
|
<p className="text-xs text-zinc-600 mt-0.5">These may not have hit a statement yet, or the statement hasn't been imported.</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800">
|
||||||
|
{noMatches.map((tx) => (
|
||||||
|
<div key={tx.id} className="px-4 py-3 flex items-center gap-3">
|
||||||
|
<span className="text-zinc-500 text-xs w-20 flex-shrink-0">{formatDate(tx.transaction_date)}</span>
|
||||||
|
<span className="text-zinc-300 text-sm flex-1 truncate">{tx.effective_merchant || tx.description}</span>
|
||||||
|
<span className={`text-sm font-mono ${["debit","fee","interest"].includes(tx.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
{formatAmt(tx.amount, tx.transaction_type)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(tx.tags as { id: number; name: string; color: string }[]).map((tag) => (
|
||||||
|
<span key={tag.id} className="w-2 h-2 rounded-full" style={{ backgroundColor: tag.color }} title={tag.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReconcileRow({
|
||||||
|
tx, selection, usedStatementIds, onSelect, onSkip, onClear,
|
||||||
|
}: {
|
||||||
|
tx: ManualTxWithMatches;
|
||||||
|
selection: number | null | undefined;
|
||||||
|
usedStatementIds: Set<number>;
|
||||||
|
onSelect: (matchId: number) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const isSkipped = selection === null;
|
||||||
|
const selectedMatchId = typeof selection === "number" ? selection : null;
|
||||||
|
const tags = tx.tags as { id: number; name: string; color: string }[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-xl overflow-hidden transition-colors ${
|
||||||
|
selectedMatchId ? "border-emerald-700/60" : isSkipped ? "border-zinc-700/40 opacity-60" : "border-zinc-700"
|
||||||
|
}`}>
|
||||||
|
{/* Manual tx header */}
|
||||||
|
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-zinc-500 text-xs flex-shrink-0">{formatDate(tx.transaction_date)}</span>
|
||||||
|
<span className="text-zinc-200 text-sm truncate">{tx.effective_merchant || tx.description}</span>
|
||||||
|
{tx.effective_merchant && (
|
||||||
|
<span className="text-zinc-600 text-xs truncate hidden sm:inline">{tx.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag.id} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style={{ backgroundColor: tag.color + "33", color: tag.color }}>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-mono flex-shrink-0 ${["debit","fee","interest"].includes(tx.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
{formatAmt(tx.amount, tx.transaction_type)}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${TYPE_COLORS[tx.transaction_type] || "bg-zinc-800 text-zinc-400"}`}>
|
||||||
|
{tx.transaction_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matches */}
|
||||||
|
<div className="divide-y divide-zinc-800/50">
|
||||||
|
{tx.matches.map((match) => {
|
||||||
|
const isSelected = selectedMatchId === match.id;
|
||||||
|
const isUsedElsewhere = !isSelected && usedStatementIds.has(match.id);
|
||||||
|
return (
|
||||||
|
<MatchRow
|
||||||
|
key={match.id}
|
||||||
|
match={match}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDisabled={isUsedElsewhere}
|
||||||
|
onSelect={() => onSelect(match.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Skip / status row */}
|
||||||
|
<div className="px-4 py-2.5 flex items-center justify-between bg-zinc-950/30">
|
||||||
|
{selectedMatchId ? (
|
||||||
|
<span className="text-emerald-400 text-xs font-medium">✓ Match selected</span>
|
||||||
|
) : isSkipped ? (
|
||||||
|
<span className="text-zinc-500 text-xs">Skipped — will stay as manual transaction</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600 text-xs">Select a match above, or skip</span>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(selectedMatchId || isSkipped) && (
|
||||||
|
<button onClick={onClear} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isSkipped && (
|
||||||
|
<button onClick={onSkip} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
|
||||||
|
Skip (no match)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchRow({
|
||||||
|
match, isSelected, isDisabled, onSelect,
|
||||||
|
}: {
|
||||||
|
match: PotentialMatch;
|
||||||
|
isSelected: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "bg-emerald-900/20 hover:bg-emerald-900/30"
|
||||||
|
: isDisabled
|
||||||
|
? "opacity-30 cursor-not-allowed"
|
||||||
|
: "hover:bg-zinc-800/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
|
||||||
|
isSelected ? "border-emerald-500 bg-emerald-500" : "border-zinc-600"
|
||||||
|
}`}>
|
||||||
|
{isSelected && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-zinc-500 text-xs w-20 flex-shrink-0">{formatDate(match.transaction_date)}</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="text-zinc-300 text-sm truncate block">
|
||||||
|
{match.effective_merchant || match.description}
|
||||||
|
</span>
|
||||||
|
{match.effective_merchant && (
|
||||||
|
<span className="text-zinc-600 text-xs truncate block">{match.description}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-500 text-xs flex-shrink-0">{match.bank_name}</span>
|
||||||
|
<span className={`text-sm font-mono flex-shrink-0 ${["debit","fee","interest"].includes(match.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
{formatAmt(match.amount, match.transaction_type)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+523
-3
@@ -1,8 +1,528 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
|
||||||
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
const FIELDS = [
|
||||||
|
{ value: "merchant_normalized", label: "Merchant" },
|
||||||
|
{ value: "description", label: "Description" },
|
||||||
|
{ value: "category", label: "Category" },
|
||||||
|
{ value: "bank_name", label: "Bank" },
|
||||||
|
{ value: "amount", label: "Amount" },
|
||||||
|
{ value: "transaction_type", label: "Transaction Type" },
|
||||||
|
{ value: "tag", label: "Tag" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TEXT_OPS = [
|
||||||
|
{ value: "contains", label: "contains" },
|
||||||
|
{ value: "equals", label: "equals" },
|
||||||
|
{ value: "starts_with", label: "starts with" },
|
||||||
|
{ value: "not_equals", label: "not equals" },
|
||||||
|
];
|
||||||
|
const AMOUNT_OPS = [
|
||||||
|
{ value: "equals", label: "=" },
|
||||||
|
{ value: "not_equals", label: "≠" },
|
||||||
|
{ value: "gt", label: ">" },
|
||||||
|
{ value: "lt", label: "<" },
|
||||||
|
];
|
||||||
|
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 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, tagNames?: Map<number, string>): string {
|
||||||
|
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
||||||
|
if (c.field === "tag") {
|
||||||
|
const tagName = tagNames?.get(Number(c.value)) || `tag#${c.value}`;
|
||||||
|
return `Tag ${c.operator === "not_equals" ? "is not" : "is"} "${tagName}"`;
|
||||||
|
}
|
||||||
|
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>, 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}`);
|
||||||
|
if (a.add_tag_ids?.length) {
|
||||||
|
const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", ");
|
||||||
|
parts.push(`add tags: ${names}`);
|
||||||
|
}
|
||||||
|
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() {
|
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-09");
|
||||||
|
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>(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: "" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCondition(i: number, patch: Partial<Condition>) {
|
||||||
|
setConditions(conditions.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCondition(i: number) {
|
||||||
|
setConditions(conditions.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
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(ruleId?: number) {
|
||||||
|
const result = await applyRules.mutateAsync({ splitFrom: applyFrom || undefined, ruleId });
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">Rules</h2>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-zinc-500">Coming soon - auto-classify transactions with rules.</p>
|
<h2 className="text-xl font-semibold">Rules</h2>
|
||||||
|
<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}
|
||||||
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{applyRules.isPending ? "Applying..." : "Apply All Rules"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={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"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{applyResult && (
|
||||||
|
<div className="p-4 bg-emerald-900/30 border border-emerald-700 rounded-lg text-sm">
|
||||||
|
Applied: <strong>{applyResult.matched}</strong> condition matches across{" "}
|
||||||
|
<strong>{applyResult.transactions_affected}</strong> transactions.
|
||||||
|
<button onClick={() => setApplyResult(null)} className="ml-4 text-zinc-400 hover:text-white">
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 space-y-4">
|
||||||
|
<h3 className="font-semibold text-sm text-zinc-300">{editingId !== null ? "Edit Rule" : "New Rule"}</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="e.g. Tag Woolworths as groceries"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-xs text-zinc-500">Conditions (ALL must match)</label>
|
||||||
|
<button type="button" onClick={addCondition} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||||
|
+ Add condition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{conditions.map((cond, i) => {
|
||||||
|
const isAmount = cond.field === "amount";
|
||||||
|
const isEnum = cond.field === "transaction_type";
|
||||||
|
const isTag = cond.field === "tag";
|
||||||
|
const ops = isAmount ? AMOUNT_OPS : (isEnum || isTag) ? ENUM_OPS : TEXT_OPS;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex gap-2 mb-2 items-center">
|
||||||
|
<select
|
||||||
|
value={cond.field}
|
||||||
|
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 if (newField === "tag") { patch.operator = "equals"; patch.value = tags[0] ? String(tags[0].id) : ""; }
|
||||||
|
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) => (
|
||||||
|
<option key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={cond.operator}
|
||||||
|
onChange={(e) => updateCondition(i, { operator: e.target.value })}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{ops.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{isTag ? (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<option key={t.id} value={String(t.id)}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
{tags.length === 0 && <option value="">No tags</option>}
|
||||||
|
</select>
|
||||||
|
) : 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)}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{conditions.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-600">No conditions — rule will match ALL transactions.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Set Category (optional)</label>
|
||||||
|
<select
|
||||||
|
value={actions.set_category || ""}
|
||||||
|
onChange={(e) => setActions({ ...actions, set_category: e.target.value || undefined })}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— no change —</option>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{formatCategory(c)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Set Merchant (optional)</label>
|
||||||
|
<input
|
||||||
|
value={actions.set_merchant || ""}
|
||||||
|
onChange={(e) => setActions({ ...actions, set_merchant: e.target.value || undefined })}
|
||||||
|
placeholder="Normalized name"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Add Tags (optional)</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const selected = (actions.add_tag_ids || []).includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const ids = actions.add_tag_ids || [];
|
||||||
|
setActions({
|
||||||
|
...actions,
|
||||||
|
add_tag_ids: selected ? ids.filter((id) => id !== tag.id) : [...ids, tag.id],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 rounded text-xs border transition-colors ${
|
||||||
|
selected ? "border-transparent text-white" : "border-zinc-700 text-zinc-400"
|
||||||
|
}`}
|
||||||
|
style={selected ? { backgroundColor: tag.color } : {}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tags.length === 0 && <p className="text-xs text-zinc-600">No tags created yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(Number(e.target.value))}
|
||||||
|
className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{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 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">No rules yet. Create one to auto-classify transactions.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rules.map((rule) => {
|
||||||
|
const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
|
||||||
|
const acts =
|
||||||
|
rule.actions && typeof rule.actions === "object" ? (rule.actions as Actions) : {};
|
||||||
|
return (
|
||||||
|
<div key={rule.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="font-medium text-sm">{rule.name}</span>
|
||||||
|
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
{conds.length > 0 ? conds.map((c) => humanCondition(c, tagNames)).join(" AND ") : "(matches all)"}
|
||||||
|
</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
|
||||||
|
onClick={() => handleApply(rule.id)}
|
||||||
|
disabled={applyRules.isPending}
|
||||||
|
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||||
|
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||||
|
rule.enabled ? "bg-indigo-600" : "bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${
|
||||||
|
rule.enabled ? "translate-x-4" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => 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);
|
||||||
|
}}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+472
-4
@@ -1,8 +1,476 @@
|
|||||||
export default function SharedPage() {
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useSharedTransactions,
|
||||||
|
useParticipantBalances,
|
||||||
|
useCreateParticipant,
|
||||||
|
useRecordPayment,
|
||||||
|
usePaymentHistory,
|
||||||
|
useDeletePayment,
|
||||||
|
useCurrentUser,
|
||||||
|
useTags,
|
||||||
|
type SplitPayment,
|
||||||
|
} from "@/lib/hooks";
|
||||||
|
import type { SharedTransactionRow } from "@/lib/queries";
|
||||||
|
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||||
|
|
||||||
|
function formatDate(d: string) {
|
||||||
|
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag multi-select ──────────────────────────────────────────────────────────
|
||||||
|
function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
|
||||||
|
const { data: tags = [] } = useTags();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handler(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
let next: string[];
|
||||||
|
if (value.includes(id)) {
|
||||||
|
next = value.filter((x) => x !== id);
|
||||||
|
} else if (id === "untagged") {
|
||||||
|
next = ["untagged"];
|
||||||
|
} else {
|
||||||
|
next = [...value.filter((x) => x !== "untagged"), id];
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = value.length === 0 ? "All Tags"
|
||||||
|
: value.includes("untagged") ? "No tags"
|
||||||
|
: value.length === 1 ? (tags.find((t) => String(t.id) === value[0])?.name ?? "1 tag")
|
||||||
|
: `${value.length} tags`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div ref={ref} className="relative">
|
||||||
<h2 className="text-xl font-semibold mb-4">Shared Expenses</h2>
|
<button
|
||||||
<p className="text-zinc-500">Coming soon - track shared expenses and splits.</p>
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`border rounded px-3 py-1.5 text-sm flex items-center gap-2 min-w-[120px] bg-zinc-900 ${value.length > 0 ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1 text-left">{label}</span>
|
||||||
|
<span className="text-zinc-500 text-xs">▾</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full mt-1 z-20 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-[160px] max-h-56 overflow-y-auto">
|
||||||
|
<label className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm border-b border-zinc-800">
|
||||||
|
<input type="checkbox" checked={value.includes("untagged")} onChange={() => toggle("untagged")}
|
||||||
|
className="accent-indigo-500 flex-shrink-0" />
|
||||||
|
<span className="text-zinc-400 italic">No tags</span>
|
||||||
|
</label>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<label key={t.id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm">
|
||||||
|
<input type="checkbox" checked={value.includes(String(t.id))} onChange={() => toggle(String(t.id))}
|
||||||
|
className="accent-indigo-500 flex-shrink-0" />
|
||||||
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: t.color }} />
|
||||||
|
{t.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add Participant ───────────────────────────────────────────────────────────
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Record Payment modal ──────────────────────────────────────────────────────
|
||||||
|
function RecordPaymentModal({
|
||||||
|
participant,
|
||||||
|
currentUserId,
|
||||||
|
currentBalance,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
participant: { id: number; name: string };
|
||||||
|
currentUserId: number;
|
||||||
|
currentBalance: number; // positive = they owe me, negative = I owe them
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const record = useRecordPayment();
|
||||||
|
const theyOweMe = currentBalance > 0;
|
||||||
|
|
||||||
|
// Default direction matches the debt direction
|
||||||
|
const [amount, setAmount] = useState(Math.abs(currentBalance).toFixed(2));
|
||||||
|
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
// direction: "received" = they paid me, "sent" = I paid them
|
||||||
|
const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setError("");
|
||||||
|
const amt = parseFloat(amount);
|
||||||
|
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
|
||||||
|
try {
|
||||||
|
await record.mutateAsync({
|
||||||
|
from_participant_id: direction === "received" ? participant.id : currentUserId,
|
||||||
|
to_participant_id: direction === "received" ? currentUserId : participant.id,
|
||||||
|
amount: amt,
|
||||||
|
payment_date: date,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to record payment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 w-full max-w-sm mx-4 shadow-2xl p-6 space-y-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="font-semibold text-sm text-zinc-300">Record Payment</h3>
|
||||||
|
|
||||||
|
{/* Direction toggle */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-zinc-700 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDirection("received")}
|
||||||
|
className={`flex-1 py-1.5 transition-colors ${direction === "received" ? "bg-emerald-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
|
||||||
|
>
|
||||||
|
{participant.name} paid me
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDirection("sent")}
|
||||||
|
className={`flex-1 py-1.5 transition-colors ${direction === "sent" ? "bg-blue-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
|
||||||
|
>
|
||||||
|
I paid {participant.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
|
||||||
|
<input type="number" step="0.01" min="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 pl-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
|
||||||
|
<input value={notes} onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="e.g. Bank transfer, cash"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="flex-1 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={record.isPending}
|
||||||
|
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
|
||||||
|
{record.isPending ? "Saving…" : "Record"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment history inline ────────────────────────────────────────────────────
|
||||||
|
function PaymentHistory({ participantId, currentUserId }: { participantId: number; currentUserId: number }) {
|
||||||
|
const { data: payments = [], isLoading } = usePaymentHistory(participantId);
|
||||||
|
const deletePayment = useDeletePayment();
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-xs text-zinc-600 mt-2">Loading payments…</p>;
|
||||||
|
if (payments.length === 0) return <p className="text-xs text-zinc-600 italic mt-2">No payments recorded</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
<p className="text-xs text-zinc-500 font-medium">Payment history</p>
|
||||||
|
{payments.map((p: SplitPayment) => {
|
||||||
|
const theyPaidMe = p.to_participant_id === currentUserId;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className={`font-mono font-medium ${theyPaidMe ? "text-emerald-400" : "text-blue-400"}`}>
|
||||||
|
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-500">{formatDate(p.payment_date)}</span>
|
||||||
|
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => deletePayment.mutate(p.id)}
|
||||||
|
className="text-zinc-600 hover:text-red-400 leading-none ml-auto flex-shrink-0"
|
||||||
|
title="Delete payment"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
type SortCol = "transaction_date" | "created_at" | "amount";
|
||||||
|
|
||||||
|
export default function SharedPage() {
|
||||||
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
|
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
|
||||||
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||||
|
const realTagIds = tagIds.filter((id) => id !== "untagged");
|
||||||
|
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
|
||||||
|
|
||||||
|
const transactions = [...rawTransactions].sort((a, b) => {
|
||||||
|
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||||||
|
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||||||
|
return sortDir === "desc" ? bv - av : av - bv;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSort(col: SortCol) {
|
||||||
|
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||||
|
else { setSortCol(col); setSortDir("desc"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortIcon({ col }: { col: SortCol }) {
|
||||||
|
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
|
||||||
|
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
|
||||||
|
}
|
||||||
|
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
|
||||||
|
const { data: me } = useCurrentUser();
|
||||||
|
const [addingParticipant, setAddingParticipant] = useState(false);
|
||||||
|
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
|
||||||
|
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||||||
|
const [editModal, setEditModal] = useState<SharedTransactionRow | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<TagFilter value={tagIds} onChange={setTagIds} />
|
||||||
|
{!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 whitespace-nowrap">
|
||||||
|
+ Add Participant
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addingParticipant && <AddParticipantForm onDone={() => setAddingParticipant(false)} />}
|
||||||
|
|
||||||
|
{/* Balance cards */}
|
||||||
|
{realTagIds.length === 0 && tagIds.includes("untagged") ? null : realTagIds.length > 0 && (
|
||||||
|
<p className="text-xs text-zinc-500 mb-2">Showing split totals for selected tag — payments excluded (payments settle overall debt, not per-tag)</p>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
) : balances.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm col-span-3">No participants yet.</p>
|
||||||
|
) : (
|
||||||
|
balances.map((b) => {
|
||||||
|
const theyOweMe = b.total_owed > 0;
|
||||||
|
const net = Math.abs(b.total_owed);
|
||||||
|
const settled = net < 0.005;
|
||||||
|
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">
|
||||||
|
{settled ? "all square" : theyOweMe ? `owes you` : "you owe"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-lg font-semibold ${settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
|
||||||
|
${net.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
|
||||||
|
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
|
||||||
|
>
|
||||||
|
Record Payment
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showHistory === b.id && me && (
|
||||||
|
<PaymentHistory participantId={b.id} currentUserId={me.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transaction list */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto">
|
||||||
|
<div className="px-4 py-3 border-b border-zinc-800">
|
||||||
|
<h3 className="text-sm font-medium">Split Transactions</h3>
|
||||||
|
</div>
|
||||||
|
{txLoading ? (
|
||||||
|
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
|
||||||
|
) : transactions.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm px-4 py-6">
|
||||||
|
No split transactions yet. Use the Split button on any transaction.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm min-w-[520px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th
|
||||||
|
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||||
|
onClick={() => toggleSort("transaction_date")}
|
||||||
|
>
|
||||||
|
Date <SortIcon col="transaction_date" />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||||
|
onClick={() => toggleSort("created_at")}
|
||||||
|
>
|
||||||
|
Imported <SortIcon col="created_at" />
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
|
||||||
|
<th
|
||||||
|
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
|
||||||
|
onClick={() => toggleSort("amount")}
|
||||||
|
>
|
||||||
|
Amount <SortIcon col="amount" />
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
|
||||||
|
<th className="px-4 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(transactions as SharedTransactionRow[]).map((tx) => {
|
||||||
|
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||||
|
return (
|
||||||
|
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||||
|
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||||||
|
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||||||
|
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
|
||||||
|
{tx.effective_merchant && (
|
||||||
|
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||||||
|
)}
|
||||||
|
{tx.notes && (
|
||||||
|
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<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">
|
||||||
|
{splits.map((s) => (
|
||||||
|
<span key={s.participant_id}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
|
||||||
|
{s.participant_id === me?.id ? "Me" : s.name} {s.share_percent}%
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditModal(tx)}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment modal */}
|
||||||
|
{paymentModal && me && (
|
||||||
|
<RecordPaymentModal
|
||||||
|
participant={{ id: paymentModal.id, name: paymentModal.name }}
|
||||||
|
currentUserId={me.id}
|
||||||
|
currentBalance={paymentModal.balance}
|
||||||
|
onClose={() => setPaymentModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editModal && (
|
||||||
|
<EditTransactionModal
|
||||||
|
transaction={editModal}
|
||||||
|
onClose={() => setEditModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+208
-48
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useStatements } from "@/lib/hooks";
|
import { useStatements, useParticipants, useUpdateStatement } from "@/lib/hooks";
|
||||||
|
|
||||||
function formatDate(d: string | null) {
|
function formatDate(d: string | null) {
|
||||||
if (!d) return "-";
|
if (!d) return "—";
|
||||||
return new Date(d).toLocaleDateString("en-AU", {
|
return new Date(d).toLocaleDateString("en-AU", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "short",
|
month: "short",
|
||||||
@@ -12,64 +13,223 @@ function formatDate(d: string | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number | null, currency = "AUD") {
|
function formatPeriod(start: string | null, end: string | null) {
|
||||||
if (amount === null || amount === undefined) return "-";
|
if (!start && !end) return "—";
|
||||||
|
const fmt = (d: string) =>
|
||||||
|
new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "2-digit" });
|
||||||
|
if (!start) return `until ${fmt(end!)}`;
|
||||||
|
if (!end) return `from ${fmt(start)}`;
|
||||||
|
return `${fmt(start)} – ${fmt(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(n: number | null): string {
|
||||||
|
if (n === null || n === undefined) return "—";
|
||||||
return new Intl.NumberFormat("en-AU", {
|
return new Intl.NumberFormat("en-AU", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency: "AUD",
|
||||||
}).format(amount);
|
minimumFractionDigits: 2,
|
||||||
|
}).format(Number(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectCls =
|
||||||
|
"bg-zinc-900 border border-zinc-700 rounded text-xs px-2 py-1.5 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500";
|
||||||
|
|
||||||
export default function StatementsPage() {
|
export default function StatementsPage() {
|
||||||
const { data: statements, isLoading } = useStatements();
|
const { data: statements, isLoading } = useStatements();
|
||||||
|
const { data: participants } = useParticipants();
|
||||||
|
const updateStatement = useUpdateStatement();
|
||||||
|
|
||||||
|
const [bankFilter, setBankFilter] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<"all" | "card" | "bank">("all");
|
||||||
|
const [ownerFilter, setOwnerFilter] = useState("");
|
||||||
|
const [yearFilter, setYearFilter] = useState("");
|
||||||
|
|
||||||
|
const banks = useMemo(
|
||||||
|
() => [...new Set((statements ?? []).map((s) => s.bank_name))].sort(),
|
||||||
|
[statements]
|
||||||
|
);
|
||||||
|
const years = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
...new Set(
|
||||||
|
(statements ?? [])
|
||||||
|
.map((s) => s.billing_end_date?.slice(0, 4))
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
),
|
||||||
|
].sort((a, b) => b.localeCompare(a)),
|
||||||
|
[statements]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!statements) return [];
|
||||||
|
return statements.filter((s) => {
|
||||||
|
const isCard = s.statement_type?.toLowerCase().includes("card") ?? false;
|
||||||
|
if (bankFilter && s.bank_name !== bankFilter) return false;
|
||||||
|
if (typeFilter === "card" && !isCard) return false;
|
||||||
|
if (typeFilter === "bank" && isCard) return false;
|
||||||
|
if (ownerFilter && String(s.owner_id) !== ownerFilter) return false;
|
||||||
|
if (yearFilter && s.billing_end_date?.slice(0, 4) !== yearFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [statements, bankFilter, typeFilter, ownerFilter, yearFilter]);
|
||||||
|
|
||||||
|
const hasFilters = bankFilter || typeFilter !== "all" || ownerFilter || yearFilter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">Statements</h2>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Statements</h2>
|
||||||
|
{!isLoading && statements && (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{hasFilters ? `${filtered.length} of ${statements.length}` : statements.length} statements
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
{!isLoading && statements && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<select value={bankFilter} onChange={(e) => setBankFilter(e.target.value)} className={selectCls}>
|
||||||
|
<option value="">All banks</option>
|
||||||
|
{banks.map((b) => (
|
||||||
|
<option key={b} value={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)} className={selectCls}>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
<option value="card">Credit card</option>
|
||||||
|
<option value="bank">Bank account</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{participants && participants.length > 1 && (
|
||||||
|
<select value={ownerFilter} onChange={(e) => setOwnerFilter(e.target.value)} className={selectCls}>
|
||||||
|
<option value="">All owners</option>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<option key={p.id} value={String(p.id)}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select value={yearFilter} onChange={(e) => setYearFilter(e.target.value)} className={selectCls}>
|
||||||
|
<option value="">All years</option>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>{y}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setBankFilter(""); setTypeFilter("all"); setOwnerFilter(""); setYearFilter(""); }}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
× Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-zinc-500">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
) : !statements?.length ? (
|
) : !filtered.length ? (
|
||||||
<p className="text-zinc-500">No statements found</p>
|
<p className="text-zinc-500 text-sm">{hasFilters ? "No statements match filters" : "No statements found"}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="border border-zinc-700 rounded-xl overflow-x-auto">
|
||||||
{statements.map((s) => (
|
<table className="w-full text-sm min-w-[800px]">
|
||||||
<div
|
<thead>
|
||||||
key={s.id}
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
className="border border-zinc-800 rounded-lg p-4 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
|
<th className="text-left px-3 py-2.5 text-xs text-zinc-600 font-medium w-8">#</th>
|
||||||
>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Bank</th>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
|
||||||
<h3 className="font-medium">{s.bank_name}</h3>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
|
||||||
<span className="text-xs text-zinc-500">{s.currency}</span>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</th>
|
||||||
</div>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Ccy</th>
|
||||||
{s.card_name && (
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||||
<p className="text-sm text-zinc-400 mb-2">{s.card_name}</p>
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Txns</th>
|
||||||
)}
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Owner</th>
|
||||||
<div className="text-sm text-zinc-400 space-y-1">
|
<th className="px-4 py-2.5 hidden sm:table-cell"></th>
|
||||||
<p>Account: {s.account_number}</p>
|
</tr>
|
||||||
<p>
|
</thead>
|
||||||
Period: {formatDate(s.billing_start_date)} - {formatDate(s.billing_end_date)}
|
<tbody>
|
||||||
</p>
|
{filtered.map((s, idx) => {
|
||||||
<p>Due: {formatDate(s.payment_due_date)}</p>
|
const isCreditCard = s.statement_type?.toLowerCase().includes("card") ?? false;
|
||||||
</div>
|
const displayAmount = isCreditCard ? s.total_amount_due : s.closing_balance;
|
||||||
<div className="mt-3 pt-3 border-t border-zinc-800 flex items-center justify-between">
|
const amount = Number(displayAmount);
|
||||||
<div>
|
const amountColor = isCreditCard
|
||||||
<p className="text-lg font-semibold text-red-400">
|
? "text-red-400"
|
||||||
{formatCurrency(s.total_amount_due, s.currency)}
|
: amount >= 0
|
||||||
</p>
|
? "text-green-400"
|
||||||
<p className="text-xs text-zinc-500">
|
: "text-red-400";
|
||||||
{s.transaction_count} transactions
|
|
||||||
</p>
|
return (
|
||||||
</div>
|
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
|
||||||
<Link
|
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
|
||||||
href={`/transactions?statement_id=${s.id}`}
|
<td className="px-4 py-3 sticky left-0 z-10 bg-zinc-950 border-r border-zinc-800/80">
|
||||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-sm transition-colors"
|
<div className="font-medium truncate max-w-[160px]" title={s.bank_name}>
|
||||||
>
|
{s.bank_name}
|
||||||
View
|
</div>
|
||||||
</Link>
|
{s.card_name && (
|
||||||
</div>
|
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
<Link
|
||||||
|
href={`/transactions?statement_id=${s.id}`}
|
||||||
|
className="sm:hidden text-xs text-indigo-400 hover:text-indigo-300 mt-1 inline-block"
|
||||||
|
>
|
||||||
|
View →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-400 font-mono text-xs">
|
||||||
|
{s.account_number}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||||
|
{formatPeriod(s.billing_start_date, s.billing_end_date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||||
|
{isCreditCard ? formatDate(s.payment_due_date) : formatDate(s.billing_end_date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||||||
|
{s.currency}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{displayAmount !== null && displayAmount !== undefined ? (
|
||||||
|
<span className={amountColor}>{formatAmount(displayAmount)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-zinc-500">
|
||||||
|
{s.transaction_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{participants?.length ? (
|
||||||
|
<select
|
||||||
|
value={s.owner_id ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateStatement.mutate({ id: s.id, owner_id: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600 text-xs">{s.owner_name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<Link
|
||||||
|
href={`/transactions?statement_id=${s.id}`}
|
||||||
|
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-xs transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
View →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+650
-68
@@ -1,10 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
|
||||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser } from "@/lib/hooks";
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
import { SplitModal } from "@/components/split-modal";
|
import { SplitModal } from "@/components/split-modal";
|
||||||
import { TagPicker } from "@/components/tag-picker";
|
import { TagPicker } from "@/components/tag-picker";
|
||||||
|
import { AddTransactionModal } from "@/components/add-transaction-modal";
|
||||||
|
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||||
|
import { CsvImportModal } from "@/components/csv-import-modal";
|
||||||
|
import type { TransactionRow } from "@/lib/queries";
|
||||||
|
|
||||||
function formatDate(d: string) {
|
function formatDate(d: string) {
|
||||||
return new Date(d).toLocaleDateString("en-AU", {
|
return new Date(d).toLocaleDateString("en-AU", {
|
||||||
@@ -14,30 +19,135 @@ function formatDate(d: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||||
|
|
||||||
function formatAmount(amount: number, type: string) {
|
function formatAmount(amount: number, type: string) {
|
||||||
const formatted = new Intl.NumberFormat("en-AU", {
|
const formatted = new Intl.NumberFormat("en-AU", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "AUD",
|
currency: "AUD",
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
return type === "debit" ? formatted : `+${formatted}`;
|
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
debit: "bg-red-900/30 text-red-400",
|
||||||
|
credit: "bg-green-900/30 text-green-400",
|
||||||
|
payment: "bg-blue-900/30 text-blue-400",
|
||||||
|
refund: "bg-emerald-900/30 text-emerald-400",
|
||||||
|
fee: "bg-yellow-900/30 text-yellow-400",
|
||||||
|
interest: "bg-orange-900/30 text-orange-400",
|
||||||
|
transfer: "bg-zinc-800 text-zinc-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
"debit", "credit", "payment", "refund", "fee", "interest", "transfer",
|
||||||
|
].map((t) => ({ value: t, label: t }));
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: string }) {
|
function TypeBadge({ type }: { type: string }) {
|
||||||
const colors: Record<string, string> = {
|
|
||||||
debit: "bg-red-900/30 text-red-400",
|
|
||||||
credit: "bg-green-900/30 text-green-400",
|
|
||||||
payment: "bg-blue-900/30 text-blue-400",
|
|
||||||
refund: "bg-emerald-900/30 text-emerald-400",
|
|
||||||
fee: "bg-yellow-900/30 text-yellow-400",
|
|
||||||
interest: "bg-orange-900/30 text-orange-400",
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colors[type] || "bg-zinc-800 text-zinc-400"}`}>
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${TYPE_COLORS[type] || "bg-zinc-800 text-zinc-400"}`}>
|
||||||
{type}
|
{type}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditableTypeBadge({ type, onSave }: { type: string; onSave: (t: string) => void }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setEditing(true)} title="Click to change type">
|
||||||
|
<TypeBadge type={type} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
autoFocus
|
||||||
|
defaultValue={type}
|
||||||
|
onBlur={(e) => { onSave(e.target.value); setEditing(false); }}
|
||||||
|
onChange={(e) => { onSave(e.target.value); setEditing(false); }}
|
||||||
|
className="bg-zinc-800 border border-zinc-600 rounded px-1 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{TYPE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt shown after a merchant/category edit — offers to save it as a rule
|
||||||
|
function SaveAsRulePrompt({
|
||||||
|
tx,
|
||||||
|
field,
|
||||||
|
newValue,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
||||||
|
field: "category" | "merchant";
|
||||||
|
newValue: string;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const createRule = useCreateRule();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Build a sensible default rule from the transaction context.
|
||||||
|
// Prefer merchant_normalized (full name, exact match) over a partial description word.
|
||||||
|
const hasMerchant = !!tx.effective_merchant;
|
||||||
|
const conditionField = hasMerchant ? "merchant_normalized" : "description";
|
||||||
|
const conditionOperator = hasMerchant ? "equals" : "contains";
|
||||||
|
const conditionValue = hasMerchant ? tx.effective_merchant : tx.description;
|
||||||
|
|
||||||
|
const defaultName =
|
||||||
|
field === "category"
|
||||||
|
? `${conditionValue} → ${formatCategory(newValue)}`
|
||||||
|
: `Rename ${conditionValue} → ${newValue}`;
|
||||||
|
|
||||||
|
const conditions = [{ field: conditionField, operator: conditionOperator, value: conditionValue }];
|
||||||
|
const actions =
|
||||||
|
field === "category"
|
||||||
|
? { set_category: newValue }
|
||||||
|
: { set_merchant: newValue };
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
await createRule.mutateAsync({ name: defaultName, conditions, actions, enabled: true, priority: 0 });
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 bg-zinc-800 border border-zinc-600 rounded-xl shadow-2xl p-4 w-80 text-sm">
|
||||||
|
<p className="text-zinc-200 font-medium mb-1">Save as rule?</p>
|
||||||
|
<p className="text-zinc-400 text-xs mb-3">
|
||||||
|
Automatically apply this {field === "category" ? "category" : "merchant name"} to future matching transactions.
|
||||||
|
</p>
|
||||||
|
<div className="bg-zinc-900 rounded-lg px-3 py-2 text-xs text-zinc-300 mb-3 space-y-1">
|
||||||
|
<p><span className="text-zinc-500">If</span> {conditionField === "merchant_normalized" ? "merchant" : "description"} {conditionOperator} <span className="text-white">"{conditionValue}"</span></p>
|
||||||
|
<p>
|
||||||
|
<span className="text-zinc-500">Then</span>{" "}
|
||||||
|
{field === "category"
|
||||||
|
? <>set category → <span className="text-white">{formatCategory(newValue)}</span></>
|
||||||
|
: <>set merchant → <span className="text-white">{newValue}</span></>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded-lg py-1.5 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save rule"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDone}
|
||||||
|
className="flex-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 rounded-lg py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InlineEdit({
|
function InlineEdit({
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -97,27 +207,321 @@ function InlineEdit({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mark as Payment modal ─────────────────────────────────────────────────────
|
||||||
|
function MarkAsPaymentModal({
|
||||||
|
transaction,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
transaction: TransactionRow;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { data: participants = [] } = useParticipants();
|
||||||
|
const { data: me } = useCurrentUser();
|
||||||
|
const record = useRecordPayment();
|
||||||
|
|
||||||
|
const others = participants.filter((p) => p.id !== me?.id);
|
||||||
|
|
||||||
|
const [participantId, setParticipantId] = useState<number | "">(others[0]?.id ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (participantId === "" && others.length > 0) {
|
||||||
|
setParticipantId(others[0].id);
|
||||||
|
}
|
||||||
|
}, [others]);
|
||||||
|
// For credits/refunds the default direction is "they paid me"
|
||||||
|
const [direction, setDirection] = useState<"received" | "sent">(
|
||||||
|
SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received"
|
||||||
|
);
|
||||||
|
const [amount, setAmount] = useState(Number(transaction.amount).toFixed(2));
|
||||||
|
const [date, setDate] = useState(transaction.transaction_date.slice(0, 10));
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const selectedParticipant = others.find((p) => p.id === participantId);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setError("");
|
||||||
|
if (!participantId || !me) { setError("Select a participant"); return; }
|
||||||
|
const amt = parseFloat(amount);
|
||||||
|
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
|
||||||
|
try {
|
||||||
|
await record.mutateAsync({
|
||||||
|
from_participant_id: direction === "received" ? participantId : me.id,
|
||||||
|
to_participant_id: direction === "received" ? me.id : participantId,
|
||||||
|
amount: amt,
|
||||||
|
payment_date: date,
|
||||||
|
notes: notes || undefined,
|
||||||
|
linked_transaction_id: transaction.id,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to record");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 w-full max-w-sm mx-4 shadow-2xl p-6 space-y-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm text-zinc-300">Record as Debt Payment</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5 truncate">{transaction.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Participant */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Participant</label>
|
||||||
|
<select
|
||||||
|
value={participantId}
|
||||||
|
onChange={(e) => setParticipantId(Number(e.target.value))}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{others.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Direction */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-zinc-700 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDirection("received")}
|
||||||
|
className={`flex-1 py-1.5 transition-colors ${direction === "received" ? "bg-emerald-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
|
||||||
|
>
|
||||||
|
{selectedParticipant?.name ?? "They"} paid me
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDirection("sent")}
|
||||||
|
className={`flex-1 py-1.5 transition-colors ${direction === "sent" ? "bg-blue-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
|
||||||
|
>
|
||||||
|
I paid {selectedParticipant?.name ?? "them"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
|
||||||
|
<input
|
||||||
|
type="number" step="0.01" min="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 pl-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
|
||||||
|
<input
|
||||||
|
value={notes} onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="e.g. bank transfer reference"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="flex-1 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={record.isPending}
|
||||||
|
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
|
||||||
|
{record.isPending ? "Saving…" : "Record Payment"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Query bar parser ──────────────────────────────────────────────────────────
|
||||||
|
interface QueryToken { key: string; label: string }
|
||||||
|
interface ParsedQuery { text: string; amountMin?: number; amountMax?: number; tokens: QueryToken[] }
|
||||||
|
|
||||||
|
function parseQuery(input: string): ParsedQuery {
|
||||||
|
let text = input;
|
||||||
|
let amountMin: number | undefined;
|
||||||
|
let amountMax: number | undefined;
|
||||||
|
const tokens: QueryToken[] = [];
|
||||||
|
|
||||||
|
// Range shorthand: 500-1500
|
||||||
|
text = text.replace(/\b(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\b/g, (_, a, b) => {
|
||||||
|
amountMin = parseFloat(a);
|
||||||
|
amountMax = parseFloat(b);
|
||||||
|
tokens.push({ key: "range", label: `$${a}–$${b}` });
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Operators: >=500 <=500 >500 <500
|
||||||
|
text = text.replace(/(>=|<=|>|<)\s*(\d+(?:\.\d+)?)/g, (_, op, num) => {
|
||||||
|
const val = parseFloat(num);
|
||||||
|
if (op === ">") amountMin = val + 0.005;
|
||||||
|
else if (op === ">=") amountMin = val;
|
||||||
|
else if (op === "<") amountMax = Math.max(0, val - 0.005);
|
||||||
|
else if (op === "<=") amountMax = val;
|
||||||
|
const display = op === ">=" ? "≥" : op === "<=" ? "≤" : op;
|
||||||
|
tokens.push({ key: `amt_${op}`, label: `${display} $${num}` });
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
return { text: text.trim(), amountMin, amountMax, tokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MultiSelect dropdown ──────────────────────────────────────────────────────
|
||||||
|
function MultiSelect({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
value: string[];
|
||||||
|
onChange: (v: string[]) => void;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handler(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (v: string) =>
|
||||||
|
onChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
|
||||||
|
|
||||||
|
const label =
|
||||||
|
value.length === 0
|
||||||
|
? placeholder
|
||||||
|
: value.length === 1
|
||||||
|
? (options.find((o) => o.value === value[0])?.label ?? value[0])
|
||||||
|
: `${value.length} selected`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`bg-zinc-900 border rounded px-3 py-1.5 text-sm flex items-center gap-2 min-w-[140px] whitespace-nowrap ${
|
||||||
|
value.length > 0 ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1 text-left truncate">{label}</span>
|
||||||
|
<span className="text-zinc-500 text-xs">▾</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full mt-1 z-20 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-[160px] max-h-64 overflow-y-auto">
|
||||||
|
{options.map((o) => (
|
||||||
|
<label
|
||||||
|
key={o.value}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.includes(o.value)}
|
||||||
|
onChange={() => toggle(o.value)}
|
||||||
|
className="accent-indigo-500 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
{o.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
|
||||||
|
<TransactionsContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransactionsContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialStatementId = searchParams.get("statement_id") || "";
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
from: "",
|
from: "",
|
||||||
to: "",
|
to: "",
|
||||||
category: "",
|
categories: [] as string[],
|
||||||
bank_name: "",
|
bank_names: [] as string[],
|
||||||
|
tag_ids: [] as string[],
|
||||||
|
transaction_types: [] as string[],
|
||||||
search: "",
|
search: "",
|
||||||
tag_id: "",
|
statement_id: initialStatementId,
|
||||||
sort_by: "transaction_date",
|
sort_by: "transaction_date",
|
||||||
sort_dir: "desc",
|
sort_dir: "desc",
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
amount_min: undefined as number | undefined,
|
||||||
|
amount_max: undefined as number | undefined,
|
||||||
|
has_split: "" as string,
|
||||||
});
|
});
|
||||||
|
const [queryInput, setQueryInput] = useState("");
|
||||||
|
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
|
||||||
|
|
||||||
|
function handleQueryChange(val: string) {
|
||||||
|
setQueryInput(val);
|
||||||
|
const parsed = parseQuery(val);
|
||||||
|
setQueryTokens(parsed.tokens);
|
||||||
|
setFilters((f) => ({
|
||||||
|
...f,
|
||||||
|
search: parsed.text,
|
||||||
|
amount_min: parsed.amountMin,
|
||||||
|
amount_max: parsed.amountMax,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueryToken(key: string) {
|
||||||
|
// Rebuild input without the token's contribution by re-running parse on cleared input
|
||||||
|
// Simplest: just clear the whole query bar
|
||||||
|
const next = queryInput
|
||||||
|
.replace(/(>=|<=|>|<)\s*\d+(?:\.\d+)?/g, "")
|
||||||
|
.replace(/\b\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?\b/g, "")
|
||||||
|
.trim();
|
||||||
|
handleQueryChange(next);
|
||||||
|
}
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [bulkCategory, setBulkCategory] = useState("");
|
const [bulkCategory, setBulkCategory] = useState("");
|
||||||
const [bulkTagId, setBulkTagId] = useState("");
|
const [bulkTagId, setBulkTagId] = useState("");
|
||||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null);
|
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 [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
|
||||||
|
const [rulePrompt, setRulePrompt] = useState<{
|
||||||
|
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
||||||
|
field: "category" | "merchant";
|
||||||
|
newValue: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useTransactions(filters);
|
const { data, isLoading } = useTransactions(filters);
|
||||||
const { data: banks } = useBanks();
|
const { data: banks } = useBanks();
|
||||||
const { data: tags } = useTags();
|
const { data: tags } = useTags();
|
||||||
|
const { data: me } = useCurrentUser();
|
||||||
|
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
|
||||||
const updateTxn = useUpdateTransaction();
|
const updateTxn = useUpdateTransaction();
|
||||||
const bulkAction = useBulkAction();
|
const bulkAction = useBulkAction();
|
||||||
|
|
||||||
@@ -159,17 +563,78 @@ export default function TransactionsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Import CSV
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statement context banner */}
|
||||||
|
{filters.statement_id && statementInfo && (
|
||||||
|
<div className="flex items-center gap-3 mb-4 px-3 py-2 bg-indigo-950/40 border border-indigo-800/50 rounded-lg text-sm">
|
||||||
|
<span className="text-indigo-300 font-medium">{statementInfo.bank_name}</span>
|
||||||
|
{statementInfo.billing_start_date && statementInfo.billing_end_date && (
|
||||||
|
<span className="text-zinc-400">
|
||||||
|
{new Date(statementInfo.billing_start_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
|
||||||
|
{" – "}
|
||||||
|
{new Date(statementInfo.billing_end_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-zinc-500 text-xs">{statementInfo.transaction_count} transactions</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, statement_id: "", offset: 0 }))}
|
||||||
|
className="ml-auto text-zinc-500 hover:text-zinc-200 text-xs px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
× Clear filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div className="flex flex-wrap gap-3 mb-4">
|
<div className="flex flex-wrap gap-3 mb-2">
|
||||||
<input
|
{/* Smart query bar */}
|
||||||
type="text"
|
<div className="flex flex-col gap-1">
|
||||||
placeholder="Search..."
|
<input
|
||||||
value={filters.search}
|
type="text"
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value, offset: 0 }))}
|
value={queryInput}
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
|
onChange={(e) => handleQueryChange(e.target.value)}
|
||||||
/>
|
placeholder="Search… or >500 <=1500 200-800"
|
||||||
|
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-full sm:w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
{queryTokens.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{queryTokens.map((tok) => (
|
||||||
|
<span
|
||||||
|
key={tok.key}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-indigo-900/50 border border-indigo-700/50 text-indigo-300"
|
||||||
|
>
|
||||||
|
{tok.label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => clearQueryToken(tok.key)}
|
||||||
|
className="text-indigo-400 hover:text-white leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date range */}
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.from}
|
value={filters.from}
|
||||||
@@ -182,35 +647,45 @@ export default function TransactionsPage() {
|
|||||||
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value, offset: 0 }))}
|
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value, offset: 0 }))}
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
|
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Multi-select dropdowns */}
|
||||||
|
<MultiSelect
|
||||||
|
options={CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }))}
|
||||||
|
value={filters.categories}
|
||||||
|
onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))}
|
||||||
|
placeholder="All Categories"
|
||||||
|
/>
|
||||||
|
<MultiSelect
|
||||||
|
options={(banks ?? []).map((b) => ({ value: b, label: b }))}
|
||||||
|
value={filters.bank_names}
|
||||||
|
onChange={(v) => setFilters((f) => ({ ...f, bank_names: v, offset: 0 }))}
|
||||||
|
placeholder="All Banks"
|
||||||
|
/>
|
||||||
|
<MultiSelect
|
||||||
|
options={[{ value: "untagged", label: "No tags" }, ...(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))]}
|
||||||
|
value={filters.tag_ids}
|
||||||
|
onChange={(v) => {
|
||||||
|
let next = v;
|
||||||
|
if (v.includes("untagged") && !filters.tag_ids.includes("untagged")) next = ["untagged"];
|
||||||
|
else if (filters.tag_ids.includes("untagged") && v.length > 1) next = v.filter((x) => x !== "untagged");
|
||||||
|
setFilters((f) => ({ ...f, tag_ids: next, offset: 0 }));
|
||||||
|
}}
|
||||||
|
placeholder="All Tags"
|
||||||
|
/>
|
||||||
|
<MultiSelect
|
||||||
|
options={TYPE_OPTIONS}
|
||||||
|
value={filters.transaction_types}
|
||||||
|
onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
|
||||||
|
placeholder="All Types"
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
value={filters.category}
|
value={filters.has_split}
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, offset: 0 }))}
|
onChange={(e) => setFilters((f) => ({ ...f, has_split: e.target.value, offset: 0 }))}
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
|
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
|
||||||
>
|
>
|
||||||
<option value="">All Categories</option>
|
<option value="">All Splits</option>
|
||||||
{CATEGORIES.map((c) => (
|
<option value="yes">Split only</option>
|
||||||
<option key={c} value={c}>{formatCategory(c)}</option>
|
<option value="no">Unsplit only</option>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={filters.bank_name}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, bank_name: e.target.value, offset: 0 }))}
|
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">All Banks</option>
|
|
||||||
{banks?.map((b) => (
|
|
||||||
<option key={b} value={b}>{b}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={filters.tag_id}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, tag_id: e.target.value, offset: 0 }))}
|
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">All Tags</option>
|
|
||||||
{tags?.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>{t.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,10 +759,10 @@ export default function TransactionsPage() {
|
|||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm min-w-[900px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900/50">
|
<tr className="border-b border-zinc-800 bg-zinc-900/50">
|
||||||
<th className="p-2 w-8">
|
<th className="p-2 w-8 sticky left-0 z-10 bg-zinc-900">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={data?.data.length ? selected.size === data.data.length : false}
|
checked={data?.data.length ? selected.size === data.data.length : false}
|
||||||
@@ -296,11 +771,17 @@ export default function TransactionsPage() {
|
|||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="p-2 text-left cursor-pointer hover:text-white"
|
className="p-2 text-left cursor-pointer hover:text-white sticky left-8 z-10 bg-zinc-900 border-r border-zinc-800/80 whitespace-nowrap"
|
||||||
onClick={() => toggleSort("transaction_date")}
|
onClick={() => toggleSort("transaction_date")}
|
||||||
>
|
>
|
||||||
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||||
</th>
|
</th>
|
||||||
|
<th
|
||||||
|
className="p-2 text-left text-zinc-500 cursor-pointer hover:text-white whitespace-nowrap"
|
||||||
|
onClick={() => toggleSort("created_at")}
|
||||||
|
>
|
||||||
|
Imported {filters.sort_by === "created_at" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||||
|
</th>
|
||||||
<th className="p-2 text-left">Description</th>
|
<th className="p-2 text-left">Description</th>
|
||||||
<th className="p-2 text-left">Merchant</th>
|
<th className="p-2 text-left">Merchant</th>
|
||||||
<th
|
<th
|
||||||
@@ -318,9 +799,9 @@ export default function TransactionsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">Loading...</td></tr>
|
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">Loading...</td></tr>
|
||||||
) : !data?.data.length ? (
|
) : !data?.data.length ? (
|
||||||
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||||
) : (
|
) : (
|
||||||
data.data.map((t) => (
|
data.data.map((t) => (
|
||||||
<tr
|
<tr
|
||||||
@@ -329,7 +810,7 @@ export default function TransactionsPage() {
|
|||||||
selected.has(t.id) ? "bg-zinc-800/40" : ""
|
selected.has(t.id) ? "bg-zinc-800/40" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="p-2">
|
<td className={`p-2 sticky left-0 z-10 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(t.id)}
|
checked={selected.has(t.id)}
|
||||||
@@ -337,13 +818,22 @@ export default function TransactionsPage() {
|
|||||||
className="accent-blue-600"
|
className="accent-blue-600"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</td>
|
<td className={`p-2 whitespace-nowrap sticky left-8 z-10 border-r border-zinc-800/80 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>{formatDate(t.transaction_date)}</td>
|
||||||
<td className="p-2 max-w-xs truncate" title={t.description}>{t.description}</td>
|
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</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]">
|
<td className="p-2 max-w-[150px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={t.effective_merchant || ""}
|
value={t.effective_merchant || ""}
|
||||||
onSave={(val) => updateTxn.mutate({ id: t.id, merchant_normalized: val })}
|
onSave={(val) => {
|
||||||
|
updateTxn.mutate({ id: t.id, merchant_normalized: val });
|
||||||
|
setRulePrompt({ tx: t, field: "merchant", newValue: val });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{t.merchant_override && (
|
{t.merchant_override && (
|
||||||
<span className="absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" title="Manually overridden" />
|
<span className="absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" title="Manually overridden" />
|
||||||
@@ -351,16 +841,24 @@ export default function TransactionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`p-2 text-right whitespace-nowrap font-mono ${
|
<td className={`p-2 text-right whitespace-nowrap font-mono ${
|
||||||
t.transaction_type === "debit" ? "text-red-400" : "text-green-400"
|
SPEND_TYPES.has(t.transaction_type) ? "text-red-400" : "text-green-400"
|
||||||
}`}>
|
}`}>
|
||||||
{formatAmount(t.amount, t.transaction_type)}
|
{formatAmount(t.amount, t.transaction_type)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2"><TypeBadge type={t.transaction_type} /></td>
|
<td className="p-2">
|
||||||
|
<EditableTypeBadge
|
||||||
|
type={t.transaction_type}
|
||||||
|
onSave={(val) => updateTxn.mutate({ id: t.id, transaction_type: val })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="p-2 max-w-[140px]">
|
<td className="p-2 max-w-[140px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={t.effective_category}
|
value={t.effective_category}
|
||||||
onSave={(val) => updateTxn.mutate({ id: t.id, category: val })}
|
onSave={(val) => {
|
||||||
|
updateTxn.mutate({ id: t.id, category: val });
|
||||||
|
setRulePrompt({ tx: t, field: "category", newValue: val });
|
||||||
|
}}
|
||||||
type="select"
|
type="select"
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
/>
|
/>
|
||||||
@@ -384,13 +882,63 @@ export default function TransactionsPage() {
|
|||||||
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
|
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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.participant_id !== me?.id).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.participant_id !== me?.id)
|
||||||
|
? "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
|
<button
|
||||||
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, 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"
|
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>
|
||||||
|
{!SPEND_TYPES.has(t.transaction_type) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentModal(t)}
|
||||||
|
className="text-xs text-emerald-600 hover:text-emerald-400 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
|
title="Record as debt payment"
|
||||||
|
>
|
||||||
|
Payment
|
||||||
|
</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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -407,10 +955,44 @@ export default function TransactionsPage() {
|
|||||||
transactionIds={splitModal.transactionIds}
|
transactionIds={splitModal.transactionIds}
|
||||||
amount={splitModal.amount}
|
amount={splitModal.amount}
|
||||||
description={splitModal.description}
|
description={splitModal.description}
|
||||||
|
merchant={splitModal.merchant}
|
||||||
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
|
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showImportModal && <CsvImportModal onClose={() => setShowImportModal(false)} />}
|
||||||
|
|
||||||
|
{addModal && (
|
||||||
|
<AddTransactionModal
|
||||||
|
prefill={addModal.prefill}
|
||||||
|
title={addModal.title}
|
||||||
|
onClose={() => setAddModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editModal && (
|
||||||
|
<EditTransactionModal
|
||||||
|
transaction={editModal}
|
||||||
|
onClose={() => setEditModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paymentModal && (
|
||||||
|
<MarkAsPaymentModal
|
||||||
|
transaction={paymentModal}
|
||||||
|
onClose={() => setPaymentModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rulePrompt && (
|
||||||
|
<SaveAsRulePrompt
|
||||||
|
tx={rulePrompt.tx}
|
||||||
|
field={rulePrompt.field}
|
||||||
|
newValue={rulePrompt.newValue}
|
||||||
|
onDone={() => setRulePrompt(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{data && data.total > filters.limit && (
|
{data && data.total > filters.limit && (
|
||||||
<div className="flex items-center justify-between mt-4">
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useCreateTransaction, useParticipants, useTags } 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 { data: allTags = [] } = useTags();
|
||||||
|
|
||||||
|
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 [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTag(id: number) {
|
||||||
|
setSelectedTagIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const result = await createTransaction.mutateAsync({
|
||||||
|
date,
|
||||||
|
description,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
transaction_type: type,
|
||||||
|
merchant_normalized: merchant || undefined,
|
||||||
|
category: category || undefined,
|
||||||
|
splits: splits.length ? splits : undefined,
|
||||||
|
});
|
||||||
|
if (selectedTagIds.length && result?.id) {
|
||||||
|
await Promise.all(
|
||||||
|
selectedTagIds.map((tagId) =>
|
||||||
|
fetch(`/api/transactions/${result.id}/tags`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tag_id: tagId }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Tags (optional)</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{allTags.map((tag) => {
|
||||||
|
const selected = selectedTagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTag(tag.id)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs border transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-transparent text-white"
|
||||||
|
: "border-zinc-600 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
style={selected ? { backgroundColor: tag.color + "cc", borderColor: tag.color } : {}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</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,413 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
import { useImportCSV } from "@/lib/hooks";
|
||||||
|
import {
|
||||||
|
parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping,
|
||||||
|
saveBankPreset, loadBankPresets,
|
||||||
|
type DateFormat, type ColumnMapping, type ParsedTransaction, type BankPreset,
|
||||||
|
} from "@/lib/csv-parser";
|
||||||
|
|
||||||
|
const DATE_FORMATS: DateFormat[] = ["DD/MM/YYYY", "YYYY-MM-DD", "MM/DD/YYYY", "M/D/YYYY"];
|
||||||
|
const TX_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
|
|
||||||
|
type Step = "upload" | "map" | "review" | "done";
|
||||||
|
|
||||||
|
function ColSelect({
|
||||||
|
label, value, onChange, options, required,
|
||||||
|
}: {
|
||||||
|
label: string; value: string; onChange: (v: string) => void;
|
||||||
|
options: string[]; required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">{label}</label>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{!required && <option value="">— none —</option>}
|
||||||
|
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const importCSV = useImportCSV();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("upload");
|
||||||
|
const [rawRows, setRawRows] = useState<string[][]>([]);
|
||||||
|
const [hasHeaders, setHasHeaders] = useState(false);
|
||||||
|
const [columnLabels, setColumnLabels] = useState<string[]>([]);
|
||||||
|
const [dataRows, setDataRows] = useState<string[][]>([]);
|
||||||
|
const [bankName, setBankName] = useState("");
|
||||||
|
const [dateFormat, setDateFormat] = useState<DateFormat>("DD/MM/YYYY");
|
||||||
|
const [mapping, setMapping] = useState<ColumnMapping>({
|
||||||
|
dateCol: "", descriptionCol: "", amountMode: "single", amountCol: "",
|
||||||
|
});
|
||||||
|
const [savePreset, setSavePreset] = useState(false);
|
||||||
|
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [presets, setPresets] = useState<BankPreset[]>([]);
|
||||||
|
const [insertedCount, setInsertedCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => { setPresets(loadBankPresets()); }, []);
|
||||||
|
|
||||||
|
function handleFile(file: File) {
|
||||||
|
setError("");
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
const rows = parseCSVRows(text);
|
||||||
|
if (rows.length === 0) { setError("No data found in file"); return; }
|
||||||
|
setRawRows(rows);
|
||||||
|
const headers = detectHasHeaders(rows, dateFormat);
|
||||||
|
setHasHeaders(headers);
|
||||||
|
const labels = getColumnLabels(rows, headers);
|
||||||
|
setColumnLabels(labels);
|
||||||
|
setDataRows(getDataRows(rows, headers));
|
||||||
|
// auto-set first columns as defaults
|
||||||
|
setMapping((m) => ({
|
||||||
|
...m,
|
||||||
|
dateCol: labels[0] ?? "",
|
||||||
|
descriptionCol: labels[1] ?? "",
|
||||||
|
amountCol: labels[2] ?? "",
|
||||||
|
}));
|
||||||
|
setStep("map");
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(preset: BankPreset) {
|
||||||
|
setBankName(preset.bankName);
|
||||||
|
setDateFormat(preset.dateFormat);
|
||||||
|
setMapping(preset.mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshColumns() {
|
||||||
|
if (!rawRows.length) return;
|
||||||
|
const headers = detectHasHeaders(rawRows, dateFormat);
|
||||||
|
setHasHeaders(headers);
|
||||||
|
const labels = getColumnLabels(rawRows, headers);
|
||||||
|
setColumnLabels(labels);
|
||||||
|
setDataRows(getDataRows(rawRows, headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
setError("");
|
||||||
|
if (!bankName.trim()) { setError("Bank name is required"); return; }
|
||||||
|
if (!mapping.dateCol) { setError("Date column is required"); return; }
|
||||||
|
if (!mapping.descriptionCol) { setError("Description column is required"); return; }
|
||||||
|
if (mapping.amountMode === "single" && !mapping.amountCol) { setError("Amount column is required"); return; }
|
||||||
|
if (mapping.amountMode === "debit_credit" && !mapping.debitCol && !mapping.creditCol) {
|
||||||
|
setError("At least one of debit/credit columns is required"); return;
|
||||||
|
}
|
||||||
|
const parsed = applyMapping(dataRows, columnLabels, mapping, dateFormat);
|
||||||
|
if (parsed.length === 0) { setError("No valid transactions could be parsed — check your column mapping and date format"); return; }
|
||||||
|
if (savePreset) {
|
||||||
|
saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat });
|
||||||
|
}
|
||||||
|
setEditedRows(parsed);
|
||||||
|
setStep("review");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
setError("");
|
||||||
|
const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description);
|
||||||
|
if (!valid.length) { setError("No valid rows to import"); return; }
|
||||||
|
try {
|
||||||
|
const result = await importCSV.mutateAsync({ bank_name: bankName, transactions: valid });
|
||||||
|
setInsertedCount(result.inserted);
|
||||||
|
setStep("done");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Import failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRow(i: number, patch: Partial<ParsedTransaction>) {
|
||||||
|
setEditedRows((rows) => rows.map((r, idx) => idx === i ? { ...r, ...patch } : r));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRow(i: number) {
|
||||||
|
setEditedRows((rows) => rows.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalWidth = step === "review" ? "max-w-5xl" : step === "map" ? "max-w-xl" : "max-w-md";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={`bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full ${modalWidth} flex flex-col max-h-[90vh]`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm text-zinc-200">Import CSV</h3>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
{(["upload", "map", "review", "done"] as Step[]).map((s, i) => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className={`text-xs ${step === s ? "text-indigo-400 font-medium" : "text-zinc-600"}`}
|
||||||
|
>
|
||||||
|
{i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||||||
|
|
||||||
|
{/* Step 1: Upload */}
|
||||||
|
{step === "upload" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{presets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Load saved preset</label>
|
||||||
|
<select
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const p = presets.find((x) => x.bankName === e.target.value);
|
||||||
|
if (p) applyPreset(p);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— select preset —</option>
|
||||||
|
{presets.map((p) => <option key={p.bankName} value={p.bankName}>{p.bankName}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="w-full border-2 border-dashed border-zinc-700 hover:border-indigo-500 rounded-xl py-12 text-center transition-colors"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
|
||||||
|
>
|
||||||
|
<p className="text-zinc-400 text-sm">Drop a CSV file here, or click to browse</p>
|
||||||
|
<p className="text-zinc-600 text-xs mt-1">Westpac, ANZ, CBA, NAB and others</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Map */}
|
||||||
|
{step === "map" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Raw preview */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-2">First 3 rows from file:</p>
|
||||||
|
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||||
|
<table className="text-xs text-zinc-400 w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
{columnLabels.map((h) => (
|
||||||
|
<th key={h} className="px-2 py-1.5 text-left font-medium text-zinc-300 whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dataRows.slice(0, 3).map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-zinc-800/50">
|
||||||
|
{columnLabels.map((_, ci) => (
|
||||||
|
<td key={ci} className="px-2 py-1 truncate max-w-[160px]">{row[ci] ?? ""}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Bank Name</label>
|
||||||
|
<input
|
||||||
|
value={bankName}
|
||||||
|
onChange={(e) => setBankName(e.target.value)}
|
||||||
|
placeholder="e.g. Westpac"
|
||||||
|
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">Date Format</label>
|
||||||
|
<select
|
||||||
|
value={dateFormat}
|
||||||
|
onChange={(e) => { setDateFormat(e.target.value as DateFormat); refreshColumns(); }}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{DATE_FORMATS.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ColSelect label="Date Column *" value={mapping.dateCol} onChange={(v) => setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required />
|
||||||
|
<ColSelect label="Description Column *" value={mapping.descriptionCol} onChange={(v) => setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-2">Amount</label>
|
||||||
|
<div className="flex gap-4 mb-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="radio" name="amtmode" value="single" checked={mapping.amountMode === "single"} onChange={() => setMapping((m) => ({ ...m, amountMode: "single" }))} className="accent-indigo-500" />
|
||||||
|
Single signed column
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="radio" name="amtmode" value="debit_credit" checked={mapping.amountMode === "debit_credit"} onChange={() => setMapping((m) => ({ ...m, amountMode: "debit_credit" }))} className="accent-indigo-500" />
|
||||||
|
Separate debit / credit columns
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{mapping.amountMode === "single" ? (
|
||||||
|
<ColSelect label="Amount Column *" value={mapping.amountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, amountCol: v }))} options={columnLabels} required />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ColSelect label="Debit Column" value={mapping.debitCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, debitCol: v }))} options={columnLabels} />
|
||||||
|
<ColSelect label="Credit Column" value={mapping.creditCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, creditCol: v }))} options={columnLabels} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
|
||||||
|
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
|
||||||
|
<input type="checkbox" checked={savePreset} onChange={(e) => setSavePreset(e.target.checked)} className="accent-indigo-500" />
|
||||||
|
Save as preset for {bankName || "this bank"}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Review */}
|
||||||
|
{step === "review" && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-3">
|
||||||
|
{editedRows.length} transactions parsed. Edit or remove rows before importing.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="border-b border-zinc-800">
|
||||||
|
<tr>
|
||||||
|
{["Date", "Description", "Amount", "Type", "Merchant", "Category", ""].map((h) => (
|
||||||
|
<th key={h} className="px-2 py-2 text-left text-zinc-400 font-medium whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{editedRows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input type="date" value={row.date} onChange={(e) => updateRow(i, { date: e.target.value })}
|
||||||
|
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input value={row.description} onChange={(e) => updateRow(i, { description: e.target.value })}
|
||||||
|
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-48 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input type="number" step="0.01" value={row.amount} onChange={(e) => updateRow(i, { amount: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-20 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<select value={row.transaction_type} onChange={(e) => updateRow(i, { transaction_type: e.target.value })}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||||||
|
{TX_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input value={row.merchant_name ?? ""} onChange={(e) => updateRow(i, { merchant_name: e.target.value || undefined })}
|
||||||
|
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<select value={row.category ?? ""} onChange={(e) => updateRow(i, { category: e.target.value || undefined })}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||||||
|
<option value="">—</option>
|
||||||
|
{CATEGORIES.map((c) => <option key={c} value={c}>{formatCategory(c)}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<button onClick={() => deleteRow(i)} className="text-zinc-600 hover:text-red-400 text-base leading-none">×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Done */}
|
||||||
|
{step === "done" && (
|
||||||
|
<div className="text-center py-6 space-y-3">
|
||||||
|
<div className="text-4xl">✓</div>
|
||||||
|
<p className="text-zinc-200 font-medium">Imported {insertedCount} transactions</p>
|
||||||
|
<p className="text-zinc-500 text-sm">Tagged with <span className="text-indigo-400">csv-import</span></p>
|
||||||
|
<div className="flex gap-2 justify-center pt-2">
|
||||||
|
<a href="/transactions" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
View Transactions
|
||||||
|
</a>
|
||||||
|
<a href="/reconcile" className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm">
|
||||||
|
Reconcile
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{step !== "done" && (
|
||||||
|
<div className="flex gap-2 px-6 py-4 border-t border-zinc-800 flex-shrink-0">
|
||||||
|
{step === "map" && (
|
||||||
|
<button onClick={() => setStep("upload")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === "review" && (
|
||||||
|
<button onClick={() => setStep("map")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button onClick={onClose} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{step === "map" && (
|
||||||
|
<button onClick={handleNext} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === "review" && (
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importCSV.isPending || editedRows.length === 0}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{importCSV.isPending ? "Importing..." : `Import ${editedRows.length} transactions`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||||
{ href: "/shared", label: "Shared", icon: "users" },
|
{ href: "/shared", label: "Shared", icon: "users" },
|
||||||
{ href: "/budget", label: "Budget", icon: "bar-chart" },
|
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
|
||||||
|
{ href: "/insights", label: "Insights", icon: "lightbulb" },
|
||||||
|
{ href: "/merchants", label: "Merchants", icon: "store" },
|
||||||
{ href: "/tags", label: "Tags", icon: "tag" },
|
{ href: "/tags", label: "Tags", icon: "tag" },
|
||||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
{ href: "/rules", label: "Rules", icon: "settings" },
|
||||||
|
{ href: "/reconcile", label: "Reconcile", icon: "git-merge" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ICONS: Record<string, React.ReactNode> = {
|
const ICONS: Record<string, React.ReactNode> = {
|
||||||
@@ -44,15 +48,53 @@ const ICONS: Record<string, React.ReactNode> = {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
lightbulb: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"git-merge": (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 3v12M18 9a3 3 0 100-6 3 3 0 000 6zm0 0v12M6 15a3 3 0 100 6 3 3 0 000-6zm0 0c0-4 3-6 6-6h6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
store: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
// Close drawer on route change
|
||||||
<aside className="w-56 bg-zinc-900 border-r border-zinc-800 flex flex-col min-h-screen">
|
useEffect(() => {
|
||||||
<div className="p-4 border-b border-zinc-800">
|
setOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Prevent body scroll when drawer is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
||||||
<h1 className="text-lg font-semibold text-white">Finance</h1>
|
<h1 className="text-lg font-semibold text-white">Finance</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="md:hidden p-1 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 p-2">
|
<nav className="flex-1 p-2">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
@@ -73,6 +115,46 @@ export function Sidebar() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile header bar */}
|
||||||
|
<div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-zinc-900 border-b border-zinc-800 flex items-center px-4 h-14">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="p-1.5 -ml-1.5 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="ml-3 text-base font-semibold text-white">Finance</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile drawer overlay */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="md:hidden fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile drawer */}
|
||||||
|
<aside
|
||||||
|
className={`md:hidden fixed top-0 left-0 z-50 w-64 h-full bg-zinc-900 border-r border-zinc-800 flex flex-col transform transition-transform duration-200 ease-in-out ${
|
||||||
|
open ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{navContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Desktop sidebar — unchanged */}
|
||||||
|
<aside className="hidden md:flex w-56 bg-zinc-900 border-r border-zinc-800 flex-col min-h-screen">
|
||||||
|
{navContent}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } from "@/lib/hooks";
|
||||||
|
|
||||||
|
interface Split {
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
transactionId?: number;
|
||||||
|
transactionIds?: number[];
|
||||||
|
amount?: number;
|
||||||
|
description: string;
|
||||||
|
merchant?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitModal({ transactionId, transactionIds, amount, description, merchant, onClose }: Props) {
|
||||||
|
const isBulk = !!transactionIds && transactionIds.length > 0;
|
||||||
|
const singleId = transactionId ?? 0;
|
||||||
|
|
||||||
|
const { data: participants } = useParticipants();
|
||||||
|
const { data: currentUser } = useCurrentUser();
|
||||||
|
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
|
||||||
|
const setSplits = useSetSplits();
|
||||||
|
const bulkAction = useBulkAction();
|
||||||
|
const createRule = useCreateRule();
|
||||||
|
|
||||||
|
const [splits, setSplitsState] = useState<Split[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [saveAsRule, setSaveAsRule] = useState(false);
|
||||||
|
const [ruleSaved, setRuleSaved] = useState(false);
|
||||||
|
|
||||||
|
// Initialise: bulk always defaults to 100% Me; single loads existing splits
|
||||||
|
useEffect(() => {
|
||||||
|
if (!participants || participants.length === 0 || !currentUser) return;
|
||||||
|
if (isBulk) {
|
||||||
|
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
|
||||||
|
} else if (existingSplits && existingSplits.length > 0) {
|
||||||
|
setSplitsState(
|
||||||
|
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
|
||||||
|
participant_id: s.participant_id,
|
||||||
|
share_percent: Number(s.share_percent),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
|
||||||
|
}
|
||||||
|
}, [existingSplits, participants, isBulk, currentUser]);
|
||||||
|
|
||||||
|
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
|
||||||
|
|
||||||
|
const toggleParticipant = (id: number) => {
|
||||||
|
setSplitsState((prev) => {
|
||||||
|
const exists = prev.find((s) => s.participant_id === id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.filter((s) => s.participant_id !== id);
|
||||||
|
}
|
||||||
|
// Add with equal split
|
||||||
|
const count = prev.length + 1;
|
||||||
|
const equal = Math.floor(100 / count);
|
||||||
|
const remainder = 100 - equal * count;
|
||||||
|
return [
|
||||||
|
...prev.map((s, i) => ({ ...s, share_percent: equal + (i === 0 ? remainder : 0) })),
|
||||||
|
{ participant_id: id, share_percent: equal },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShare = (id: number, value: number) => {
|
||||||
|
setSplitsState((prev) =>
|
||||||
|
prev.map((s) => (s.participant_id === id ? { ...s, share_percent: value } : s))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitEvenly = () => {
|
||||||
|
if (splits.length === 0) return;
|
||||||
|
const each = Math.floor(100 / splits.length);
|
||||||
|
const remainder = 100 - each * splits.length;
|
||||||
|
setSplitsState((prev) =>
|
||||||
|
prev.map((s, i) => ({ ...s, share_percent: each + (i === 0 ? remainder : 0) }))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = isBulk ? bulkAction.isPending : setSplits.isPending;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError("");
|
||||||
|
if (Math.abs(total - 100) > 0.01) {
|
||||||
|
setError(`Shares must sum to 100% (currently ${total.toFixed(1)}%)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isBulk) {
|
||||||
|
await bulkAction.mutateAsync({ action: "split", ids: transactionIds!, splits });
|
||||||
|
} else {
|
||||||
|
await setSplits.mutateAsync({ transactionId: singleId, splits });
|
||||||
|
}
|
||||||
|
if (saveAsRule && !isBulk) {
|
||||||
|
const matchValue = merchant || (description.split(" ")[0] ?? description);
|
||||||
|
await createRule.mutateAsync({
|
||||||
|
name: `Split: ${merchant || description}`,
|
||||||
|
conditions: [{ field: "merchant_normalized", operator: "contains", value: matchValue }],
|
||||||
|
actions: { apply_split: splits },
|
||||||
|
enabled: true,
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
setRuleSaved(true);
|
||||||
|
setTimeout(onClose, 1200);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to save splits");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md mx-4 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold mb-1">
|
||||||
|
{isBulk ? `Split ${transactionIds!.length} Transactions` : "Split Transaction"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-400 mb-4 truncate">{description}</p>
|
||||||
|
{!isBulk && amount !== undefined && (
|
||||||
|
<p className="text-2xl font-mono font-semibold mb-6">
|
||||||
|
${Number(amount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Participant toggles */}
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{participants?.map((p) => {
|
||||||
|
const split = splits.find((s) => s.participant_id === p.id);
|
||||||
|
const active = !!split;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleParticipant(p.id)}
|
||||||
|
className={`w-8 h-8 rounded-full text-sm font-medium flex-shrink-0 transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-zinc-800 text-zinc-500 hover:bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.name.charAt(0).toUpperCase()}
|
||||||
|
</button>
|
||||||
|
<span className="flex-1 text-sm">{p.name}</span>
|
||||||
|
{active && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={99}
|
||||||
|
value={split.share_percent}
|
||||||
|
onChange={(e) => updateShare(p.id, Number(e.target.value))}
|
||||||
|
className="w-24 accent-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="w-12 text-right text-sm font-mono">
|
||||||
|
{split.share_percent}%
|
||||||
|
</span>
|
||||||
|
{!isBulk && amount !== undefined && (
|
||||||
|
<span className="w-20 text-right text-sm text-zinc-400 font-mono">
|
||||||
|
${((amount * split.share_percent) / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total indicator */}
|
||||||
|
<div className="flex items-center justify-between mb-4 text-sm">
|
||||||
|
<span className={`font-mono ${Math.abs(total - 100) > 0.01 ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
Total: {total.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={splitEvenly}
|
||||||
|
className="text-blue-400 hover:text-blue-300 text-xs"
|
||||||
|
>
|
||||||
|
Split evenly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
|
||||||
|
|
||||||
|
{ruleSaved && (
|
||||||
|
<p className="text-green-400 text-sm mb-3">Rule saved — future matching transactions will be split the same way.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isBulk && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-zinc-400 mb-3 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={saveAsRule}
|
||||||
|
onChange={(e) => setSaveAsRule(e.target.checked)}
|
||||||
|
className="accent-blue-500"
|
||||||
|
/>
|
||||||
|
Also save as rule for <span className="text-zinc-200 font-medium">{merchant || description.split(" ")[0]}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isPending || Math.abs(total - 100) > 0.01}
|
||||||
|
className="flex-1 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? "Saving..." : "Save splits"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { queryRaw } from "./db";
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser(req: NextRequest): Promise<CurrentUser | null> {
|
||||||
|
const email = req.headers.get("x-forwarded-user");
|
||||||
|
|
||||||
|
// Dev fallback: no Traefik header → use participant id=1
|
||||||
|
if (!email) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
const rows = await queryRaw<CurrentUser>(`SELECT id, name, email FROM participants WHERE id = 1`);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await queryRaw<CurrentUser>(
|
||||||
|
`SELECT id, name, COALESCE(email, '') as email FROM participants WHERE email = $1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
@@ -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 = [
|
export const CATEGORIES = [
|
||||||
"groceries",
|
"groceries",
|
||||||
"dining",
|
"dining",
|
||||||
@@ -14,12 +20,16 @@ export const CATEGORIES = [
|
|||||||
"government",
|
"government",
|
||||||
"education",
|
"education",
|
||||||
"rent",
|
"rent",
|
||||||
|
"home_goods",
|
||||||
|
"home_maintenance",
|
||||||
"transfers",
|
"transfers",
|
||||||
"income",
|
"income",
|
||||||
|
"investment",
|
||||||
"personal_care",
|
"personal_care",
|
||||||
"pets",
|
"pets",
|
||||||
"gifts",
|
"gifts",
|
||||||
"charity",
|
"charity",
|
||||||
|
"fees",
|
||||||
"other",
|
"other",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
export type DateFormat = "DD/MM/YYYY" | "YYYY-MM-DD" | "MM/DD/YYYY" | "M/D/YYYY";
|
||||||
|
|
||||||
|
export interface ColumnMapping {
|
||||||
|
dateCol: string;
|
||||||
|
descriptionCol: string;
|
||||||
|
amountMode: "single" | "debit_credit";
|
||||||
|
amountCol?: string;
|
||||||
|
debitCol?: string;
|
||||||
|
creditCol?: string;
|
||||||
|
merchantCol?: string;
|
||||||
|
categoryCol?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankPreset {
|
||||||
|
bankName: string;
|
||||||
|
mapping: ColumnMapping;
|
||||||
|
dateFormat: DateFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedTransaction {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
merchant_name?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCSVRows(text: string): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
const next = text[i + 1];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (c === '"' && next === '"') { field += '"'; i++; }
|
||||||
|
else if (c === '"') { inQuotes = false; }
|
||||||
|
else { field += c; }
|
||||||
|
} else {
|
||||||
|
if (c === '"') { inQuotes = true; }
|
||||||
|
else if (c === ',') { row.push(field.trim()); field = ""; }
|
||||||
|
else if (c === '\r' && next === '\n') {
|
||||||
|
row.push(field.trim());
|
||||||
|
if (row.some((f) => f !== "")) rows.push(row);
|
||||||
|
row = []; field = ""; i++;
|
||||||
|
} else if (c === '\n' || c === '\r') {
|
||||||
|
row.push(field.trim());
|
||||||
|
if (row.some((f) => f !== "")) rows.push(row);
|
||||||
|
row = []; field = "";
|
||||||
|
} else { field += c; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field || row.length > 0) {
|
||||||
|
row.push(field.trim());
|
||||||
|
if (row.some((f) => f !== "")) rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDate(raw: string, format: DateFormat): string {
|
||||||
|
const s = raw.trim();
|
||||||
|
try {
|
||||||
|
if (format === "DD/MM/YYYY") {
|
||||||
|
const [d, m, y] = s.split("/");
|
||||||
|
if (!d || !m || !y || y.length !== 4) return "";
|
||||||
|
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
if (format === "YYYY-MM-DD") {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : "";
|
||||||
|
}
|
||||||
|
if (format === "MM/DD/YYYY" || format === "M/D/YYYY") {
|
||||||
|
const [m, d, y] = s.split("/");
|
||||||
|
if (!d || !m || !y || y.length !== 4) return "";
|
||||||
|
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
} catch { return ""; }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectHasHeaders(rows: string[][], dateFormat: DateFormat): boolean {
|
||||||
|
if (rows.length === 0) return false;
|
||||||
|
return parseDate(rows[0][0] ?? "", dateFormat) === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnLabels(rows: string[][], hasHeaders: boolean): string[] {
|
||||||
|
if (hasHeaders && rows.length > 0) {
|
||||||
|
return rows[0].map((h, i) => h || `Column ${i + 1}`);
|
||||||
|
}
|
||||||
|
const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||||
|
return Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataRows(rows: string[][], hasHeaders: boolean): string[][] {
|
||||||
|
return hasHeaders ? rows.slice(1) : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyMapping(
|
||||||
|
dataRows: string[][],
|
||||||
|
columnLabels: string[],
|
||||||
|
mapping: ColumnMapping,
|
||||||
|
dateFormat: DateFormat
|
||||||
|
): ParsedTransaction[] {
|
||||||
|
const idx = (name: string) => columnLabels.indexOf(name);
|
||||||
|
const dateIdx = idx(mapping.dateCol);
|
||||||
|
const descIdx = idx(mapping.descriptionCol);
|
||||||
|
const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
|
||||||
|
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
||||||
|
|
||||||
|
const results: ParsedTransaction[] = [];
|
||||||
|
for (const row of dataRows) {
|
||||||
|
const date = parseDate(row[dateIdx] ?? "", dateFormat);
|
||||||
|
const description = (row[descIdx] ?? "").trim();
|
||||||
|
if (!date || !description) continue;
|
||||||
|
|
||||||
|
let amount = 0;
|
||||||
|
let transaction_type = "debit";
|
||||||
|
|
||||||
|
if (mapping.amountMode === "single" && mapping.amountCol) {
|
||||||
|
const raw = (row[idx(mapping.amountCol)] ?? "").replace(/[^\d.\-+]/g, "");
|
||||||
|
const val = parseFloat(raw);
|
||||||
|
if (isNaN(val) || val === 0) continue;
|
||||||
|
amount = Math.abs(val);
|
||||||
|
transaction_type = val < 0 ? "debit" : "credit";
|
||||||
|
} else if (mapping.amountMode === "debit_credit") {
|
||||||
|
const debitIdx = mapping.debitCol ? idx(mapping.debitCol) : -1;
|
||||||
|
const creditIdx = mapping.creditCol ? idx(mapping.creditCol) : -1;
|
||||||
|
const dVal = parseFloat((row[debitIdx] ?? "").replace(/[^\d.]/g, ""));
|
||||||
|
const cVal = parseFloat((row[creditIdx] ?? "").replace(/[^\d.]/g, ""));
|
||||||
|
if (!isNaN(dVal) && dVal > 0) { amount = dVal; transaction_type = "debit"; }
|
||||||
|
else if (!isNaN(cVal) && cVal > 0) { amount = cVal; transaction_type = "credit"; }
|
||||||
|
else continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount <= 0) continue;
|
||||||
|
const tx: ParsedTransaction = { date, description, amount, transaction_type };
|
||||||
|
if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
|
||||||
|
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
||||||
|
results.push(tx);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBankPreset(preset: BankPreset): void {
|
||||||
|
try {
|
||||||
|
const existing = loadBankPresets().filter((p) => p.bankName !== preset.bankName);
|
||||||
|
localStorage.setItem("csv-presets", JSON.stringify([...existing, preset]));
|
||||||
|
} catch { /* localStorage unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBankPresets(): BankPreset[] {
|
||||||
|
try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
+392
-15
@@ -14,21 +14,30 @@ interface TransactionsResponse {
|
|||||||
interface TransactionFilters {
|
interface TransactionFilters {
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
category?: string;
|
categories?: string[];
|
||||||
bank_name?: string;
|
bank_names?: string[];
|
||||||
|
tag_ids?: string[];
|
||||||
|
transaction_types?: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
statement_id?: string;
|
statement_id?: string;
|
||||||
tag_id?: string;
|
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_dir?: string;
|
sort_dir?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
amount_min?: number;
|
||||||
|
amount_max?: number;
|
||||||
|
has_split?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildParams(filters: TransactionFilters): string {
|
function buildParams(filters: TransactionFilters): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
Object.entries(filters).forEach(([key, val]) => {
|
Object.entries(filters).forEach(([key, val]) => {
|
||||||
if (val !== undefined && val !== "") params.set(key, String(val));
|
if (val === undefined || val === "") return;
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
if (val.length > 0) params.set(key, val.join(","));
|
||||||
|
} else {
|
||||||
|
params.set(key, String(val));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}
|
}
|
||||||
@@ -83,6 +92,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() {
|
export function useUpdateTransaction() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -94,6 +132,11 @@ export function useUpdateTransaction() {
|
|||||||
category?: string;
|
category?: string;
|
||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
notes?: 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}`, {
|
const res = await fetch(`/api/transactions/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -105,6 +148,7 @@ export function useUpdateTransaction() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -155,21 +199,23 @@ export function useParticipants() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useParticipantBalances() {
|
export function useParticipantBalances(tagIds?: string[]) {
|
||||||
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
|
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
|
||||||
queryKey: ["participant-balances"],
|
queryKey: ["participant-balances", tagIds],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch("/api/participants/balances");
|
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
|
||||||
|
const res = await fetch(`/api/participants/balances${params}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSharedTransactions() {
|
export function useSharedTransactions(tagIds?: string[]) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["shared-transactions"],
|
queryKey: ["shared-transactions", tagIds],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch("/api/shared-transactions");
|
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
|
||||||
|
const res = await fetch(`/api/shared-transactions${params}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -214,11 +260,43 @@ export function useSetSplits() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSettleSplits() {
|
export interface SplitPayment {
|
||||||
|
id: number;
|
||||||
|
from_participant_id: number;
|
||||||
|
from_name: string;
|
||||||
|
to_participant_id: number;
|
||||||
|
to_name: string;
|
||||||
|
amount: number;
|
||||||
|
payment_date: string;
|
||||||
|
notes: string | null;
|
||||||
|
linked_transaction_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaymentHistory(participantId: number | null) {
|
||||||
|
return useQuery<SplitPayment[]>({
|
||||||
|
queryKey: ["split-payments", participantId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!participantId) return [];
|
||||||
|
const res = await fetch(`/api/split-payments?participant_id=${participantId}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!participantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecordPayment() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: { participant_id?: number; split_ids?: number[] }) => {
|
mutationFn: async (body: {
|
||||||
const res = await fetch("/api/splits/settle", {
|
from_participant_id: number;
|
||||||
|
to_participant_id: number;
|
||||||
|
amount: number;
|
||||||
|
payment_date: string;
|
||||||
|
notes?: string;
|
||||||
|
linked_transaction_id?: number;
|
||||||
|
}) => {
|
||||||
|
const res = await fetch("/api/split-payments", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -226,8 +304,22 @@ export function useSettleSplits() {
|
|||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["split-payments"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePayment() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const res = await fetch(`/api/split-payments?id=${id}`, { method: "DELETE" });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["split-payments"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -353,6 +445,125 @@ export function useCreateParticipant() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Rules ---
|
||||||
|
|
||||||
|
export interface RuleRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
conditions: { field: string; operator: string; value: string }[];
|
||||||
|
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: { participant_id: number; share_percent: number }[] };
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRules() {
|
||||||
|
return useQuery<RuleRow[]>({
|
||||||
|
queryKey: ["rules"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/rules");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: Omit<RuleRow, "id" | "created_at">) => {
|
||||||
|
const res = await fetch("/api/rules", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create rule");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...data }: Partial<RuleRow> & { id: number }) => {
|
||||||
|
const res = await fetch(`/api/rules/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update rule");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await fetch(`/api/rules/${id}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApplyRules() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (args?: { splitFrom?: string; ruleId?: number }) => {
|
||||||
|
const res = await fetch("/api/rules/apply", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ splitFrom: args?.splitFrom || null, ruleId: args?.ruleId || null }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to apply rules");
|
||||||
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Budgets & Analytics ---
|
// --- Budgets & Analytics ---
|
||||||
|
|
||||||
export interface BudgetRow {
|
export interface BudgetRow {
|
||||||
@@ -372,7 +583,9 @@ export interface MonthlyAnalyticsRow {
|
|||||||
export interface MonthlyAnalytics {
|
export interface MonthlyAnalytics {
|
||||||
months: string[];
|
months: string[];
|
||||||
rows: MonthlyAnalyticsRow[];
|
rows: MonthlyAnalyticsRow[];
|
||||||
totals: Record<string, { spent: number; budget: number }>;
|
income: Record<string, number>;
|
||||||
|
investments: Record<string, number>;
|
||||||
|
totals: Record<string, { spent: number; income: number; investments: number; net: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBudgets(month: string) {
|
export function useBudgets(month: string) {
|
||||||
@@ -421,3 +634,167 @@ export function useMonthlyAnalytics(months?: number) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionRow {
|
||||||
|
merchant: string;
|
||||||
|
category: string;
|
||||||
|
frequency: string;
|
||||||
|
avg_amount: number;
|
||||||
|
monthly_equiv: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
occurrences: number;
|
||||||
|
total_paid: number;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscriptions() {
|
||||||
|
return useQuery<{ subscriptions: SubscriptionRow[]; total_monthly_equiv: number }>({
|
||||||
|
queryKey: ["analytics", "subscriptions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/analytics/subscriptions");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeeBankRow {
|
||||||
|
bank_name: string;
|
||||||
|
fees: number;
|
||||||
|
interest: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeeTxnRow {
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
merchant_name: string | null;
|
||||||
|
transaction_type: string;
|
||||||
|
my_amount: number;
|
||||||
|
bank_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFees() {
|
||||||
|
return useQuery<{
|
||||||
|
by_bank: FeeBankRow[];
|
||||||
|
transactions: FeeTxnRow[];
|
||||||
|
total_fees: number;
|
||||||
|
total_interest: number;
|
||||||
|
}>({
|
||||||
|
queryKey: ["analytics", "fees"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/analytics/fees");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantRow {
|
||||||
|
merchant: string;
|
||||||
|
category: string;
|
||||||
|
debit_count: number;
|
||||||
|
refund_count: number;
|
||||||
|
gross_spend: number;
|
||||||
|
total_refunds: number;
|
||||||
|
net_spend: number;
|
||||||
|
avg_debit: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
months_active: number;
|
||||||
|
monthly_trend: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMerchants(months = 12) {
|
||||||
|
return useQuery<{ merchants: MerchantRow[]; months: number }>({
|
||||||
|
queryKey: ["analytics", "merchants", months],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/analytics/merchants?months=${months}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantTxnRow {
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
amount_aud: number | null;
|
||||||
|
my_amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
category: string;
|
||||||
|
bank_name: string;
|
||||||
|
statement_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CSV Import & Reconcile ---
|
||||||
|
|
||||||
|
export function useImportCSV() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: {
|
||||||
|
bank_name: string;
|
||||||
|
transactions: {
|
||||||
|
date: string; description: string; amount: number; transaction_type: string;
|
||||||
|
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string;
|
||||||
|
}[];
|
||||||
|
}) => {
|
||||||
|
const res = await fetch("/api/import/csv", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error || "Import failed");
|
||||||
|
return res.json() as Promise<{ inserted: number }>;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { ManualTxWithMatches } from "./queries";
|
||||||
|
|
||||||
|
export function usePendingReconciliations() {
|
||||||
|
return useQuery<ManualTxWithMatches[]>({
|
||||||
|
queryKey: ["reconcile-pending"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/reconcile/pending");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReconcile() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (matches: { manual_id: number; statement_tx_id: number }[]) => {
|
||||||
|
const res = await fetch("/api/transactions/reconcile", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ matches }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error || "Reconcile failed");
|
||||||
|
return res.json() as Promise<{ reconciled: number }>;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMerchantTransactions(merchant: string | null) {
|
||||||
|
return useQuery<{ transactions: MerchantTxnRow[] }>({
|
||||||
|
queryKey: ["analytics", "merchant-txns", merchant],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/analytics/merchants/${encodeURIComponent(merchant!)}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!merchant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+349
-50
@@ -8,10 +8,11 @@ export interface TagRow {
|
|||||||
|
|
||||||
export interface TransactionRow {
|
export interface TransactionRow {
|
||||||
id: number;
|
id: number;
|
||||||
statement_id: number;
|
statement_id: number | null;
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
amount_aud: number | null;
|
||||||
transaction_type: string;
|
transaction_type: string;
|
||||||
merchant_name: string | null;
|
merchant_name: string | null;
|
||||||
merchant_normalized: string | null;
|
merchant_normalized: string | null;
|
||||||
@@ -25,14 +26,17 @@ export interface TransactionRow {
|
|||||||
category_override: string | null;
|
category_override: string | null;
|
||||||
merchant_override: string | null;
|
merchant_override: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
my_share_percent: number | null;
|
||||||
effective_category: string;
|
effective_category: string;
|
||||||
effective_merchant: string;
|
effective_merchant: string;
|
||||||
// statement context
|
// statement context (null for manual transactions)
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
owner_id: number;
|
owner_id: number;
|
||||||
owner_name: string;
|
owner_name: string;
|
||||||
// tags
|
// tags
|
||||||
tags: TagRow[];
|
tags: TagRow[];
|
||||||
|
// splits
|
||||||
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatementRow {
|
export interface StatementRow {
|
||||||
@@ -55,6 +59,7 @@ export interface StatementRow {
|
|||||||
fees_charged: number | null;
|
fees_charged: number | null;
|
||||||
credit_limit: number | null;
|
credit_limit: number | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
statement_type: string | null;
|
||||||
tier_used: string | null;
|
tier_used: string | null;
|
||||||
owner_id: number;
|
owner_id: number;
|
||||||
owner_name: string;
|
owner_name: string;
|
||||||
@@ -65,19 +70,26 @@ export interface StatementRow {
|
|||||||
interface TransactionFilters {
|
interface TransactionFilters {
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
category?: string;
|
categories?: string[];
|
||||||
bank_name?: string;
|
bank_names?: string[];
|
||||||
|
tag_ids?: string[];
|
||||||
|
transaction_types?: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
statement_id?: string;
|
statement_id?: string;
|
||||||
tag_id?: string;
|
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_dir?: string;
|
sort_dir?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
amount_min?: number;
|
||||||
|
amount_max?: number;
|
||||||
|
has_split?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTransactions(ownerId: number, filters: 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))`,
|
||||||
|
`NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`,
|
||||||
|
];
|
||||||
const params: unknown[] = [ownerId];
|
const params: unknown[] = [ownerId];
|
||||||
let paramIdx = 2;
|
let paramIdx = 2;
|
||||||
|
|
||||||
@@ -89,16 +101,39 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
conditions.push(`t.transaction_date <= $${paramIdx++}`);
|
conditions.push(`t.transaction_date <= $${paramIdx++}`);
|
||||||
params.push(filters.to);
|
params.push(filters.to);
|
||||||
}
|
}
|
||||||
if (filters.category) {
|
if (filters.categories?.length) {
|
||||||
conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`);
|
conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`);
|
||||||
params.push(filters.category);
|
params.push(filters.categories);
|
||||||
}
|
}
|
||||||
if (filters.bank_name) {
|
if (filters.bank_names?.length) {
|
||||||
conditions.push(`s.bank_name = $${paramIdx++}`);
|
const hasManual = filters.bank_names.includes("Manual");
|
||||||
params.push(filters.bank_name);
|
const bankList = filters.bank_names.filter((b) => b !== "Manual");
|
||||||
|
if (hasManual && bankList.length > 0) {
|
||||||
|
conditions.push(`(t.statement_id IS NULL OR s.bank_name = ANY($${paramIdx++}::text[]))`);
|
||||||
|
params.push(bankList);
|
||||||
|
} else if (hasManual) {
|
||||||
|
conditions.push(`t.statement_id IS NULL`);
|
||||||
|
} else {
|
||||||
|
conditions.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
|
||||||
|
params.push(bankList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.tag_ids?.length) {
|
||||||
|
const noTags = filters.tag_ids.includes("untagged");
|
||||||
|
const realTagIds = filters.tag_ids.filter((id) => id !== "untagged").map(Number);
|
||||||
|
if (noTags) {
|
||||||
|
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id)`);
|
||||||
|
} else if (realTagIds.length > 0) {
|
||||||
|
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`);
|
||||||
|
params.push(realTagIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.transaction_types?.length) {
|
||||||
|
conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`);
|
||||||
|
params.push(filters.transaction_types);
|
||||||
}
|
}
|
||||||
if (filters.search) {
|
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}%`);
|
params.push(`%${filters.search}%`);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
@@ -106,14 +141,23 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
conditions.push(`t.statement_id = $${paramIdx++}`);
|
conditions.push(`t.statement_id = $${paramIdx++}`);
|
||||||
params.push(Number(filters.statement_id));
|
params.push(Number(filters.statement_id));
|
||||||
}
|
}
|
||||||
if (filters.tag_id) {
|
if (filters.amount_min !== undefined) {
|
||||||
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`);
|
conditions.push(`t.amount >= $${paramIdx++}`);
|
||||||
params.push(Number(filters.tag_id));
|
params.push(filters.amount_min);
|
||||||
|
}
|
||||||
|
if (filters.amount_max !== undefined) {
|
||||||
|
conditions.push(`t.amount <= $${paramIdx++}`);
|
||||||
|
params.push(filters.amount_max);
|
||||||
|
}
|
||||||
|
if (filters.has_split === "yes") {
|
||||||
|
conditions.push(`EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||||
|
} else if (filters.has_split === "no") {
|
||||||
|
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = `WHERE ${conditions.join(" AND ")}`;
|
const where = `WHERE ${conditions.join(" AND ")}`;
|
||||||
|
|
||||||
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
|
const sortCol = filters.sort_by === "amount" ? "t.amount" : filters.sort_by === "created_at" ? "t.created_at" : "t.transaction_date";
|
||||||
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
||||||
const limit = filters.limit || 50;
|
const limit = filters.limit || 50;
|
||||||
const offset = filters.offset || 0;
|
const offset = filters.offset || 0;
|
||||||
@@ -122,7 +166,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
SELECT COUNT(*)::int as total
|
SELECT COUNT(*)::int as total
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
${where}
|
${where}
|
||||||
`;
|
`;
|
||||||
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
||||||
@@ -130,32 +174,43 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
|
|
||||||
const dataSql = `
|
const dataSql = `
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.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,
|
p.name as owner_name,
|
||||||
txn_tags.tags
|
COALESCE(src.created_at, t.created_at) as created_at,
|
||||||
|
txn_tags.tags,
|
||||||
|
txn_splits.splits
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p ON p.id = s.owner_id
|
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
|
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||||
LEFT JOIN LATERAL (
|
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
|
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
|
FROM transaction_tags tt
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
WHERE tt.transaction_id = t.id
|
WHERE tt.transaction_id = t.id
|
||||||
) txn_tags ON true
|
) 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}
|
${where}
|
||||||
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
||||||
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
||||||
`;
|
`;
|
||||||
params.push(limit, offset);
|
params.push(limit, offset);
|
||||||
|
|
||||||
const 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) => ({
|
const data = raw.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
||||||
|
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
||||||
})) as TransactionRow[];
|
})) as TransactionRow[];
|
||||||
|
|
||||||
return { data, total, limit, offset };
|
return { data, total, limit, offset };
|
||||||
@@ -164,15 +219,16 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
export async function getTransactionById(id: number) {
|
export async function getTransactionById(id: number) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.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
|
p.name as owner_name
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p ON p.id = s.owner_id
|
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
WHERE t.id = $1
|
WHERE t.id = $1
|
||||||
`;
|
`;
|
||||||
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
||||||
@@ -218,8 +274,13 @@ export async function getMerchantSuggestions(search: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBankNames() {
|
export async function getBankNames() {
|
||||||
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
|
const [bankRows, manualCount] = await Promise.all([
|
||||||
return queryRaw<{ bank_name: string }>(sql);
|
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 {
|
export interface ParticipantBalance {
|
||||||
@@ -229,25 +290,247 @@ export interface ParticipantBalance {
|
|||||||
unsettled_count: number;
|
unsettled_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getParticipantBalances(ownerId: number) {
|
export async function getParticipantBalances(ownerId: number, tagIds?: number[]) {
|
||||||
|
const params: unknown[] = [ownerId];
|
||||||
|
let tagFilter = "";
|
||||||
|
if (tagIds?.length) {
|
||||||
|
params.push(tagIds);
|
||||||
|
tagFilter = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payments settle the total relationship between two people, not a specific tag.
|
||||||
|
// Only subtract payments when viewing the unfiltered total; with a tag filter
|
||||||
|
// active, show the raw split amount for that tag context only.
|
||||||
|
const paymentsJoin = tagIds?.length ? "" : `
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid,
|
||||||
|
SUM(CASE WHEN sp.to_participant_id = $1 THEN sp.amount ELSE -sp.amount END) AS net_paid
|
||||||
|
FROM split_payments sp
|
||||||
|
WHERE sp.from_participant_id = $1 OR sp.to_participant_id = $1
|
||||||
|
GROUP BY pid
|
||||||
|
) payments ON payments.pid = p.id`;
|
||||||
|
const paymentsSelect = tagIds?.length ? "" : "- COALESCE(payments.net_paid, 0)::numeric(12,2)";
|
||||||
|
const paymentsGroup = tagIds?.length ? "" : ", payments.net_paid";
|
||||||
|
|
||||||
return queryRaw<ParticipantBalance>(`
|
return queryRaw<ParticipantBalance>(`
|
||||||
SELECT p.id, p.name,
|
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,
|
COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2)
|
||||||
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
|
${paymentsSelect} AS total_owed,
|
||||||
|
COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count
|
||||||
FROM participants p
|
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 (
|
||||||
LEFT JOIN statements s ON s.id = t.statement_id
|
-- They owe me: their splits on transactions I own
|
||||||
WHERE (s.owner_id = $1 OR s.id IS NULL)
|
SELECT ts.participant_id AS pid,
|
||||||
GROUP BY p.id, p.name
|
(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100 AS signed_amount,
|
||||||
|
1 AS split_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
|
||||||
|
${tagFilter}
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- I owe them: my splits on transactions they own
|
||||||
|
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
|
||||||
|
-((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100) AS signed_amount,
|
||||||
|
0 AS split_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
|
||||||
|
${tagFilter}
|
||||||
|
) splits ON splits.pid = p.id
|
||||||
|
${paymentsJoin}
|
||||||
|
|
||||||
|
WHERE p.id != $1
|
||||||
|
GROUP BY p.id, p.name ${paymentsGroup}
|
||||||
ORDER BY p.name
|
ORDER BY p.name
|
||||||
`, [ownerId]);
|
`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharedTransactionRow extends TransactionRow {
|
export interface SharedTransactionRow extends TransactionRow {
|
||||||
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ensureTag(name: string, color: string): Promise<number> {
|
||||||
|
const rows = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO tags (name, color) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id`,
|
||||||
|
[name, color]
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchInsertCSVTransactions(
|
||||||
|
ownerId: number,
|
||||||
|
rows: {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
merchant_name?: string;
|
||||||
|
foreign_currency_amount?: number;
|
||||||
|
foreign_currency_code?: string;
|
||||||
|
category?: string;
|
||||||
|
}[],
|
||||||
|
tagId: number
|
||||||
|
): Promise<number> {
|
||||||
|
if (rows.length === 0) return 0;
|
||||||
|
|
||||||
|
const baseRows = await queryRaw<{ base: number }>(
|
||||||
|
`SELECT COALESCE(MAX(row_index), -1) as base FROM transactions WHERE owner_id = $1 AND statement_id IS NULL`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
const base = Number(baseRows[0].base);
|
||||||
|
|
||||||
|
const valueClauses: string[] = [];
|
||||||
|
const params: unknown[] = [ownerId];
|
||||||
|
let p = 2;
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
valueClauses.push(`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`);
|
||||||
|
params.push(r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null, r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const txIds = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, row_index)
|
||||||
|
VALUES ${valueClauses.join(", ")}
|
||||||
|
RETURNING id`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (txIds.length > 0) {
|
||||||
|
const tagValueClauses = txIds.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`);
|
||||||
|
const tagParams: unknown[] = txIds.flatMap((r) => [r.id, tagId]);
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${tagValueClauses.join(", ")} ON CONFLICT DO NOTHING`,
|
||||||
|
tagParams
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return txIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PotentialMatch {
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
effective_merchant: string;
|
||||||
|
effective_category: string;
|
||||||
|
bank_name: string;
|
||||||
|
billing_end_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualTxWithMatches extends TransactionRow {
|
||||||
|
matches: PotentialMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingReconciliations(ownerId: number): Promise<ManualTxWithMatches[]> {
|
||||||
|
// Fetch all unreconciled manual transactions
|
||||||
|
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(
|
||||||
|
`SELECT t.*,
|
||||||
|
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,
|
||||||
|
'Manual' as bank_name,
|
||||||
|
t.owner_id,
|
||||||
|
p.name as owner_name,
|
||||||
|
txn_tags.tags,
|
||||||
|
txn_splits.splits
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
LEFT JOIN participants p ON p.id = t.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 t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL
|
||||||
|
ORDER BY t.transaction_date DESC, t.row_index ASC`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const manualTxs = 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[];
|
||||||
|
|
||||||
|
if (manualTxs.length === 0) return [];
|
||||||
|
|
||||||
|
// Fetch all potential matches in one query using window function
|
||||||
|
const matchRows = await queryRaw<PotentialMatch & { manual_id: number; rn: number }>(
|
||||||
|
`SELECT manual_id, id, transaction_date, description, amount, transaction_type,
|
||||||
|
effective_merchant, effective_category, bank_name, billing_end_date
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
m.id AS manual_id,
|
||||||
|
t.id,
|
||||||
|
t.transaction_date,
|
||||||
|
t.description,
|
||||||
|
t.amount,
|
||||||
|
t.transaction_type,
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, '') AS effective_merchant,
|
||||||
|
COALESCE(o.category_override, t.category, '') AS effective_category,
|
||||||
|
s.bank_name,
|
||||||
|
s.billing_end_date,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY m.id
|
||||||
|
ORDER BY ABS(t.amount - m.amount), ABS(t.transaction_date - m.transaction_date)
|
||||||
|
) AS rn
|
||||||
|
FROM transactions m
|
||||||
|
JOIN transactions t ON t.statement_id IS NOT NULL
|
||||||
|
AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3
|
||||||
|
AND t.amount BETWEEN m.amount * 0.99 AND m.amount * 1.01
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
WHERE m.statement_id IS NULL
|
||||||
|
AND m.owner_id = $1
|
||||||
|
AND m.reconciled_with_id IS NULL
|
||||||
|
AND COALESCE(t.owner_id, s.owner_id) = $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id
|
||||||
|
)
|
||||||
|
) sq
|
||||||
|
WHERE rn <= 5
|
||||||
|
ORDER BY manual_id, rn`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group matches by manual_id
|
||||||
|
const matchesByManualId = new Map<number, PotentialMatch[]>();
|
||||||
|
for (const row of matchRows) {
|
||||||
|
const list = matchesByManualId.get(row.manual_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
id: row.id,
|
||||||
|
transaction_date: row.transaction_date,
|
||||||
|
description: row.description,
|
||||||
|
amount: row.amount,
|
||||||
|
transaction_type: row.transaction_type,
|
||||||
|
effective_merchant: row.effective_merchant,
|
||||||
|
effective_category: row.effective_category,
|
||||||
|
bank_name: row.bank_name,
|
||||||
|
billing_end_date: row.billing_end_date,
|
||||||
|
});
|
||||||
|
matchesByManualId.set(row.manual_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manualTxs.map((tx) => ({
|
||||||
|
...tx,
|
||||||
|
matches: matchesByManualId.get(tx.id) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTags() {
|
export async function getTags() {
|
||||||
return queryRaw<TagRow & { transaction_count: number }>(`
|
return queryRaw<TagRow & { transaction_count: number }>(`
|
||||||
SELECT tg.id, tg.name, tg.color,
|
SELECT tg.id, tg.name, tg.color,
|
||||||
@@ -259,14 +542,25 @@ export async function getTags() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSharedTransactions(ownerId: number) {
|
export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean) {
|
||||||
|
const params: unknown[] = [ownerId];
|
||||||
|
let tagClause = "";
|
||||||
|
if (noTags) {
|
||||||
|
tagClause = `AND NOT EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id)`;
|
||||||
|
} else if (tagIds?.length) {
|
||||||
|
params.push(tagIds);
|
||||||
|
tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
|
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.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,
|
p_owner.name as owner_name,
|
||||||
|
COALESCE(src.created_at, t.created_at) as created_at,
|
||||||
json_agg(json_build_object(
|
json_agg(json_build_object(
|
||||||
'split_id', ts.id,
|
'split_id', ts.id,
|
||||||
'participant_id', ts.participant_id,
|
'participant_id', ts.participant_id,
|
||||||
@@ -278,17 +572,22 @@ export async function getSharedTransactions(ownerId: number) {
|
|||||||
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
||||||
JOIN participants p ON p.id = ts.participant_id
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
|
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
WHERE s.owner_id = $1
|
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||||
AND EXISTS (
|
WHERE (
|
||||||
SELECT 1 FROM transaction_splits ts2
|
(
|
||||||
JOIN participants p2 ON p2.id = ts2.participant_id
|
COALESCE(t.owner_id, s.owner_id) = $1
|
||||||
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
|
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
|
||||||
|
) OR (
|
||||||
|
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
|
${tagClause}
|
||||||
|
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at
|
||||||
ORDER BY t.transaction_date DESC
|
ORDER BY t.transaction_date DESC
|
||||||
`, [ownerId]);
|
`, params);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export interface Condition {
|
||||||
|
field:
|
||||||
|
| "merchant_normalized"
|
||||||
|
| "description"
|
||||||
|
| "category"
|
||||||
|
| "bank_name"
|
||||||
|
| "amount"
|
||||||
|
| "transaction_type"
|
||||||
|
| "tag";
|
||||||
|
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitEntry {
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Actions {
|
||||||
|
set_category?: string;
|
||||||
|
add_tag_ids?: number[];
|
||||||
|
set_merchant?: string;
|
||||||
|
apply_split?: SplitEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TxFields {
|
||||||
|
effective_category: string;
|
||||||
|
effective_merchant: string;
|
||||||
|
description: string;
|
||||||
|
bank_name: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
tags: { id: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||||
|
if (cond.field === "amount") {
|
||||||
|
const numVal = Number(tx.amount);
|
||||||
|
const numCond = Number(cond.value);
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals": return numVal === numCond;
|
||||||
|
case "not_equals": return numVal !== numCond;
|
||||||
|
case "gt": return numVal > numCond;
|
||||||
|
case "lt": return numVal < numCond;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cond.field === "tag") {
|
||||||
|
const tagId = Number(cond.value);
|
||||||
|
const hasTag = tx.tags.some((t) => t.id === tagId);
|
||||||
|
return cond.operator === "not_equals" ? !hasTag : hasTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldVal: string;
|
||||||
|
switch (cond.field) {
|
||||||
|
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
||||||
|
case "description": fieldVal = tx.description || ""; break;
|
||||||
|
case "category": fieldVal = tx.effective_category || ""; break;
|
||||||
|
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||||
|
case "transaction_type": fieldVal = tx.transaction_type || ""; break;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strVal = fieldVal.toLowerCase();
|
||||||
|
const strCond = cond.value.toLowerCase();
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "contains": return strVal.includes(strCond);
|
||||||
|
case "equals": return strVal === strCond;
|
||||||
|
case "starts_with": return strVal.startsWith(strCond);
|
||||||
|
case "not_equals": return strVal !== strCond;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/__tests__/unit/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
// Load .env.test into process.env HERE, at config-load time.
|
||||||
|
// Forked workers inherit the parent process env, so Prisma's singleton
|
||||||
|
// will see the test DATABASE_URL when db.ts is first imported.
|
||||||
|
const envTestPath = resolve(__dirname, ".env.test");
|
||||||
|
if (existsSync(envTestPath)) {
|
||||||
|
for (const line of readFileSync(envTestPath, "utf-8").split("\n")) {
|
||||||
|
const m = line.match(/^([^#=]+)=(.*)/);
|
||||||
|
if (m) process.env[m[1].trim()] = m[2].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/__tests__/integration/**/*.test.ts"],
|
||||||
|
pool: "forks",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user