Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0985c38be8 | |||
| af4c64bba7 | |||
| a8743ba7df | |||
| 7b3fd4b65f | |||
| dd11019fdf | |||
| 714c5a9b25 | |||
| 2a10450c3e | |||
| e72d3ad9e5 | |||
| a7461ff83b | |||
| c1d031511a | |||
| 7379437cc3 | |||
| 8bd7d77a8a | |||
| f90ba332bd | |||
| e3aa17acdd | |||
| 1eff0f9337 | |||
| 3cf67f6e2a | |||
| 90d8db4abe | |||
| d1a0eedf03 | |||
| 5dbeb0cb87 | |||
| 30a7857d13 | |||
| 1e79ada6d8 | |||
| be85822cc7 | |||
| d455738732 |
@@ -1,36 +1,305 @@
|
||||
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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
- **Frontend**: Next.js 16 App Router, TypeScript, Tailwind CSS, Recharts
|
||||
- **Backend**: Next.js API routes, raw PostgreSQL via `pg` + `@prisma/adapter-pg`
|
||||
- **Database**: PostgreSQL (`postgres-personal` container)
|
||||
- **Auth**: `X-Forwarded-User` header (email) set by Traefik forward-auth → mapped to `participants.email`
|
||||
- **Ingestion**: N8N workflow → Gemini 2.5 Flash (PDF parsing) → PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## 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` | Parent statement |
|
||||
| `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`
|
||||
**Condition operators**: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`
|
||||
|
||||
---
|
||||
|
||||
### `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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
| Migration | What it adds |
|
||||
|-----------|-------------|
|
||||
| `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).
|
||||
|
||||
## 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.
|
||||
## Deployment
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
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
+395
-2
@@ -15,7 +15,8 @@
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -1490,6 +1491,42 @@
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1503,6 +1540,12 @@
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1820,6 +1863,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1882,6 +1988,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||
@@ -2987,6 +3099,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3056,6 +3177,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -3135,6 +3377,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3482,6 +3730,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -3935,6 +4193,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
@@ -4509,6 +4773,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4551,6 +4825,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -6386,9 +6669,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -6402,6 +6707,51 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6461,6 +6811,12 @@
|
||||
"url": "https://github.com/sponsors/remeda"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -7104,6 +7460,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
@@ -7450,6 +7812,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||
@@ -7464,6 +7835,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -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,11 @@
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
category TEXT NOT NULL,
|
||||
month DATE NOT NULL,
|
||||
amount_limit NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(owner_id, category, month)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_owner_month ON budgets(owner_id, month);
|
||||
@@ -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,74 @@
|
||||
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
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END::numeric(12,2) AS my_amount,
|
||||
s.bank_name
|
||||
FROM transactions t
|
||||
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 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
ELSE
|
||||
(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
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,122 @@
|
||||
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 SPEND_EXPR = `
|
||||
CASE
|
||||
WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||
-(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
ELSE
|
||||
(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
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
|
||||
CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END
|
||||
ELSE 0 END
|
||||
), 0)::numeric(12,2) as gross_spend,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||
CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END
|
||||
ELSE 0 END
|
||||
), 0)::numeric(12,2) as total_refunds,
|
||||
SUM(${SPEND_EXPR})::numeric(12,2) as net_spend,
|
||||
AVG(
|
||||
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
||||
CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 { searchParams } = new URL(req.url);
|
||||
const monthCount = Math.min(Math.max(Number(searchParams.get("months") || "6"), 1), 24);
|
||||
|
||||
const now = new Date();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
|
||||
|
||||
const startStr = startDate.toISOString().slice(0, 10);
|
||||
const endStr = endDate.toISOString().slice(0, 10);
|
||||
|
||||
// Expenses: debits excluding transfers and investments, split-adjusted
|
||||
const spendRows = await queryRaw<{
|
||||
month: string;
|
||||
category: string;
|
||||
total_spent: number;
|
||||
transaction_count: number;
|
||||
}>(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||
COALESCE(o.category_override, t.category) as category,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END
|
||||
)::numeric(12,2) as total_spent,
|
||||
COUNT(*)::int as transaction_count
|
||||
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', 'interest')
|
||||
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||
AND t.transaction_date >= $2
|
||||
AND t.transaction_date < $3
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1 DESC, total_spent DESC`,
|
||||
[user.id, startStr, endStr]
|
||||
);
|
||||
|
||||
// Income: credits/payments categorised as income
|
||||
const incomeRows = await queryRaw<{
|
||||
month: string;
|
||||
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]
|
||||
);
|
||||
|
||||
// Build month list (most recent first)
|
||||
const months: string[] = [];
|
||||
for (let i = monthCount - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
months.reverse();
|
||||
|
||||
const spendMap = new Map<string, number>();
|
||||
const countMap = new Map<string, number>();
|
||||
const incomeMap = new Map<string, number>();
|
||||
const investMap = new Map<string, number>();
|
||||
|
||||
for (const r of spendRows) {
|
||||
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
||||
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
||||
}
|
||||
for (const r of incomeRows) incomeMap.set(r.month, Number(r.total_income));
|
||||
for (const r of investmentRows) investMap.set(r.month, Number(r.total_invested));
|
||||
|
||||
const allCategories = new Set<string>();
|
||||
for (const r of spendRows) allCategories.add(r.category);
|
||||
|
||||
const rows = Array.from(allCategories)
|
||||
.sort()
|
||||
.map((cat) => {
|
||||
const spent: Record<string, number> = {};
|
||||
const txCount: Record<string, number> = {};
|
||||
for (const m of months) {
|
||||
const s = spendMap.get(`${cat}:${m}`);
|
||||
const c = countMap.get(`${cat}:${m}`);
|
||||
if (s !== undefined) spent[m] = s;
|
||||
if (c !== undefined) txCount[m] = c;
|
||||
}
|
||||
return { category: cat, spent, txCount };
|
||||
});
|
||||
|
||||
const totals: Record<string, { spent: number; income: number; investments: number; net: number }> = {};
|
||||
for (const m of months) {
|
||||
let spent = 0;
|
||||
for (const row of rows) spent += row.spent[m] || 0;
|
||||
const income = incomeMap.get(m) || 0;
|
||||
const investments = investMap.get(m) || 0;
|
||||
totals[m] = {
|
||||
spent: Math.round(spent * 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, income: Object.fromEntries(incomeMap), investments: Object.fromEntries(investMap), totals });
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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
|
||||
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,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
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 budgets WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(id), user.id]
|
||||
);
|
||||
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
await queryRaw(`DELETE FROM budgets WHERE id = $1`, [Number(id)]);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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 { searchParams } = new URL(req.url);
|
||||
const month = searchParams.get("month");
|
||||
|
||||
let monthDate: string;
|
||||
if (month) {
|
||||
monthDate = month.length === 7 ? `${month}-01` : month;
|
||||
} else {
|
||||
const now = new Date();
|
||||
monthDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
}
|
||||
|
||||
const rows = await queryRaw<{ id: number; category: string; month: string; amount_limit: number }>(
|
||||
`SELECT id, category, month::text, amount_limit::numeric FROM budgets WHERE owner_id = $1 AND month = $2::date`,
|
||||
[user.id, monthDate]
|
||||
);
|
||||
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 { category, month, amount_limit } = await req.json();
|
||||
if (!category || !month || amount_limit === undefined) {
|
||||
return NextResponse.json({ error: "category, month, and amount_limit required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const monthDate = month.length === 7 ? `${month}-01` : month;
|
||||
|
||||
const rows = await queryRaw<{ id: number; category: string; month: string; amount_limit: number }>(
|
||||
`INSERT INTO budgets (owner_id, category, month, amount_limit)
|
||||
VALUES ($1, $2, $3::date, $4)
|
||||
ON CONFLICT (owner_id, category, month) DO UPDATE SET amount_limit = $4, updated_at = NOW()
|
||||
RETURNING id, category, month::text, amount_limit::numeric`,
|
||||
[user.id, category, monthDate, amount_limit]
|
||||
);
|
||||
return NextResponse.json(rows[0], { 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);
|
||||
}
|
||||
@@ -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,11 @@
|
||||
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 balances = await getParticipantBalances(user.id);
|
||||
return NextResponse.json(balances);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const participants = await prisma.participants.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
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 });
|
||||
}
|
||||
@@ -9,10 +9,16 @@ interface Condition {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SplitEntry {
|
||||
participant_id: number;
|
||||
share_percent: number;
|
||||
}
|
||||
|
||||
interface Actions {
|
||||
set_category?: string;
|
||||
add_tag_ids?: number[];
|
||||
set_merchant?: string;
|
||||
apply_split?: SplitEntry[];
|
||||
}
|
||||
|
||||
interface TxFields {
|
||||
@@ -108,6 +114,18 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.apply_split?.length) {
|
||||
// Delete existing splits then insert new ones
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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 transactions = await getSharedTransactions(user.id);
|
||||
return NextResponse.json(transactions);
|
||||
}
|
||||
@@ -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 { getTransactionById } from "@/lib/queries";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
@@ -20,12 +23,30 @@ export async function PATCH(
|
||||
const transactionId = Number(id);
|
||||
const body = await req.json();
|
||||
|
||||
const { category, merchant_normalized, notes } = body as {
|
||||
const { category, merchant_normalized, notes, transaction_type } = body as {
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
transaction_type?: string;
|
||||
};
|
||||
|
||||
// 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 go through the overrides table
|
||||
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined;
|
||||
if (!hasOverride) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { updated_at: new Date() };
|
||||
if (category !== undefined) data.category_override = category;
|
||||
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+631
-4
@@ -1,8 +1,635 @@
|
||||
export default function BudgetPage() {
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment, useMemo } from "react";
|
||||
import {
|
||||
ComposedChart,
|
||||
LineChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||
|
||||
function currentMonthStr(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
function prevMonth(m: string): string {
|
||||
const [year, month] = m.split("-").map(Number);
|
||||
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 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 "";
|
||||
}
|
||||
|
||||
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>
|
||||
<h2 className="text-xl font-semibold mb-4">Budget</h2>
|
||||
<p className="text-zinc-500">Coming soon - monthly budgets and analytics.</p>
|
||||
<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({ 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 [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||||
|
||||
// Cumulative chart: fetch this month's transactions
|
||||
const smFrom = `${selectedMonth}-01`;
|
||||
const [smYear, smMonth] = selectedMonth.split("-").map(Number);
|
||||
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 months = useMemo(() => analytics ? [...analytics.months].reverse() : [], [analytics]);
|
||||
|
||||
// Category rows for selected month
|
||||
const categoryRows = useMemo(() => {
|
||||
if (!analytics) return [];
|
||||
return analytics.rows
|
||||
.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]);
|
||||
|
||||
// Trend line data — top 8 categories by total 6-month spend
|
||||
const trendData = useMemo(() => {
|
||||
if (!analytics) return { data: [], categories: [] };
|
||||
const categoryTotals = analytics.rows
|
||||
.map((r) => ({ category: r.category, total: months.reduce((s, m) => s + (r.spent[m] || 0), 0) }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 8)
|
||||
.map((r) => r.category);
|
||||
|
||||
const data = months.map((m) => {
|
||||
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]);
|
||||
|
||||
// Pareto chart data
|
||||
const paretoData = useMemo(() => {
|
||||
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]);
|
||||
|
||||
// Cumulative spend chart data
|
||||
const cumulativeData = useMemo(() => {
|
||||
const daysInMonth = new Date(smYear, smMonth, 0).getDate();
|
||||
const isCurrentMonth = selectedMonth === currentMonthStr();
|
||||
const today = new Date();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header + month selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Analytics</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<button onClick={() => setSelectedMonth(nextMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
|
||||
{hasIncome && (
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">Income</p>
|
||||
<p className="text-2xl font-semibold text-emerald-400">{fmtExact(totals.income)}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">received</p>
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
{analytics.months.length > 0 && (
|
||||
<div>
|
||||
<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">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<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">Category</th>
|
||||
{analytics.months.map((m) => (
|
||||
<th
|
||||
key={m}
|
||||
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)}
|
||||
>
|
||||
{formatShortMonth(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analytics.rows.map((row) => (
|
||||
<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">
|
||||
<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>
|
||||
{analytics.months.map((m) => {
|
||||
const spent = row.spent[m];
|
||||
return (
|
||||
<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" : ""}`}>
|
||||
{spent !== undefined ? fmt(spent) : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
|
||||
{analytics.months.map((m) => {
|
||||
const t = analytics.totals[m];
|
||||
return (
|
||||
<td key={m} className={`px-3 py-2 text-right tabular-nums ${m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""}`}>
|
||||
{fmt(t?.spent || 0)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Legend,
|
||||
} from "recharts";
|
||||
import { useMonthlyAnalytics, useSubscriptions, useFees } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
const COMMITTED_CATEGORIES = new Set(["rent", "utilities", "insurance", "subscriptions"]);
|
||||
|
||||
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).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 CommittedTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const committed = payload.find((p: any) => p.dataKey === "committed")?.value ?? 0;
|
||||
const discretionary = payload.find((p: any) => p.dataKey === "discretionary")?.value ?? 0;
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-xs space-y-1">
|
||||
<div className="font-medium text-zinc-300 mb-1">{label}</div>
|
||||
<div className="flex justify-between gap-4"><span className="text-indigo-400">Committed</span><span>{fmt(committed)}</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Discretionary</span><span>{fmt(discretionary)}</span></div>
|
||||
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span>{fmt(committed + discretionary)}</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────
|
||||
export default function InsightsPage() {
|
||||
const { data: analytics } = useMonthlyAnalytics(12);
|
||||
const { data: subData } = useSubscriptions();
|
||||
const { data: feesData } = useFees();
|
||||
|
||||
// Build committed/discretionary chart data
|
||||
const chartData = useMemo(() => {
|
||||
if (!analytics) return [];
|
||||
return [...analytics.months].reverse().map((month) => {
|
||||
let committed = 0;
|
||||
let discretionary = 0;
|
||||
for (const row of analytics.rows) {
|
||||
const spend = Number(row.spent[month] ?? 0);
|
||||
if (COMMITTED_CATEGORIES.has(row.category)) committed += spend;
|
||||
else discretionary += spend;
|
||||
}
|
||||
return {
|
||||
month: month.slice(5) + "/" + month.slice(2, 4),
|
||||
committed: Math.round(committed),
|
||||
discretionary: Math.round(discretionary),
|
||||
total: Math.round(committed + discretionary),
|
||||
};
|
||||
});
|
||||
}, [analytics]);
|
||||
|
||||
const committedValues = chartData.map((d) => d.committed);
|
||||
const committedTrend = trend(committedValues);
|
||||
const avgCommitted = committedValues.length
|
||||
? Math.round(committedValues.reduce((a, b) => a + b, 0) / committedValues.length)
|
||||
: 0;
|
||||
const latestCommitted = committedValues[committedValues.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. Committed vs Discretionary ── */}
|
||||
<Section title="Committed vs Discretionary Spend">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">This month — committed floor</div>
|
||||
<div className="text-xl font-semibold text-indigo-400">{fmt(latestCommitted)}</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">12-month avg committed</div>
|
||||
<div className="text-xl font-semibold text-zinc-200">{fmt(avgCommitted)}</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">Trend (first 3 vs last 3 mo)</div>
|
||||
<div className={`text-xl font-semibold ${committedTrend.dir === "up" ? "text-red-400" : committedTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
|
||||
{committedTrend.dir === "up" ? "↑" : committedTrend.dir === "down" ? "↓" : "→"} {committedTrend.pct}%
|
||||
</div>
|
||||
</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={<CommittedTooltip />} />
|
||||
<Bar dataKey="committed" stackId="a" fill="#6366f1" name="Committed" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="discretionary" stackId="a" fill="#3f3f46" name="Discretionary" radius={[3, 3, 0, 0]} />
|
||||
<Line type="monotone" dataKey="committed" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex gap-4 mt-2 justify-end">
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-indigo-500 inline-block" />Committed (rent, utilities, insurance, subscriptions)</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-zinc-600 inline-block" />Discretionary</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── 2. 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>
|
||||
|
||||
{/* ── 3. 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+142
-3
@@ -1,8 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
useSettleSplits,
|
||||
} from "@/lib/hooks";
|
||||
import type { SharedTransactionRow } from "@/lib/queries";
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return `$${Number(n).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function SharedPage() {
|
||||
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions();
|
||||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances();
|
||||
const settle = useSettleSplits();
|
||||
const [settling, setSettling] = useState<number | null>(null);
|
||||
|
||||
async function handleSettleParticipant(participantId: number) {
|
||||
setSettling(participantId);
|
||||
await settle.mutateAsync({ participant_id: participantId });
|
||||
setSettling(null);
|
||||
}
|
||||
|
||||
const others = balances.filter((b) => b.name !== "Me");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Shared Expenses</h2>
|
||||
<p className="text-zinc-500">Coming soon - track shared expenses and splits.</p>
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
||||
|
||||
{/* Balance summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{balLoading ? (
|
||||
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
|
||||
) : (
|
||||
others.map((b) => (
|
||||
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-medium">{b.name}</p>
|
||||
<p className="text-xs text-zinc-500">{b.unsettled_count} unsettled</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold text-amber-400">{formatAmount(b.total_owed)}</p>
|
||||
<p className="text-xs text-zinc-500">owes you</p>
|
||||
</div>
|
||||
</div>
|
||||
{b.unsettled_count > 0 && (
|
||||
<button
|
||||
onClick={() => handleSettleParticipant(b.id)}
|
||||
disabled={settling === b.id}
|
||||
className="w-full py-1.5 text-xs font-medium bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{settling === b.id ? "Settling..." : "Mark All Settled"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transaction list */}
|
||||
<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">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">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Date</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th>
|
||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">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 text-xs text-zinc-500 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(transactions as SharedTransactionRow[]).map((tx) => {
|
||||
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||
const unsettled = splits.filter((s) => !s.settled && s.name !== "Me");
|
||||
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">
|
||||
<p className="font-medium truncate max-w-48">{tx.effective_merchant || tx.description}</p>
|
||||
<p className="text-xs text-zinc-500 truncate max-w-48">{tx.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium tabular-nums">
|
||||
{formatAmount(tx.amount)}
|
||||
</td>
|
||||
<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 ${
|
||||
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>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{unsettled.length > 0 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
settle.mutateAsync({
|
||||
split_ids: unsettled.map((s) => (s as unknown as { split_id: number }).split_id),
|
||||
})
|
||||
}
|
||||
disabled={settle.isPending}
|
||||
className="text-xs px-2 py-1 bg-emerald-800 hover:bg-emerald-700 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
Settle
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+202
-48
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useStatements } from "@/lib/hooks";
|
||||
import { useStatements, useParticipants, useUpdateStatement } from "@/lib/hooks";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return "-";
|
||||
if (!d) return "—";
|
||||
return new Date(d).toLocaleDateString("en-AU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
@@ -12,64 +13,217 @@ function formatDate(d: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number | null, currency = "AUD") {
|
||||
if (amount === null || amount === undefined) return "-";
|
||||
function formatPeriod(start: string | null, end: string | null) {
|
||||
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", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amount);
|
||||
currency: "AUD",
|
||||
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() {
|
||||
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 (
|
||||
<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 ? (
|
||||
<p className="text-zinc-500">Loading...</p>
|
||||
) : !statements?.length ? (
|
||||
<p className="text-zinc-500">No statements found</p>
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
) : !filtered.length ? (
|
||||
<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">
|
||||
{statements.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="border border-zinc-800 rounded-lg p-4 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium">{s.bank_name}</h3>
|
||||
<span className="text-xs text-zinc-500">{s.currency}</span>
|
||||
</div>
|
||||
{s.card_name && (
|
||||
<p className="text-sm text-zinc-400 mb-2">{s.card_name}</p>
|
||||
)}
|
||||
<div className="text-sm text-zinc-400 space-y-1">
|
||||
<p>Account: {s.account_number}</p>
|
||||
<p>
|
||||
Period: {formatDate(s.billing_start_date)} - {formatDate(s.billing_end_date)}
|
||||
</p>
|
||||
<p>Due: {formatDate(s.payment_due_date)}</p>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-red-400">
|
||||
{formatCurrency(s.total_amount_due, s.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{s.transaction_count} transactions
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/transactions?statement_id=${s.id}`}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-sm transition-colors"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</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-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">Bank</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Ccy</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||
<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>
|
||||
<th className="px-4 py-2.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s, idx) => {
|
||||
const isCreditCard = s.statement_type?.toLowerCase().includes("card") ?? false;
|
||||
const displayAmount = isCreditCard ? s.total_amount_due : s.closing_balance;
|
||||
const amount = Number(displayAmount);
|
||||
const amountColor = isCreditCard
|
||||
? "text-red-400"
|
||||
: amount >= 0
|
||||
? "text-green-400"
|
||||
: "text-red-400";
|
||||
|
||||
return (
|
||||
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
|
||||
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
|
||||
{s.bank_name}
|
||||
</div>
|
||||
{s.card_name && (
|
||||
<div className="text-xs text-zinc-500 truncate max-w-[180px]">{s.card_name}</div>
|
||||
)}
|
||||
</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">
|
||||
<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>
|
||||
|
||||
+184
-18
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks";
|
||||
import { useState, useCallback, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import { SplitModal } from "@/components/split-modal";
|
||||
import { TagPicker } from "@/components/tag-picker";
|
||||
@@ -14,30 +15,135 @@ function formatDate(d: string) {
|
||||
});
|
||||
}
|
||||
|
||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||
|
||||
function formatAmount(amount: number, type: string) {
|
||||
const formatted = new Intl.NumberFormat("en-AU", {
|
||||
style: "currency",
|
||||
currency: "AUD",
|
||||
}).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 }) {
|
||||
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 (
|
||||
<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}
|
||||
</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({
|
||||
value,
|
||||
onSave,
|
||||
@@ -98,12 +204,24 @@ function InlineEdit({
|
||||
}
|
||||
|
||||
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({
|
||||
from: "",
|
||||
to: "",
|
||||
category: "",
|
||||
bank_name: "",
|
||||
search: "",
|
||||
statement_id: initialStatementId,
|
||||
tag_id: "",
|
||||
sort_by: "transaction_date",
|
||||
sort_dir: "desc",
|
||||
@@ -113,11 +231,17 @@ export default function TransactionsPage() {
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkCategory, setBulkCategory] = useState("");
|
||||
const [bulkTagId, setBulkTagId] = useState("");
|
||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null);
|
||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | 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: banks } = useBanks();
|
||||
const { data: tags } = useTags();
|
||||
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
|
||||
const updateTxn = useUpdateTransaction();
|
||||
const bulkAction = useBulkAction();
|
||||
|
||||
@@ -161,6 +285,27 @@ export default function TransactionsPage() {
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Transactions</h2>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
<input
|
||||
@@ -343,7 +488,10 @@ export default function TransactionsPage() {
|
||||
<div className="relative">
|
||||
<InlineEdit
|
||||
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 && (
|
||||
<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 +499,24 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</td>
|
||||
<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)}
|
||||
</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]">
|
||||
<div className="relative">
|
||||
<InlineEdit
|
||||
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"
|
||||
options={categoryOptions}
|
||||
/>
|
||||
@@ -386,7 +542,7 @@ export default function TransactionsPage() {
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<button
|
||||
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, transactionIds: undefined })}
|
||||
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Split this transaction"
|
||||
>
|
||||
@@ -407,10 +563,20 @@ export default function TransactionsPage() {
|
||||
transactionIds={splitModal.transactionIds}
|
||||
amount={splitModal.amount}
|
||||
description={splitModal.description}
|
||||
merchant={splitModal.merchant}
|
||||
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rulePrompt && (
|
||||
<SaveAsRulePrompt
|
||||
tx={rulePrompt.tx}
|
||||
field={rulePrompt.field}
|
||||
newValue={rulePrompt.newValue}
|
||||
onDone={() => setRulePrompt(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.total > filters.limit && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
|
||||
@@ -7,7 +7,9 @@ const NAV_ITEMS = [
|
||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||
{ 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: "/rules", label: "Rules", icon: "settings" },
|
||||
];
|
||||
@@ -44,6 +46,16 @@ 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" />
|
||||
</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>
|
||||
),
|
||||
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() {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule } 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: 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) return;
|
||||
const me = participants.find((p) => p.name === "Me");
|
||||
if (isBulk) {
|
||||
if (me) setSplitsState([{ participant_id: me.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 if (me) {
|
||||
setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
|
||||
}
|
||||
}, [existingSplits, participants, isBulk]);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -14,12 +14,16 @@ export const CATEGORIES = [
|
||||
"government",
|
||||
"education",
|
||||
"rent",
|
||||
"home_goods",
|
||||
"home_maintenance",
|
||||
"transfers",
|
||||
"income",
|
||||
"investment",
|
||||
"personal_care",
|
||||
"pets",
|
||||
"gifts",
|
||||
"charity",
|
||||
"fees",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
|
||||
+177
-1
@@ -94,6 +94,7 @@ export function useUpdateTransaction() {
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
transaction_type?: string;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -359,7 +360,7 @@ 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 };
|
||||
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;
|
||||
@@ -431,3 +432,178 @@ export function useApplyRules() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Budgets & Analytics ---
|
||||
|
||||
export interface BudgetRow {
|
||||
id: number;
|
||||
category: string;
|
||||
month: string;
|
||||
amount_limit: number;
|
||||
}
|
||||
|
||||
export interface MonthlyAnalyticsRow {
|
||||
category: string;
|
||||
spent: Record<string, number>;
|
||||
budget: Record<string, number>;
|
||||
txCount: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MonthlyAnalytics {
|
||||
months: string[];
|
||||
rows: MonthlyAnalyticsRow[];
|
||||
income: Record<string, number>;
|
||||
investments: Record<string, number>;
|
||||
totals: Record<string, { spent: number; income: number; investments: number; net: number }>;
|
||||
}
|
||||
|
||||
export function useBudgets(month: string) {
|
||||
return useQuery<BudgetRow[]>({
|
||||
queryKey: ["budgets", month],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/budgets?month=${month}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertBudget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { category: string; month: string; amount_limit: number }) => {
|
||||
const res = await fetch("/api/budgets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save budget");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBudget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await fetch(`/api/budgets/${id}`, { method: "DELETE" });
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonthlyAnalytics(months?: number) {
|
||||
const m = months || 6;
|
||||
return useQuery<MonthlyAnalytics>({
|
||||
queryKey: ["analytics", "monthly", m],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/analytics/monthly?months=${m}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface TransactionRow {
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
amount_aud: number | null;
|
||||
transaction_type: string;
|
||||
merchant_name: string | null;
|
||||
merchant_normalized: string | null;
|
||||
@@ -55,6 +56,7 @@ export interface StatementRow {
|
||||
fees_charged: number | null;
|
||||
credit_limit: number | null;
|
||||
currency: string;
|
||||
statement_type: string | null;
|
||||
tier_used: string | null;
|
||||
owner_id: number;
|
||||
owner_name: string;
|
||||
|
||||
Reference in New Issue
Block a user