Compare commits

..

40 Commits

Author SHA1 Message Date
1400aa99d6 Enforce positive=CREDIT for all BANK account uploads
Instead of relying on per-profile invertAmountSign settings, the upload
route now overrides invertAmountSign: true for any BANK account using
Strategy A. This ensures positive CSV amounts always map to CREDIT
(deposits) regardless of which bank or whether profile was auto-detected.
Credit card logic is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:39:14 -04:00
948ac2afe6 Fix Huntington profile detection: remove optional Split/Tags columns
Huntington CSVs don't always include Split and Tags headers, causing
detection to fail and fall back to the manual mapper with wrong defaults.
Detect on Date, Description, Amount which are always present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:37:28 -04:00
705a23c520 Revert bank profiles to invertAmountSign: true
Huntington and Fidelity CSVs use positive for deposits (CREDIT) and
negative for withdrawals (DEBIT). The previous change to false was wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:32:36 -04:00
a472749b21 Fix bank account sign convention: positive = DEBIT (withdrawal)
Huntington and Fidelity CSVs use positive amounts for withdrawals/
purchases and negative for deposits/credits. Change invertAmountSign
to false so the normalizer correctly maps them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:38:13 -04:00
99e41aab78 Fix pagination resetting to page 1 on navigation
push() depended on searchParams, causing the search debounce effect to
re-fire on every page change and delete the page param. Store searchParams
in a ref so push() is stable and only the search value triggers it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:20:22 -04:00
038539c191 Remove dedupeHash and duplicate skipping from CSV upload
Drop dedupeHash field and unique constraint from Transaction model.
Remove skipDuplicates from createMany. All rows in every upload are
now inserted unconditionally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:02:54 -04:00
decfb19ec6 Add restart: unless-stopped to db and app services
Automatically restarts both containers on crash or server reboot,
unless manually stopped with docker compose down.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:56:33 -04:00
3c13ae3597 Fix budget SelectValue showing ID instead of name in edit dialog
Pass budget name as explicit children to SelectValue so the selected
label renders correctly. Also handle TRANSFER type display in the header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:28:36 -04:00
6f1376cc53 Add TRANSFER transaction type with bulk action and auto-rules
- Add TRANSFER to TransactionType enum; excluded from cash flow queries
- Add TransferRule model: description patterns that auto-mark transactions
  as transfers on upload (takes priority over budget rules)
- Bulk action "Mark as transfer" in transaction table
- Transfer Rules button/dialog on transactions page for managing patterns
- Transfers shown with ⇄ prefix and muted color in transaction list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:15:07 -04:00
34bf24b35d Fix Discover CC sign convention: negative CSV = charge (DEBIT)
Discover CC exports charges as negative amounts and payments/refunds
as positive. invertAmountSign: true maps negative -> DEBIT (charge)
and positive -> CREDIT (payment/refund), which is correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:57:19 -04:00
587ac19b18 Revert budget to net DEBIT-CREDIT formula, remove fix-cc-types endpoint
Budget formula correctly adds DEBIT (CC charges stored from positive CSV
values) and subtracts CREDIT (refunds from negative CSV values).
Remove the fix-cc-types endpoint which was flipping CC transaction types
and causing charges to appear as CREDIT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:46:49 -04:00
00d4796008 Fix budget totals going negative
Budget formula now sums DEBIT transactions only, matching the intended
"current-month DEBIT total" behavior. Previously, CREDIT transactions
(CC payments) assigned to a budget would subtract and push totals negative.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:40:03 -04:00
5a795d7e93 Fix stale balances after transaction delete
- Bulk delete now recomputes currentBalanceCents and BalanceSnapshot
  records for every affected account+month after deletion
- Add POST /api/admin/recalculate-balances to fix currently stale
  accounts (zeros out balances and removes orphaned snapshots)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:30:24 -04:00
60dabb6264 Recompute account balance after bulk transaction delete
After deleting transactions, recalculate currentBalanceCents for each
affected account so the account card and net worth dashboard stay accurate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:24:59 -04:00
da938c1fcf Fix account delete blocked by CsvUpload FK constraint
Add onDelete: Cascade to CsvUpload.accountId so deleting an account
cascades to its upload records. Also explicitly delete uploads before
the account in the API route so existing deployed DBs without the
constraint don't error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:18:25 -04:00
0cf4612106 Add INVESTMENT account type with manual portfolio value recording 2026-04-21 11:32:50 -04:00
0ea6a7c698 Fix account select showing cuid instead of name in transaction filters 2026-04-21 11:05:11 -04:00
62ca178308 Remove CC type flip — normalizer already produces correct DEBIT/CREDIT for Discover CC 2026-04-21 10:59:59 -04:00
a9c12b94e1 Budget spend: simple DEBIT+/CREDIT- net formula, consistent with type flip 2026-04-21 10:52:39 -04:00
1a984d1eac Fix budget sign logic: CC CREDIT=spend, CC DEBIT=refund (type-flip aware) 2026-04-21 10:46:05 -04:00
c2b9184f2c Changed budget allocation 2026-04-21 10:43:00 -04:00
8c0e4ad684 budget pulls all Cc transactions instead of just credits 2026-04-21 10:37:33 -04:00
2a63b9120e Budget query needs to look for credit for CCs 2026-04-21 10:25:49 -04:00
e6f5d5a33b reverted budget allocation for credit cards 2026-04-21 09:47:49 -04:00
7ccd64a7bb CC account value consistency 2026-04-21 08:25:16 -04:00
d67a80b413 account specific budget queries 2026-04-20 23:44:06 -04:00
91a33cdfec Month Year selection for Dashboard 2026-04-20 23:31:59 -04:00
d865b02752 wrapped setBudgetTarget to fall back to '__none__' when the value is null. 2026-04-20 23:20:17 -04:00
f4216815e8 added functionality for multi-edit of transactions 2026-04-20 23:17:56 -04:00
jerick
60fc836b73 feat: category import from CSV + budget auto-assign rules
Category:
- Add categoryColumn to NormalizerConfig and NormalizedRow
- Map 'Category' column for Discover CC profile
- Write category to Transaction on upload

Budget rules:
- Add BudgetRule model (userId, budgetId, pattern)
- API: GET/POST /api/budget-rules, DELETE /api/budget-rules/:id
- Apply rules during upload (first case-insensitive match wins)
- Budgets page fetches and passes rules per budget
- BudgetCard 'Rules' menu item opens BudgetRulesDialog for add/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:56:41 -04:00
jerick
efe42ac366 fix: show account name in upload select after selection
Base UI Select.Value renders the raw value prop (cuid) rather than
the item label. Pass the looked-up account name as children to
override it. Falls back to placeholder when no account matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:30:58 -04:00
jerick
42f1d34ddd fix: move seed command to prisma.config.ts (Prisma 7 requirement)
Prisma 7 no longer reads the seed command from package.json.
It must be set in prisma.config.ts under migrations.seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:01:18 -04:00
jerick
adb5d144a0 fix: allow unauthenticated access to /login to prevent redirect loop
Unauthenticated requests to /login were hitting the auth gate and
redirecting back to /login?callbackUrl=/login, causing a loop.
Let the login page render for unauthenticated users; only redirect
away from /login when the user is already logged in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:49:01 -04:00
jerick
2e264014b6 fix: derive redirect and origin check from Host header
req.url resolves to the internal hostname in Docker standalone mode.
Read the Host header directly so redirects and CSRF origin checks use
whatever host the browser actually used (IP, hostname, or domain).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:44:11 -04:00
jerick
8b0fba5014 fix: use req.url as redirect base so host is preserved
req.nextUrl.origin resolves to localhost inside the container.
Using req.url preserves the Host header the browser sent, so
redirects work when accessing via IP or any external hostname.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:35:47 -04:00
jerick
874b022139 fix: bind app port to 0.0.0.0 so it's reachable from the network
127.0.0.1:3000 only accepted connections from localhost.
DB port intentionally stays on 127.0.0.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:33:04 -04:00
jerick
d41ab0c4e8 fix: split auth config so middleware uses Edge-compatible module
NextAuth's Credentials provider pulls in Prisma -> pg -> Node.js crypto,
which crashes in the Edge runtime. Extract an auth.config.ts with only
JWT/session callbacks (no DB, no bcrypt) and use NextAuth(authConfig) in
middleware. auth.ts spreads the config and adds the Credentials provider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:12:15 -04:00
jerick
0b4f9f5c0e fix: add setup service for db push/seed; update README
The production runner image has no node_modules, so prisma CLI and tsx
are unavailable. Add a Compose 'setup' profile service that uses the
builder stage (which has all dev tools) to run db push and db seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 15:25:02 -04:00
55debd082b first build commit 2026-04-19 00:44:43 -04:00
bc271b7ce1 First build commit 2026-04-19 00:35:42 -04:00
110 changed files with 19070 additions and 0 deletions

26
.dockerignore Normal file
View File

@@ -0,0 +1,26 @@
# Dependencies (rebuilt inside Docker)
node_modules
npm-debug.log*
# Next.js build output (rebuilt inside Docker)
.next
out
# Environment files (injected at runtime via Docker Compose)
.env
.env.local
.env.*.local
# Version control
.git
.gitignore
.gitattributes
# Editor / OS
.DS_Store
*.log
.vscode
.idea
# TypeScript build cache
tsconfig.tsbuildinfo

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Database
DATABASE_URL="postgresql://financeapp:password@localhost:5432/financeapp"
# NextAuth
NEXTAUTH_SECRET="<generate with: openssl rand -base64 32>"
NEXTAUTH_URL="http://localhost:3000"
# Seed user (used by prisma/seed.ts only)
SEED_EMAIL="your@email.com"
SEED_PASSWORD="your-secure-password"
# Postgres (used by docker-compose.yml)
POSTGRES_USER="financeapp"
POSTGRES_PASSWORD="password"
POSTGRES_DB="financeapp"

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

5
AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

356
CLAUDE.md Normal file
View File

@@ -0,0 +1,356 @@
# Finance App — Claude Context
Personal finance web app for tracking bank transactions, monitoring net worth, and visualizing spending. Single-user, self-hosted via Docker Compose.
## Tech Stack
| Layer | Choice |
|---|---|
| Framework | Next.js 16+ App Router + TypeScript |
| Database | PostgreSQL + Prisma ORM |
| Auth | NextAuth.js v5 (Credentials provider, bcrypt, JWT sessions) |
| UI | Tailwind CSS + shadcn/ui |
| Charts | Recharts |
| CSV Parsing | Papa Parse (server-side only — never client-side) |
| Validation | Zod on every API route before any DB access |
| Deployment | Docker Compose (postgres:16-alpine + Next.js app) |
## Build Status
**Phases 16 complete.**
## Implementation Phases
| # | Phase | Status |
|---|---|---|
| 1 | Scaffold: create-next-app, shadcn, Prisma schema, Docker, .env | Done |
| 2 | Auth: NextAuth, middleware, login page, seed script | Done |
| 3 | Accounts CRUD: API routes + AccountList UI | Done |
| 4 | CSV Upload: bank profiles, parser, normalizer, upload API + UI | Done |
| 5 | Transaction browsing: paginated API, filters, edit dialog | Done |
| 6 | Budgets: CRUD API, UI, assign transactions to budgets | Done |
| 7 | Dashboard: aggregate API, NetWorthCard, CashFlowCard, BudgetSummary | Not started |
| 8 | Graphs: Recharts components, BalanceSnapshot read path | Not started |
| 9 | Security hardening: HTTP headers, rate limiting, Origin check | Not started |
| 10 | Docker polish: multi-stage Dockerfile, health checks, README | Not started |
---
## Prisma 7 Notes
- Generator provider is `prisma-client` (not `prisma-client-js`)
- Client output: `src/generated/prisma/` — import as `@/generated/prisma/client`
- **Requires Driver Adapter** — no URL in constructor. Use `@prisma/adapter-pg` with `pg`:
```ts
import { Pool } from 'pg'
import { PrismaPg } from '@prisma/adapter-pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
new PrismaClient({ adapter })
```
- Datasource URL configured in `prisma.config.ts` (for CLI) and via `Pool` at runtime
- Singleton: `src/lib/prisma.ts`
- Seed: `npx prisma db seed` (runs `tsx prisma/seed.ts`)
---
## Database Schema
Store all money as **integer cents** (never floats). `100.10` → `10010`.
```prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String // bcrypt hash, cost factor 12
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
budgets Budget[]
}
enum AccountType {
BANK // Checking, savings, investment — counts toward net worth
CREDIT_CARD // Tracking only — never counted in net worth or cash flow
}
model Account {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
institution String?
type AccountType
currency String @default("USD")
currentBalanceCents Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
uploads CsvUpload[]
@@index([userId])
}
enum TransactionType {
DEBIT // Money out (spending, withdrawals, purchases)
CREDIT // Money in (deposits, payments received, refunds)
}
model Transaction {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
uploadId String?
upload CsvUpload? @relation(fields: [uploadId], references: [id])
budgetId String?
budget Budget? @relation(fields: [budgetId], references: [id], onDelete: SetNull)
date DateTime
description String
amountCents Int // Always positive; direction from `type` field
type TransactionType
category String?
notes String?
dedupeHash String // SHA-256(accountId|date|description|amountCents) — prevents re-upload duplicates
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([dedupeHash])
@@index([accountId, date])
@@index([date])
@@index([budgetId])
}
// User-defined spending groups, primarily for CC transactions
model Budget {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // e.g. "Groceries", "Entertainment"
limitCents Int? // Optional monthly cap
color String? // Hex color for UI, e.g. "#6366f1"
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
@@index([userId])
}
model CsvUpload {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id])
fileName String
rowCount Int
importedCount Int
skippedCount Int
status String // PENDING | SUCCESS | PARTIAL | FAILED
errorMessage String?
uploadedAt DateTime @default(now())
transactions Transaction[]
@@index([accountId])
}
// Monthly balance snapshots for net worth trend graphs.
// No FK to Account intentionally — snapshots survive account deletion.
model BalanceSnapshot {
id String @id @default(cuid())
accountId String
year Int
month Int // 112
balanceCents Int
computedAt DateTime @default(now())
@@unique([accountId, year, month])
@@index([year, month])
}
```
### Key schema decisions
- `currentBalanceCents` is always **recomputed by summing all transactions** after each upload — never incremented by delta — to handle out-of-order historical imports
- `dedupeHash` unique constraint lets `createMany({ skipDuplicates: true })` silently handle re-uploads
- `budgetId` uses `onDelete: SetNull` — deleting a budget unlinks its transactions, never deletes them
- `BalanceSnapshot` has no FK so historical net worth graphs survive account deletion
---
## CSV Bank Profiles
All 4 user banks with exact column mappings. Implement in `src/lib/csv/bank-profiles.ts`.
### Discover High Yield Savings → `BANK`
- **Strategy**: B (split debit/credit columns)
- `date` ← `Transaction Date`
- `description` ← `Transaction Description`
- `debitAmount` ← `Debit`
- `creditAmount` ← `Credit`
- `balance` ← `Balance` (reference only)
- Ignore: `Transaction Type`
### Discover Credit Card → `CREDIT_CARD`
- **Strategy**: A (single signed column)
- `date` ← `Trans. Date`
- `description` ← `Description`
- `amount` ← `Amount` — **positive = DEBIT (charge), negative = CREDIT (payment/refund)**
- Ignore: `Post Date`, `Category`
### Huntington Checking → `BANK`
- **Strategy**: A (single signed column)
- `date` ← `Date`
- `description` ← `Description`
- `amount` ← `Amount` — **negative = DEBIT, positive = CREDIT**
- Ignore: `Category`, `Split`, `Tags`
### Fidelity → `BANK` (investment — cash activity only)
- **Strategy**: A (single signed column)
- `date` ← `Run Date`
- `description` ← `Description`
- `amount` ← `Amount($)` — **negative = DEBIT (purchase), positive = CREDIT (dividend/sale)**
- `balance` ← `Cash Balance ($)` (reference only)
- Ignore: `Action`, `Symbol`, `Type`, `Price($)`, `Quantity`, `Commission ($)`, `Fees ($)`, `Accrued Interest ($)`, `Settlement Date`
- **Known limitation**: tracks cash activity only, not total portfolio value (securities not included)
### Amount parsing strategies
```typescript
// Strategy A — single signed column
// Sign convention varies per bank; each profile has invertAmountSign: boolean
function parseStrategyA(raw: string, invert: boolean): { amountCents: number; type: TransactionType }
// Strategy B — separate debit/credit columns (Discover Savings)
// The non-empty/non-zero column determines type and amount
function parseStrategyB(debitRaw: string, creditRaw: string): { amountCents: number; type: TransactionType }
// All amounts via:
function parseCents(raw: string): number {
return Math.round(parseFloat(raw.replace(/[$,\s]/g, '')) * 100)
}
```
### Upload flow
1. Server parses header row → attempts profile auto-detection by matching column names
2. **Match found**: auto-proceeds; client shows "Detected: [Bank Name]"
3. **No match**: returns `{ requiresMapping: true, headers, sampleRows }` → client renders `ColumnMapper` for manual column assignment
4. After mapping resolved: normalize all rows → compute `dedupeHash` → `createMany({ skipDuplicates: true })` → recompute `currentBalanceCents` (full sum) → write/update `BalanceSnapshot` for each affected month
---
## Key Features
### Account types
- **BANK** (Discover Savings, Huntington Checking, Fidelity): counted in net worth and cash flow
- **CREDIT_CARD** (Discover CC): tracked only — never in net worth or cash flow calculations
### Budgets (for CC transactions primarily)
- User creates named budgets with optional monthly spending caps and a hex color
- Any transaction can be assigned to a budget via `budgetId`
- `BudgetCard` shows: name, current-month DEBIT total, limit, progress bar (green <75% / yellow 75-100% / red >100%)
- Dashboard has a `BudgetSummary` section with all active budgets
- Deleting a budget sets `budgetId = null` on all its transactions (transactions not deleted)
### Net worth calculation
```sql
-- Always filter type = 'BANK'; credit card accounts never appear here
SELECT SUM(currentBalanceCents) FROM Account WHERE userId = ? AND type = 'BANK' AND isActive = true
```
---
## Security Requirements
- **All API routes**: validate with Zod before any DB access; scope every Prisma query by `userId`
- **Auth**: bcrypt cost 12; JWT sessions 1-hour expiry; `middleware.ts` protects all `/(app)` routes and `/api/*` except `/api/auth`
- **CSV upload**: max 10 MB; check MIME type + file extension server-side; never write file to disk; parse in memory then discard
- **Raw SQL** (aggregation queries only): use `Prisma.sql` tagged templates — never string concatenation
- **HTTP security headers** in `next.config.ts`: `Content-Security-Policy`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`
- **Docker**: Next.js runs as non-root user (`USER node`); PostgreSQL binds to `127.0.0.1` only
---
## .env Variables Required
```bash
# Database
DATABASE_URL="postgresql://financeapp:password@localhost:5432/financeapp"
# NextAuth
NEXTAUTH_SECRET="<generate with: openssl rand -base64 32>"
NEXTAUTH_URL="http://localhost:3000"
# Seed user (used by prisma/seed.ts only)
SEED_EMAIL="your@email.com"
SEED_PASSWORD="your-secure-password"
# Postgres (used by docker-compose.yml)
POSTGRES_USER="financeapp"
POSTGRES_PASSWORD="password"
POSTGRES_DB="financeapp"
```
---
## Project File Structure (target)
```
finance-app/
├── .env # Never committed
├── .env.example
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── next.config.ts
├── tailwind.config.ts
├── components.json # shadcn/ui config
├── prisma/
│ ├── schema.prisma
│ ├── seed.ts
│ └── migrations/
└── src/
├── middleware.ts
├── app/
│ ├── (auth)/login/page.tsx
│ ├── (app)/
│ │ ├── layout.tsx
│ │ ├── dashboard/page.tsx
│ │ ├── accounts/page.tsx
│ │ ├── accounts/[id]/page.tsx
│ │ ├── transactions/page.tsx
│ │ ├── upload/page.tsx
│ │ ├── budgets/page.tsx
│ │ └── graphs/page.tsx
│ └── api/
│ ├── auth/[...nextauth]/route.ts
│ ├── accounts/route.ts + [id]/route.ts
│ ├── transactions/route.ts + [id]/route.ts
│ ├── upload/route.ts
│ ├── budgets/route.ts + [id]/route.ts
│ └── dashboard/route.ts
├── components/
│ ├── ui/ # shadcn/ui generated
│ ├── layout/ # Sidebar, TopNav, PageHeader
│ ├── accounts/ # AccountList, AccountCard, CreateAccountDialog, AccountBadge
│ ├── transactions/ # TransactionTable, TransactionFilters, EditTransactionDialog
│ ├── upload/ # UploadDropzone, UploadForm, ColumnMapper, UploadPreview
│ ├── budgets/ # BudgetList, BudgetCard, CreateBudgetDialog, BudgetProgress
│ ├── dashboard/ # NetWorthCard, CashFlowCard, MonthlyBalanceCard, BudgetSummary
│ └── graphs/ # MonthlySpendingChart, NetWorthTrendChart, CategoryBreakdownChart, CashFlowChart, BudgetChart
└── lib/
├── auth.ts # NextAuth config (imported by route + middleware)
├── prisma.ts # Singleton PrismaClient
├── csv/
│ ├── bank-profiles.ts # All 4 bank profiles
│ ├── parser.ts # Profile detection + Papa Parse wrapper
│ └── normalizer.ts # Row → Transaction, cents parsing, dedupeHash
├── validations/ # account.ts, transaction.ts, upload.ts, budget.ts (Zod schemas)
└── utils/
├── currency.ts # cents → display string formatter
├── dates.ts # month boundary helpers
└── cn.ts # shadcn class merge utility
```

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM node:22-alpine AS base
# ── deps: install production + dev dependencies ───────────────────────────────
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# ── builder: generate Prisma client + build Next.js ──────────────────────────
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# ── runner: minimal production image ─────────────────────────────────────────
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
apk add --no-cache curl
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]

124
README.md
View File

@@ -0,0 +1,124 @@
# Finance App
Personal finance web app for tracking bank transactions, monitoring net worth, and visualizing spending. Self-hosted via Docker Compose — no cloud services required.
## Features
- Import transactions from CSV exports (Discover Savings, Discover Credit Card, Huntington Checking, Fidelity)
- Net worth tracking across bank accounts
- Browse, filter, and annotate transactions
- Assign transactions to budgets with optional monthly caps
- Dashboard: net worth snapshot, cash flow, recent transactions, budget summary
- Charts: net worth trend, monthly cash flow, spending by category, budget performance
## Quick start
**Prerequisites:** Docker and Docker Compose
```sh
# 1. Copy the example env file
cp .env.example .env
```
Edit `.env`:
- Set `NEXTAUTH_SECRET` to the output of `openssl rand -base64 32`
- Set `SEED_EMAIL` and `SEED_PASSWORD` to your desired login credentials
- Change `POSTGRES_PASSWORD` to a strong password
```sh
# 2. Build the images
docker compose build
# 3. Start the database
docker compose up db -d
# 4. First-run only: apply the schema and create your user
docker compose --profile setup run --rm setup
# 5. Start the app
docker compose up app -d
# 6. Open the app
open http://localhost:3000
```
Sign in with the `SEED_EMAIL` / `SEED_PASSWORD` you set.
## Environment variables
| Variable | Description |
|---|---|
| `NEXTAUTH_SECRET` | Secret for JWT signing — run `openssl rand -base64 32` |
| `NEXTAUTH_URL` | Public URL of the app (default: `http://localhost:3000`) |
| `SEED_EMAIL` | Login email created by the seed script |
| `SEED_PASSWORD` | Login password created by the seed script |
| `POSTGRES_USER` | PostgreSQL username |
| `POSTGRES_PASSWORD` | PostgreSQL password |
| `POSTGRES_DB` | PostgreSQL database name |
| `DATABASE_URL` | Full connection string — set automatically by Compose |
## Useful commands
```sh
# View logs
docker compose logs -f app
# Stop the stack
docker compose down
# Stop and wipe the database volume
docker compose down -v
# Re-seed after a wipe
docker compose --profile setup run --rm setup
# Check health
curl http://localhost:3000/api/health
```
## CSV import
Upload CSV files at `/upload`. The bank profile is auto-detected from the header row. Supported profiles:
| Bank | Account type |
|---|---|
| Discover High Yield Savings | BANK |
| Discover Credit Card | CREDIT_CARD |
| Huntington Checking | BANK |
| Fidelity (cash activity only) | BANK |
If auto-detection fails, a column mapper is shown for manual field assignment.
**BANK** accounts count toward net worth and cash flow. **CREDIT_CARD** accounts are tracked only.
## Development
```sh
# Start the database
docker compose up db -d
# Install dependencies
npm install
# Apply schema
npx prisma db push
# Seed a user
npx prisma db seed
# Start the dev server
npm run dev
```
The app is at http://localhost:3000.
## Architecture
| Layer | Choice |
|---|---|
| Framework | Next.js 16 App Router + TypeScript |
| Database | PostgreSQL 16 + Prisma ORM |
| Auth | NextAuth.js v5 (JWT sessions, bcrypt) |
| UI | Tailwind CSS + shadcn/ui |
| Charts | Recharts |
| Deployment | Docker Compose |

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

57
docker-compose.yml Normal file
View File

@@ -0,0 +1,57 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
app:
build: .
restart: unless-stopped
ports:
- "0.0.0.0:3000:3000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# First-run setup: applies the schema and seeds the user.
# Uses the builder stage so all dev tools (prisma CLI, tsx, bcryptjs) are available.
# Run once with: docker compose --profile setup run --rm setup
setup:
build:
context: .
target: builder
command: sh -c "npx prisma db push && npx prisma db seed"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SEED_EMAIL: ${SEED_EMAIL}
SEED_PASSWORD: ${SEED_PASSWORD}
depends_on:
db:
condition: service_healthy
profiles:
- setup
volumes:
postgres_data:

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

32
next.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { NextConfig } from 'next'
const securityHeaders = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; '),
},
]
const nextConfig: NextConfig = {
output: 'standalone',
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }]
},
}
export default nextConfig

12031
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "finance-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.4.0",
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"shadcn": "^4.3.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"prisma": "^7.7.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

15
prisma.config.ts Normal file
View File

@@ -0,0 +1,15 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

139
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,139 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
budgets Budget[]
budgetRules BudgetRule[]
transferRules TransferRule[]
}
enum AccountType {
BANK
CREDIT_CARD
INVESTMENT
}
model Account {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
institution String?
type AccountType
currency String @default("USD")
currentBalanceCents Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
uploads CsvUpload[]
@@index([userId])
}
enum TransactionType {
DEBIT
CREDIT
TRANSFER
}
model Transaction {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
uploadId String?
upload CsvUpload? @relation(fields: [uploadId], references: [id])
budgetId String?
budget Budget? @relation(fields: [budgetId], references: [id], onDelete: SetNull)
date DateTime
description String
amountCents Int
type TransactionType
category String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([accountId, date])
@@index([date])
@@index([budgetId])
}
model Budget {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
limitCents Int?
color String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
rules BudgetRule[]
@@index([userId])
}
model BudgetRule {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
budgetId String
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Cascade)
pattern String
createdAt DateTime @default(now())
@@index([userId])
@@index([budgetId])
}
model TransferRule {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pattern String
createdAt DateTime @default(now())
@@index([userId])
}
model CsvUpload {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
fileName String
rowCount Int
importedCount Int
skippedCount Int
status String
errorMessage String?
uploadedAt DateTime @default(now())
transactions Transaction[]
@@index([accountId])
}
model BalanceSnapshot {
id String @id @default(cuid())
accountId String
year Int
month Int
balanceCents Int
computedAt DateTime @default(now())
@@unique([accountId, year, month])
@@index([year, month])
}

31
prisma/seed.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Pool } from 'pg'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../src/generated/prisma/client'
import bcrypt from 'bcryptjs'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })
async function main() {
const email = process.env.SEED_EMAIL
const password = process.env.SEED_PASSWORD
if (!email || !password) {
throw new Error('SEED_EMAIL and SEED_PASSWORD must be set in .env')
}
const passwordHash = await bcrypt.hash(password, 12)
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash },
create: { email, passwordHash },
})
console.log(`Seeded user: ${user.email}`)
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,88 @@
import { notFound } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { AccountBadge } from '@/components/accounts/AccountBadge'
import { formatCents } from '@/lib/utils/currency'
import { TransactionTable } from '@/components/transactions/TransactionTable'
const PAGE_LIMIT = 50
type Props = {
params: Promise<{ id: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function AccountDetailPage({ params, searchParams }: Props) {
const session = await auth()
if (!session?.user?.id) return null
const { id } = await params
const sp = await searchParams
const page = Math.max(1, Number(sp.page) || 1)
const [account, budgets] = await Promise.all([
prisma.account.findFirst({ where: { id, userId: session.user.id } }),
prisma.budget.findMany({
where: { userId: session.user.id, isActive: true },
select: { id: true, name: true, color: true },
orderBy: { name: 'asc' },
}),
])
if (!account) notFound()
const where = { accountId: id }
const [transactions, total] = await Promise.all([
prisma.transaction.findMany({
where,
include: {
account: { select: { name: true, type: true } },
budget: { select: { id: true, name: true, color: true } },
},
orderBy: { date: 'desc' },
skip: (page - 1) * PAGE_LIMIT,
take: PAGE_LIMIT,
}),
prisma.transaction.count({ where }),
])
const rows = transactions.map((tx) => ({
...tx,
date: tx.date.toISOString(),
createdAt: undefined,
updatedAt: undefined,
}))
return (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{account.name}</h1>
<AccountBadge type={account.type} />
</div>
{account.institution && (
<p className="text-muted-foreground">{account.institution}</p>
)}
</div>
<div className="text-right">
<p className="text-3xl font-bold tabular-nums">
{formatCents(account.currentBalanceCents)}
</p>
<p className="text-xs text-muted-foreground mt-1">
{account.isActive ? 'Active' : 'Inactive'}
</p>
</div>
</div>
<TransactionTable
transactions={rows}
total={total}
page={page}
limit={PAGE_LIMIT}
showAccount={false}
budgets={budgets}
/>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { AccountList } from '@/components/accounts/AccountList'
export default async function AccountsPage() {
const session = await auth()
if (!session?.user?.id) return null
const accounts = await prisma.account.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
})
return (
<div className="p-6">
<AccountList accounts={accounts} />
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds } from '@/lib/utils/dates'
import { BudgetList } from '@/components/budgets/BudgetList'
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function BudgetsPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const userId = session.user.id
const sp = await searchParams
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
const now = new Date()
const month = Number(get('month')) || (now.getMonth() + 1)
const year = Number(get('year')) || now.getFullYear()
const selectedDate = new Date(year, month - 1, 1)
const { start, end } = monthBounds(selectedDate)
const [budgets, spendRows, rules] = await Promise.all([
prisma.budget.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.budgetRule.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
select: { id: true, budgetId: true, pattern: true },
}),
])
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
const rulesMap = new Map<string, { id: string; pattern: string }[]>()
for (const rule of rules) {
const existing = rulesMap.get(rule.budgetId) ?? []
existing.push({ id: rule.id, pattern: rule.pattern })
rulesMap.set(rule.budgetId, existing)
}
const budgetsWithSpend = budgets.map((b) => ({
id: b.id,
name: b.name,
limitCents: b.limitCents,
color: b.color,
isActive: b.isActive,
spendCents: spendMap.get(b.id) ?? 0,
rules: rulesMap.get(b.id) ?? [],
}))
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Budgets</h1>
<MonthYearPicker />
</div>
<BudgetList budgets={budgetsWithSpend} />
</div>
)
}

View File

@@ -0,0 +1,142 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds, monthLabel } from '@/lib/utils/dates'
import { NetWorthCard } from '@/components/dashboard/NetWorthCard'
import { CashFlowCard } from '@/components/dashboard/CashFlowCard'
import { RecentTransactions } from '@/components/dashboard/RecentTransactions'
import { BudgetSummary } from '@/components/dashboard/BudgetSummary'
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const userId = session.user.id
const sp = await searchParams
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
const now = new Date()
const month = Number(get('month')) || (now.getMonth() + 1)
const year = Number(get('year')) || now.getFullYear()
const isCurrentMonth = month === (now.getMonth() + 1) && year === now.getFullYear()
const selectedDate = new Date(year, month - 1, 1)
const { start, end } = monthBounds(selectedDate)
const [cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
prisma.$queryRaw<{ type: string; total: bigint }[]>`
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
AND t.type != 'TRANSFER'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t.type
`,
prisma.budget.findMany({
where: { userId, isActive: true },
orderBy: { name: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.transaction.findMany({
where: {
account: { userId },
date: { gte: start, lte: end },
},
include: { account: { select: { name: true } } },
orderBy: { date: 'desc' },
take: 5,
}),
])
// Net worth: live balances for current month, snapshots for past months
let netWorthCents: number
let netWorthAccounts: { id: string; name: string; balanceCents: number }[]
if (isCurrentMonth) {
const accounts = await prisma.account.findMany({
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
select: { id: true, name: true, currentBalanceCents: true },
orderBy: { name: 'asc' },
})
netWorthAccounts = accounts.map((a) => ({ id: a.id, name: a.name, balanceCents: a.currentBalanceCents }))
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
} else {
const snapshots = await prisma.$queryRaw<{ accountId: string; name: string; balanceCents: bigint }[]>`
SELECT bs."accountId", a.name, bs."balanceCents"
FROM "BalanceSnapshot" bs
JOIN "Account" a ON bs."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type IN ('BANK', 'INVESTMENT')
AND bs.year = ${year}
AND bs.month = ${month}
`
netWorthAccounts = snapshots.map((s) => ({
id: s.accountId,
name: s.name,
balanceCents: Number(s.balanceCents),
}))
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
}
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
const creditsCents = cfMap['CREDIT'] ?? 0
const debitsCents = cfMap['DEBIT'] ?? 0
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
const recentTransactions = recentTx.map((t) => ({
id: t.id,
date: t.date.toISOString(),
description: t.description,
amountCents: t.amountCents,
type: t.type,
accountName: t.account.name,
}))
const budgetsWithSpend = budgets.map((b) => ({
id: b.id,
name: b.name,
limitCents: b.limitCents,
color: b.color,
spendCents: spendMap.get(b.id) ?? 0,
}))
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<MonthYearPicker />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={netWorthAccounts} />
<CashFlowCard
monthLabel={monthLabel(selectedDate)}
creditsCents={creditsCents}
debitsCents={debitsCents}
netCents={creditsCents - debitsCents}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<RecentTransactions transactions={recentTransactions} />
<BudgetSummary budgets={budgetsWithSpend} />
</div>
</div>
)
}

View File

@@ -0,0 +1,186 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds, monthLabel, formatYearMonth, lastNMonthsStart } from '@/lib/utils/dates'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { NetWorthTrendChart } from '@/components/graphs/NetWorthTrendChart'
import { CashFlowChart } from '@/components/graphs/CashFlowChart'
import { MonthlySpendingChart } from '@/components/graphs/MonthlySpendingChart'
import { CategoryBreakdownChart } from '@/components/graphs/CategoryBreakdownChart'
import { BudgetChart } from '@/components/graphs/BudgetChart'
export default async function GraphsPage() {
const session = await auth()
if (!session?.user?.id) return null
const userId = session.user.id
const { start: monthStart, end: monthEnd } = monthBounds()
const sixMonthsAgo = lastNMonthsStart(6)
const [netWorthRows, cashFlowRows, spendingRows, categoryRows, budgetSpendRows, budgets] =
await Promise.all([
// Net worth trend from BalanceSnapshot (all time, BANK only)
prisma.$queryRaw<{ year: number; month: number; total: bigint }[]>`
SELECT bs.year, bs.month, COALESCE(SUM(bs."balanceCents"), 0)::bigint AS total
FROM "BalanceSnapshot" bs
JOIN "Account" a ON bs."accountId" = a.id
WHERE a."userId" = ${userId} AND a.type = 'BANK'
GROUP BY bs.year, bs.month
ORDER BY bs.year, bs.month
`,
// Monthly cash flow (last 6 months, BANK only)
prisma.$queryRaw<{ year: number; month: number; type: string; total: bigint }[]>`
SELECT
EXTRACT(YEAR FROM t.date)::int AS year,
EXTRACT(MONTH FROM t.date)::int AS month,
t.type,
COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
AND t.date >= ${sixMonthsAgo}
GROUP BY year, month, t.type
ORDER BY year, month
`,
// Monthly total spending across ALL accounts (last 6 months, DEBIT only)
prisma.$queryRaw<{ year: number; month: number; total: bigint }[]>`
SELECT
EXTRACT(YEAR FROM t.date)::int AS year,
EXTRACT(MONTH FROM t.date)::int AS month,
COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t.type = 'DEBIT'
AND t.date >= ${sixMonthsAgo}
GROUP BY year, month
ORDER BY year, month
`,
// Category breakdown (current month, all accounts, DEBIT)
prisma.$queryRaw<{ category: string; total: bigint }[]>`
SELECT
COALESCE(t.category, 'Uncategorized') AS category,
COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t.type = 'DEBIT'
AND t.date >= ${monthStart}
AND t.date <= ${monthEnd}
GROUP BY category
ORDER BY total DESC
`,
// Budget spend (current month)
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.type = 'DEBIT'
AND t.date >= ${monthStart}
AND t.date <= ${monthEnd}
GROUP BY t."budgetId"
`,
prisma.budget.findMany({
where: { userId, isActive: true },
orderBy: { name: 'asc' },
}),
])
// Net worth trend: take last 24 data points
const netWorthData = netWorthRows.slice(-24).map((r) => ({
label: formatYearMonth(r.year, r.month),
totalCents: Number(r.total),
}))
// Cash flow: build month-keyed map then fill in credits/debits
const cfMap = new Map<string, { label: string; creditsCents: number; debitsCents: number }>()
for (const r of cashFlowRows) {
const key = `${r.year}-${r.month}`
if (!cfMap.has(key)) {
cfMap.set(key, { label: formatYearMonth(r.year, r.month), creditsCents: 0, debitsCents: 0 })
}
const entry = cfMap.get(key)!
if (r.type === 'CREDIT') entry.creditsCents = Number(r.total)
else entry.debitsCents = Number(r.total)
}
const cashFlowData = Array.from(cfMap.values())
// Monthly spending (all accounts)
const monthlySpendData = spendingRows.map((r) => ({
label: formatYearMonth(r.year, r.month),
totalCents: Number(r.total),
}))
// Category breakdown
const categoryData = categoryRows.map((r) => ({
category: r.category,
totalCents: Number(r.total),
}))
// Budget chart
const spendMap = new Map(budgetSpendRows.map((r) => [r.budgetId, Number(r.total)]))
const budgetData = budgets.map((b) => ({
name: b.name,
spendCents: spendMap.get(b.id) ?? 0,
limitCents: b.limitCents ?? 0,
color: b.color,
}))
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Graphs</h1>
<p className="text-sm text-muted-foreground">{monthLabel()}</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-base">Net Worth Trend</CardTitle>
</CardHeader>
<CardContent>
<NetWorthTrendChart data={netWorthData} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Cash Flow · Last 6 Months (Bank)</CardTitle>
</CardHeader>
<CardContent>
<CashFlowChart data={cashFlowData} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Monthly Spending · Last 6 Months (All Accounts)</CardTitle>
</CardHeader>
<CardContent>
<MonthlySpendingChart data={monthlySpendData} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Spending by Category · {monthLabel()}</CardTitle>
</CardHeader>
<CardContent>
<CategoryBreakdownChart data={categoryData} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Budget Performance · {monthLabel()}</CardTitle>
</CardHeader>
<CardContent>
<BudgetChart data={budgetData} />
</CardContent>
</Card>
</div>
</div>
)
}

12
src/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Sidebar } from '@/components/layout/Sidebar'
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@/generated/prisma/client'
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
import { TransactionTable } from '@/components/transactions/TransactionTable'
import { TransferRulesButton } from '@/components/transactions/TransferRulesButton'
const PAGE_LIMIT = 50
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function TransactionsPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const sp = await searchParams
const get = (key: string) => (Array.isArray(sp[key]) ? sp[key][0] : sp[key]) ?? ''
const page = Math.max(1, Number(get('page')) || 1)
const accountId = get('accountId')
const dateFrom = get('dateFrom')
const dateTo = get('dateTo')
const type = get('type') as 'DEBIT' | 'CREDIT' | ''
const search = get('search')
const where: Prisma.TransactionWhereInput = {
account: { userId: session.user.id },
...(accountId && { accountId }),
...(type && { type }),
...(search && { description: { contains: search, mode: 'insensitive' } }),
...((dateFrom || dateTo) && {
date: {
...(dateFrom && { gte: new Date(dateFrom) }),
...(dateTo && { lte: new Date(dateTo + 'T23:59:59.999Z') }),
},
}),
}
const [transactions, total, accounts, budgets, transferRules] = await Promise.all([
prisma.transaction.findMany({
where,
include: {
account: { select: { name: true, type: true } },
budget: { select: { id: true, name: true, color: true } },
},
orderBy: { date: 'desc' },
skip: (page - 1) * PAGE_LIMIT,
take: PAGE_LIMIT,
}),
prisma.transaction.count({ where }),
prisma.account.findMany({
where: { userId: session.user.id },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
prisma.budget.findMany({
where: { userId: session.user.id, isActive: true },
select: { id: true, name: true, color: true },
orderBy: { name: 'asc' },
}),
prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, pattern: true },
}),
])
// Serialize dates for client components
const rows = transactions.map((tx) => ({
...tx,
date: tx.date.toISOString(),
createdAt: undefined,
updatedAt: undefined,
}))
return (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Transactions</h1>
<TransferRulesButton rules={transferRules} />
</div>
<TransactionFilters accounts={accounts} />
<TransactionTable
transactions={rows}
total={total}
page={page}
limit={PAGE_LIMIT}
showAccount
budgets={budgets}
/>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { UploadForm } from '@/components/upload/UploadForm'
export default async function UploadPage() {
const session = await auth()
if (!session?.user?.id) return null
const accounts = await prisma.account.findMany({
where: { userId: session.user.id, isActive: true },
orderBy: { name: 'asc' },
select: { id: true, name: true },
})
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold mb-1">Upload Transactions</h1>
<p className="text-sm text-muted-foreground mb-6">
Import a bank CSV. Duplicates are silently skipped.
</p>
<UploadForm accounts={accounts} />
</div>
)
}

View File

@@ -0,0 +1,75 @@
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
export default function LoginPage() {
const router = useRouter()
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError('')
setLoading(true)
const form = new FormData(e.currentTarget)
const result = await signIn('credentials', {
email: form.get('email') as string,
password: form.get('password') as string,
redirect: false,
})
if (result?.error) {
setError('Invalid email or password')
setLoading(false)
} else {
router.push('/dashboard')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Sign in</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const schema = z.object({
valueCents: z.number().int(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
})
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const account = await prisma.account.findFirst({
where: { id, userId: session.user.id, type: 'INVESTMENT' },
})
if (!account) return NextResponse.json({ error: 'Account not found' }, { status: 404 })
const result = schema.safeParse(await req.json())
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const { valueCents, date } = result.data
const d = new Date(date + 'T12:00:00')
const year = d.getFullYear()
const month = d.getMonth() + 1
await prisma.account.update({
where: { id },
data: { currentBalanceCents: valueCents },
})
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId: id, year, month } },
update: { balanceCents: valueCents, computedAt: new Date() },
create: { accountId: id, year, month, balanceCents: valueCents },
})
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { updateAccountSchema } from '@/lib/validations/account'
type Params = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const account = await prisma.account.findFirst({
where: { id, userId: session.user.id },
})
if (!account) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(account)
}
export async function PATCH(req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const parsed = updateAccountSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const existing = await prisma.account.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const account = await prisma.account.update({
where: { id },
data: parsed.data,
})
return NextResponse.json(account)
}
export async function DELETE(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const existing = await prisma.account.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.csvUpload.deleteMany({ where: { accountId: id } })
await prisma.account.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createAccountSchema } from '@/lib/validations/account'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accounts = await prisma.account.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json(accounts)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const parsed = createAccountSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const account = await prisma.account.create({
data: { ...parsed.data, userId: session.user.id },
})
return NextResponse.json(account, { status: 201 })
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function POST() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const accounts = await prisma.account.findMany({
where: { userId },
select: { id: true },
})
for (const account of accounts) {
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${account.id}
`
await prisma.account.update({
where: { id: account.id },
data: { currentBalanceCents: Number(balRow.balance) },
})
}
// Remove all snapshots for accounts that now have no transactions
await prisma.$executeRaw`
DELETE FROM "BalanceSnapshot"
WHERE "accountId" IN (
SELECT id FROM "Account" WHERE "userId" = ${userId}
)
AND NOT EXISTS (
SELECT 1 FROM "Transaction" WHERE "accountId" = "BalanceSnapshot"."accountId"
)
`
return NextResponse.json({ ok: true, accounts: accounts.length })
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const rule = await prisma.budgetRule.findFirst({ where: { id, userId: session.user.id } })
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.budgetRule.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createBudgetRuleSchema } from '@/lib/validations/budget-rule'
export async function GET() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rules = await prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, budgetId: true, pattern: true },
})
return NextResponse.json(rules)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const parsed = createBudgetRuleSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
const { budgetId, pattern } = parsed.data
// Verify budget belongs to this user
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
const rule = await prisma.budgetRule.create({
data: { userId: session.user.id, budgetId, pattern },
select: { id: true, budgetId: true, pattern: true },
})
return NextResponse.json(rule, { status: 201 })
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { updateBudgetSchema } from '@/lib/validations/budget'
type Params = { params: Promise<{ id: string }> }
export async function PATCH(req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const parsed = updateBudgetSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const existing = await prisma.budget.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const budget = await prisma.budget.update({ where: { id }, data: parsed.data })
return NextResponse.json(budget)
}
export async function DELETE(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const existing = await prisma.budget.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// onDelete: SetNull in schema nulls out Transaction.budgetId automatically
await prisma.budget.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createBudgetSchema } from '@/lib/validations/budget'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const budgets = await prisma.budget.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json(budgets)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const parsed = createBudgetSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const budget = await prisma.budget.create({
data: { ...parsed.data, userId: session.user.id },
})
return NextResponse.json(budget, { status: 201 })
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds, monthLabel } from '@/lib/utils/dates'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const { start, end } = monthBounds()
const [accounts, cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
prisma.account.findMany({
where: { userId, isActive: true },
select: { id: true, name: true, type: true, currentBalanceCents: true },
orderBy: { name: 'asc' },
}),
prisma.$queryRaw<{ type: string; total: bigint }[]>`
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t.type
`,
prisma.budget.findMany({
where: { userId, isActive: true },
orderBy: { name: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.type = 'DEBIT'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.transaction.findMany({
where: { account: { userId } },
include: { account: { select: { name: true } } },
orderBy: { date: 'desc' },
take: 5,
}),
])
const bankAccounts = accounts.filter((a) => a.type === 'BANK')
const netWorthCents = bankAccounts.reduce((s, a) => s + a.currentBalanceCents, 0)
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
const creditsCents = cfMap['CREDIT'] ?? 0
const debitsCents = cfMap['DEBIT'] ?? 0
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
return NextResponse.json({
monthLabel: monthLabel(),
netWorthCents,
bankAccounts,
cashFlow: { creditsCents, debitsCents, netCents: creditsCents - debitsCents },
budgets: budgets.map((b) => ({ ...b, spendCents: spendMap.get(b.id) ?? 0 })),
recentTransactions: recentTx.map((t) => ({
id: t.id,
date: t.date.toISOString(),
description: t.description,
amountCents: t.amountCents,
type: t.type,
accountName: t.account.name,
})),
})
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`
return NextResponse.json({ status: 'ok' })
} catch {
return NextResponse.json({ status: 'error', detail: 'database unreachable' }, { status: 503 })
}
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { updateTransactionSchema } from '@/lib/validations/transaction'
type Params = { params: Promise<{ id: string }> }
export async function PATCH(req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const parsed = updateTransactionSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
// Scope check via the account's userId
const existing = await prisma.transaction.findFirst({
where: { id, account: { userId: session.user.id } },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// Validate budgetId belongs to this user if provided
if (parsed.data.budgetId) {
const budget = await prisma.budget.findFirst({
where: { id: parsed.data.budgetId, userId: session.user.id },
})
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
}
const transaction = await prisma.transaction.update({
where: { id },
data: parsed.data,
})
return NextResponse.json(transaction)
}

View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const bulkSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('delete'),
ids: z.array(z.string()).min(1),
}),
z.object({
action: z.literal('assignBudget'),
ids: z.array(z.string()).min(1),
budgetId: z.string().nullable(),
}),
z.object({
action: z.literal('addNotes'),
ids: z.array(z.string()).min(1),
notes: z.string().max(500),
}),
z.object({
action: z.literal('markTransfer'),
ids: z.array(z.string()).min(1),
}),
])
async function recomputeAccount(accountId: string) {
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
`
await prisma.account.update({
where: { id: accountId },
data: { currentBalanceCents: Number(balRow.balance) },
})
}
async function recomputeSnapshot(accountId: string, year: number, month: number) {
const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999)
const [snap] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
AND date <= ${endOfMonth}
`
const balanceCents = Number(snap.balance)
if (balanceCents === 0) {
await prisma.balanceSnapshot.deleteMany({
where: { accountId, year, month },
})
} else {
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId, year, month } },
update: { balanceCents, computedAt: new Date() },
create: { accountId, year, month, balanceCents },
})
}
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const result = bulkSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const { action, ids } = result.data
const userId = session.user.id
// Verify all transaction IDs belong to this user
const owned = await prisma.transaction.findMany({
where: { id: { in: ids }, account: { userId } },
select: { id: true, accountId: true, date: true },
})
if (owned.length !== ids.length) {
return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 })
}
if (action === 'delete') {
// Collect affected account+month combos before deleting
const accountIds = [...new Set(owned.map((t) => t.accountId))]
const monthKeys = new Map<string, { accountId: string; year: number; month: number }>()
for (const t of owned) {
const y = t.date.getFullYear()
const m = t.date.getMonth() + 1
monthKeys.set(`${t.accountId}-${y}-${m}`, { accountId: t.accountId, year: y, month: m })
}
await prisma.transaction.deleteMany({ where: { id: { in: ids } } })
// Recompute current balance and snapshots for affected accounts/months
await Promise.all(accountIds.map(recomputeAccount))
await Promise.all([...monthKeys.values()].map((k) => recomputeSnapshot(k.accountId, k.year, k.month)))
return NextResponse.json({ deleted: ids.length })
}
if (action === 'assignBudget') {
const { budgetId } = result.data
if (budgetId !== null) {
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
}
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { budgetId },
})
return NextResponse.json({ updated: ids.length })
}
if (action === 'addNotes') {
const { notes } = result.data
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { notes: notes || null },
})
return NextResponse.json({ updated: ids.length })
}
// markTransfer
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { type: 'TRANSFER' },
})
return NextResponse.json({ updated: ids.length })
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { transactionQuerySchema } from '@/lib/validations/transaction'
import { Prisma } from '@/generated/prisma/client'
export async function GET(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const parsed = transactionQuerySchema.safeParse(Object.fromEntries(searchParams))
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const { accountId, dateFrom, dateTo, type, search, budgetId, page, limit } = parsed.data
const where: Prisma.TransactionWhereInput = {
account: { userId: session.user.id },
...(accountId && { accountId }),
...(type && { type }),
...(budgetId !== undefined && { budgetId: budgetId || null }),
...(search && { description: { contains: search, mode: 'insensitive' } }),
...((dateFrom || dateTo) && {
date: {
...(dateFrom && { gte: new Date(dateFrom) }),
...(dateTo && { lte: new Date(dateTo + 'T23:59:59.999Z') }),
},
}),
}
const [transactions, total] = await prisma.$transaction([
prisma.transaction.findMany({
where,
include: {
account: { select: { name: true, type: true } },
budget: { select: { id: true, name: true, color: true } },
},
orderBy: { date: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.transaction.count({ where }),
])
return NextResponse.json({ transactions, total, page, limit, totalPages: Math.ceil(total / limit) })
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
type Params = { params: Promise<{ id: string }> }
export async function DELETE(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const rule = await prisma.transferRule.findFirst({
where: { id, userId: session.user.id },
})
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.transferRule.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const schema = z.object({
pattern: z.string().min(1).max(200).trim(),
})
export async function GET() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rules = await prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, pattern: true },
})
return NextResponse.json(rules)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const result = schema.safeParse(await req.json())
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const rule = await prisma.transferRule.create({
data: { userId: session.user.id, pattern: result.data.pattern },
select: { id: true, pattern: true },
})
return NextResponse.json(rule, { status: 201 })
}

207
src/app/api/upload/route.ts Normal file
View File

@@ -0,0 +1,207 @@
import { NextResponse } from 'next/server'
import Papa from 'papaparse'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { detectProfile } from '@/lib/csv/bank-profiles'
import { normalizeRows } from '@/lib/csv/normalizer'
import { columnMappingSchema } from '@/lib/validations/upload'
import type { NormalizerConfig } from '@/lib/csv/bank-profiles'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get('file') as File | null
const accountId = formData.get('accountId') as string | null
const columnMappingRaw = formData.get('columnMapping') as string | null
if (!file || !accountId) {
return NextResponse.json({ error: 'file and accountId are required' }, { status: 400 })
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 10 MB)' }, { status: 400 })
}
if (!file.name.toLowerCase().endsWith('.csv')) {
return NextResponse.json({ error: 'File must be a .csv file' }, { status: 400 })
}
const validMimes = ['text/csv', 'text/plain', 'application/vnd.ms-excel', 'application/csv', '']
if (file.type && !validMimes.includes(file.type)) {
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 })
}
const account = await prisma.account.findFirst({
where: { id: accountId, userId: session.user.id },
})
if (!account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
}
const content = await file.text()
// Parse all rows once — used for both detection and normalization
const parsed = Papa.parse<Record<string, string>>(content, {
header: true,
skipEmptyLines: true,
transformHeader: (h) => h.trim(),
})
const headers = (parsed.meta.fields ?? []).map((h) => h.trim())
const allRows = parsed.data
// Detect profile or use provided manual mapping
const detected = detectProfile(headers)
if (!detected && !columnMappingRaw) {
return NextResponse.json({
requiresMapping: true,
headers,
sampleRows: allRows.slice(0, 5),
})
}
let config: NormalizerConfig
if (detected && !columnMappingRaw) {
config = detected
} else {
const result = columnMappingSchema.safeParse(JSON.parse(columnMappingRaw!))
if (!result.success) {
return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
}
config = result.data
}
// For all BANK accounts using a single amount column, positive = deposit (CREDIT),
// negative = withdrawal (DEBIT). Override whatever the profile or manual mapping says.
if (account.type === 'BANK' && config.strategy === 'A') {
config = { ...config, invertAmountSign: true }
}
const normalized = normalizeRows(allRows, accountId, config)
// Apply transfer rules first, then budget rules (transfer takes priority)
const [budgetRules, transferRules] = await Promise.all([
prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true, budgetId: true },
}),
prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true },
}),
])
const rowsWithBudgets = normalized.map((row) => {
const desc = row.description.toLowerCase()
for (const rule of transferRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, type: 'TRANSFER' as const, budgetId: null }
}
}
for (const rule of budgetRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, budgetId: rule.budgetId }
}
}
return { ...row, budgetId: null }
})
const upload = await prisma.csvUpload.create({
data: {
accountId,
fileName: file.name,
rowCount: allRows.length,
importedCount: 0,
skippedCount: 0,
status: 'PENDING',
},
})
try {
const { count: importedCount } = await prisma.transaction.createMany({
data: rowsWithBudgets.map((r) => ({
accountId,
uploadId: upload.id,
date: r.date,
description: r.description,
amountCents: r.amountCents,
type: r.type,
category: r.category ?? null,
budgetId: r.budgetId,
})),
})
const skippedCount = normalized.length - importedCount
// Recompute current balance
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
`
await prisma.account.update({
where: { id: accountId },
data: { currentBalanceCents: Number(balRow.balance) },
})
// Upsert balance snapshots for each affected month
const months = [
...new Map(
rowsWithBudgets.map((r) => {
const y = r.date.getFullYear()
const m = r.date.getMonth() + 1
return [`${y}-${m}`, { year: y, month: m }]
}),
).values(),
]
for (const { year, month } of months) {
const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999)
const [snap] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
AND date <= ${endOfMonth}
`
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId, year, month } },
update: { balanceCents: Number(snap.balance), computedAt: new Date() },
create: { accountId, year, month, balanceCents: Number(snap.balance) },
})
}
const status =
importedCount === 0 ? 'FAILED'
: skippedCount > 0 ? 'PARTIAL'
: 'SUCCESS'
await prisma.csvUpload.update({
where: { id: upload.id },
data: { importedCount, skippedCount, status },
})
return NextResponse.json({
success: true,
detected: detected?.name,
importedCount,
skippedCount,
fileName: file.name,
})
} catch (err) {
await prisma.csvUpload.update({
where: { id: upload.id },
data: {
status: 'FAILED',
errorMessage: err instanceof Error ? err.message : 'Unknown error',
},
})
return NextResponse.json({ error: 'Import failed' }, { status: 500 })
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

130
src/app/globals.css Normal file
View File

@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

33
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Finance",
description: "Personal finance tracker",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function RootPage() {
redirect('/dashboard')
}

View File

@@ -0,0 +1,8 @@
import { Badge } from '@/components/ui/badge'
import { AccountType } from '@/generated/prisma/client'
export function AccountBadge({ type }: { type: AccountType }) {
if (type === 'BANK') return <Badge variant="secondary">Bank</Badge>
if (type === 'INVESTMENT') return <Badge variant="secondary" className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Investment</Badge>
return <Badge variant="outline">Credit Card</Badge>
}

View File

@@ -0,0 +1,107 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Account } from '@/generated/prisma/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye, TrendingUp } from 'lucide-react'
import { AccountBadge } from './AccountBadge'
import { CreateAccountDialog } from './CreateAccountDialog'
import { RecordValueDialog } from './RecordValueDialog'
import { formatCents } from '@/lib/utils/currency'
export function AccountCard({ account }: { account: Account }) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [recordOpen, setRecordOpen] = useState(false)
async function handleToggleActive() {
await fetch(`/api/accounts/${account.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !account.isActive }),
})
router.refresh()
}
async function handleDelete() {
if (!confirm(`Delete "${account.name}"? This cannot be undone.`)) return
await fetch(`/api/accounts/${account.id}`, { method: 'DELETE' })
router.refresh()
}
return (
<>
<Card className={account.isActive ? '' : 'opacity-60'}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="space-y-1 min-w-0">
<CardTitle className="text-base truncate">
<Link href={`/accounts/${account.id}`} className="hover:underline">
{account.name}
</Link>
</CardTitle>
{account.institution && (
<p className="text-xs text-muted-foreground truncate">{account.institution}</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0 ml-2">
<AccountBadge type={account.type} />
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Account actions</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />Edit
</DropdownMenuItem>
{account.type === 'INVESTMENT' && (
<DropdownMenuItem onClick={() => setRecordOpen(true)}>
<TrendingUp className="h-4 w-4 mr-2" />Record value
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleToggleActive}>
{account.isActive
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
: <><Eye className="h-4 w-4 mr-2" />Activate</>
}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold tabular-nums">
{formatCents(account.currentBalanceCents)}
</p>
{!account.isActive && (
<p className="text-xs text-muted-foreground mt-1">Inactive</p>
)}
</CardContent>
</Card>
<CreateAccountDialog open={editOpen} onOpenChange={setEditOpen} account={account} />
{account.type === 'INVESTMENT' && (
<RecordValueDialog
open={recordOpen}
onOpenChange={setRecordOpen}
accountId={account.id}
accountName={account.name}
/>
)}
</>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
import { useState } from 'react'
import { Account } from '@/generated/prisma/client'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { AccountCard } from './AccountCard'
import { CreateAccountDialog } from './CreateAccountDialog'
export function AccountList({ accounts }: { accounts: Account[] }) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Accounts</h1>
<p className="text-sm text-muted-foreground">
{accounts.length} account{accounts.length !== 1 ? 's' : ''}
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add account
</Button>
</div>
{accounts.length === 0 ? (
<div className="rounded-lg border border-dashed p-12 text-center">
<p className="text-muted-foreground">No accounts yet.</p>
<Button variant="outline" className="mt-4" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add your first account
</Button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{accounts.map((account) => (
<AccountCard key={account.id} account={account} />
))}
</div>
)}
<CreateAccountDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Account } from '@/generated/prisma/client'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
account?: Account
}
export function CreateAccountDialog({ open, onOpenChange, account }: Props) {
const router = useRouter()
const isEdit = !!account
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError('')
setLoading(true)
const form = new FormData(e.currentTarget)
const body = {
name: form.get('name') as string,
institution: (form.get('institution') as string) || undefined,
type: form.get('type') as string,
currency: 'USD',
}
const res = await fetch(
isEdit ? `/api/accounts/${account.id}` : '/api/accounts',
{
method: isEdit ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
if (!res.ok) {
setError('Something went wrong. Please try again.')
setLoading(false)
return
}
router.refresh()
onOpenChange(false)
setLoading(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit account' : 'Add account'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
defaultValue={account?.name}
placeholder="e.g. Checking Account"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution <span className="text-muted-foreground">(optional)</span></Label>
<Input
id="institution"
name="institution"
defaultValue={account?.institution ?? ''}
placeholder="e.g. Chase"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type">Type</Label>
<Select name="type" defaultValue={account?.type ?? 'BANK'} required>
<SelectTrigger id="type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="BANK">Bank</SelectItem>
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
<SelectItem value="INVESTMENT">Investment</SelectItem>
</SelectContent>
</Select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Saving…' : isEdit ? 'Save changes' : 'Add account'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
accountId: string
accountName: string
}
export function RecordValueDialog({ open, onOpenChange, accountId, accountName }: Props) {
const router = useRouter()
const [value, setValue] = useState('')
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const num = parseFloat(value.replace(/[$,]/g, ''))
if (isNaN(num)) { setError('Enter a valid dollar amount'); return }
setError('')
setSaving(true)
const res = await fetch(`/api/accounts/${accountId}/record-value`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ valueCents: Math.round(num * 100), date }),
})
setSaving(false)
if (!res.ok) { setError('Failed to save. Please try again.'); return }
onOpenChange(false)
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Record portfolio value {accountName}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-2">
<Label htmlFor="rv-value">Portfolio value</Label>
<Input
id="rv-value"
placeholder="e.g. 12500.00"
value={value}
onChange={(e) => setValue(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rv-date">As of date</Label>
<Input
id="rv-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,125 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye, ListFilter } from 'lucide-react'
import { BudgetProgress } from './BudgetProgress'
import { CreateBudgetDialog } from './CreateBudgetDialog'
import { BudgetRulesDialog } from './BudgetRulesDialog'
import { formatCents } from '@/lib/utils/currency'
export interface BudgetRule {
id: string
pattern: string
}
export interface BudgetWithSpend {
id: string
name: string
limitCents: number | null
color: string | null
isActive: boolean
spendCents: number
rules?: BudgetRule[]
}
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [rulesOpen, setRulesOpen] = useState(false)
async function handleToggle() {
await fetch(`/api/budgets/${budget.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !budget.isActive }),
})
router.refresh()
}
async function handleDelete() {
if (!confirm(`Delete "${budget.name}"? All linked transactions will be unassigned.`)) return
await fetch(`/api/budgets/${budget.id}`, { method: 'DELETE' })
router.refresh()
}
return (
<>
<Card className={budget.isActive ? '' : 'opacity-60'}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center gap-2 min-w-0">
{budget.color && (
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: budget.color }}
/>
)}
<CardTitle className="text-base truncate">{budget.name}</CardTitle>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors ml-1">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Budget actions</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRulesOpen(true)}>
<ListFilter className="h-4 w-4 mr-2" />Rules
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggle}>
{budget.isActive
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
: <><Eye className="h-4 w-4 mr-2" />Activate</>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline justify-between">
<span className="text-2xl font-bold tabular-nums">
{formatCents(budget.spendCents)}
</span>
{budget.limitCents ? (
<span className="text-sm text-muted-foreground">
of {formatCents(budget.limitCents)}
</span>
) : (
<span className="text-xs text-muted-foreground">no limit</span>
)}
</div>
{budget.limitCents ? (
<BudgetProgress spendCents={budget.spendCents} limitCents={budget.limitCents} />
) : (
<p className="text-xs text-muted-foreground">All time</p>
)}
{!budget.isActive && (
<p className="text-xs text-muted-foreground">Inactive</p>
)}
</CardContent>
</Card>
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} />
<BudgetRulesDialog
open={rulesOpen}
onOpenChange={setRulesOpen}
budgetId={budget.id}
budgetName={budget.name}
rules={budget.rules ?? []}
/>
</>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { BudgetCard, type BudgetWithSpend } from './BudgetCard'
import { CreateBudgetDialog } from './CreateBudgetDialog'
export function BudgetList({ budgets }: { budgets: BudgetWithSpend[] }) {
const [dialogOpen, setDialogOpen] = useState(false)
const active = budgets.filter((b) => b.isActive)
const inactive = budgets.filter((b) => !b.isActive)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Budgets</h1>
<p className="text-sm text-muted-foreground">
{active.length} active budget{active.length !== 1 ? 's' : ''}
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New budget
</Button>
</div>
{budgets.length === 0 ? (
<div className="rounded-lg border border-dashed p-12 text-center">
<p className="text-muted-foreground">No budgets yet.</p>
<Button variant="outline" className="mt-4" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create your first budget
</Button>
</div>
) : (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{active.map((b) => <BudgetCard key={b.id} budget={b} />)}
</div>
{inactive.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Inactive
</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{inactive.map((b) => <BudgetCard key={b.id} budget={b} />)}
</div>
</div>
)}
</div>
)}
<CreateBudgetDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils'
interface Props {
spendCents: number
limitCents: number
}
export function BudgetProgress({ spendCents, limitCents }: Props) {
const pct = limitCents > 0 ? (spendCents / limitCents) * 100 : 0
const barPct = Math.min(pct, 100)
const barColor =
pct >= 100 ? 'bg-red-500'
: pct >= 75 ? 'bg-yellow-500'
: 'bg-green-500'
return (
<div className="space-y-1">
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-300', barColor)}
style={{ width: `${barPct}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-right">{Math.round(pct)}%</p>
</div>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Trash2, Plus } from 'lucide-react'
interface Rule {
id: string
pattern: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
budgetId: string
budgetName: string
rules: Rule[]
}
export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, rules }: Props) {
const router = useRouter()
const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false)
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!pattern.trim()) return
setAdding(true)
await fetch('/api/budget-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ budgetId, pattern: pattern.trim() }),
})
setPattern('')
setAdding(false)
router.refresh()
}
async function handleDelete(id: string) {
await fetch(`/api/budget-rules/${id}`, { method: 'DELETE' })
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Auto-assign rules {budgetName}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Transactions whose description contains a pattern (case-insensitive) are automatically
assigned to this budget on upload. First matching rule wins.
</p>
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : (
<ul className="space-y-1.5">
{rules.map((rule) => (
<li
key={rule.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="font-mono truncate">{rule.pattern}</span>
<button
onClick={() => handleDelete(rule.id)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete rule"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<Input
placeholder="e.g. Amazon, Netflix, Starbucks"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
const PALETTE = [
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444',
'#f97316', '#eab308', '#22c55e', '#14b8a6',
'#3b82f6', '#06b6d4', '#64748b', '#78716c',
]
interface BudgetData {
id: string
name: string
limitCents: number | null
color: string | null
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
budget?: BudgetData
}
export function CreateBudgetDialog({ open, onOpenChange, budget }: Props) {
const router = useRouter()
const isEdit = !!budget
const [color, setColor] = useState(budget?.color ?? PALETTE[0])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError('')
setLoading(true)
const form = new FormData(e.currentTarget)
const name = (form.get('name') as string).trim()
const limitRaw = (form.get('limit') as string).trim()
const limitCents = limitRaw ? Math.round(parseFloat(limitRaw) * 100) : null
if (limitRaw && (isNaN(limitCents!) || limitCents! <= 0)) {
setError('Limit must be a positive number.')
setLoading(false)
return
}
const res = await fetch(
isEdit ? `/api/budgets/${budget.id}` : '/api/budgets',
{
method: isEdit ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, limitCents, color }),
},
)
if (!res.ok) {
setError('Something went wrong. Please try again.')
setLoading(false)
return
}
router.refresh()
onOpenChange(false)
setLoading(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit budget' : 'New budget'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
defaultValue={budget?.name}
placeholder="e.g. Groceries"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="limit">
Monthly limit <span className="text-muted-foreground">(optional)</span>
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">$</span>
<Input
id="limit"
name="limit"
type="number"
min="0.01"
step="0.01"
className="pl-7"
defaultValue={budget?.limitCents ? (budget.limitCents / 100).toFixed(2) : ''}
placeholder="0.00"
/>
</div>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{PALETTE.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn(
'h-7 w-7 rounded-full border-2 transition-transform hover:scale-110',
color === c ? 'border-foreground scale-110' : 'border-transparent',
)}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Saving…' : isEdit ? 'Save changes' : 'Create budget'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,61 @@
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BudgetProgress } from '@/components/budgets/BudgetProgress'
import { formatCents } from '@/lib/utils/currency'
import { ArrowRight } from 'lucide-react'
interface BudgetItem {
id: string
name: string
limitCents: number | null
color: string | null
spendCents: number
}
export function BudgetSummary({ budgets }: { budgets: BudgetItem[] }) {
return (
<Card>
<CardHeader className="pb-2 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Budgets</CardTitle>
<Link
href="/budgets"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Manage <ArrowRight className="h-3 w-3" />
</Link>
</CardHeader>
<CardContent>
{budgets.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No active budgets.</p>
) : (
<div className="space-y-4">
{budgets.map((b) => (
<div key={b.id} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
{b.color && (
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: b.color }}
/>
)}
<span className="text-sm font-medium truncate">{b.name}</span>
</div>
<span className="text-sm tabular-nums text-muted-foreground shrink-0 ml-2">
{formatCents(b.spendCents)}
{b.limitCents ? ` / ${formatCents(b.limitCents)}` : ''}
</span>
</div>
{b.limitCents ? (
<BudgetProgress spendCents={b.spendCents} limitCents={b.limitCents} />
) : (
<div className="h-1.5 rounded-full bg-muted" />
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,49 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCents } from '@/lib/utils/currency'
import { ArrowDownLeft, ArrowUpRight, TrendingUp } from 'lucide-react'
interface CashFlowCardProps {
monthLabel: string
creditsCents: number
debitsCents: number
netCents: number
}
export function CashFlowCard({ monthLabel, creditsCents, debitsCents, netCents }: CashFlowCardProps) {
const isPositive = netCents >= 0
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Cash Flow · {monthLabel}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<TrendingUp className={`h-5 w-5 shrink-0 ${isPositive ? 'text-green-500' : 'text-red-500'}`} />
<span className={`text-3xl font-bold tabular-nums ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
{isPositive ? '+' : ''}{formatCents(netCents)}
</span>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1.5 text-muted-foreground">
<ArrowDownLeft className="h-3.5 w-3.5 text-green-500" />
Income
</span>
<span className="tabular-nums font-medium text-green-600">{formatCents(creditsCents)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1.5 text-muted-foreground">
<ArrowUpRight className="h-3.5 w-3.5 text-red-500" />
Spending
</span>
<span className="tabular-nums font-medium text-red-600">{formatCents(debitsCents)}</span>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,52 @@
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function MonthYearPicker() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const now = new Date()
const currentMonth = now.getMonth() + 1
const currentYear = now.getFullYear()
const month = Number(searchParams.get('month')) || currentMonth
const year = Number(searchParams.get('year')) || currentYear
const isCurrentMonth = month === currentMonth && year === currentYear
function navigate(delta: number) {
let m = month + delta
let y = year
if (m < 1) { m = 12; y-- }
if (m > 12) { m = 1; y++ }
const params = new URLSearchParams(searchParams.toString())
params.set('month', String(m))
params.set('year', String(y))
router.push(`${pathname}?${params.toString()}`)
}
const label = new Date(year, month - 1, 1).toLocaleDateString('en-US', {
month: 'long', year: 'numeric',
})
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => navigate(-1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium min-w-[140px] text-center">{label}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate(1)}
disabled={isCurrentMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCents } from '@/lib/utils/currency'
interface BankAccount {
id: string
name: string
balanceCents: number
}
interface NetWorthCardProps {
netWorthCents: number
bankAccounts: BankAccount[]
}
export function NetWorthCard({ netWorthCents, bankAccounts }: NetWorthCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Net Worth</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-3xl font-bold tabular-nums">{formatCents(netWorthCents)}</p>
{bankAccounts.length > 0 && (
<div className="space-y-1">
{bankAccounts.map((a) => (
<div key={a.id} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground truncate">{a.name}</span>
<span className="tabular-nums font-medium ml-4 shrink-0">
{formatCents(a.balanceCents)}
</span>
</div>
))}
</div>
)}
{bankAccounts.length === 0 && (
<p className="text-sm text-muted-foreground">No bank accounts yet.</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,58 @@
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCents } from '@/lib/utils/currency'
import { ArrowRight } from 'lucide-react'
interface RecentTx {
id: string
date: string
description: string
amountCents: number
type: string
accountName: string
}
export function RecentTransactions({ transactions }: { transactions: RecentTx[] }) {
return (
<Card>
<CardHeader className="pb-2 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Recent Transactions</CardTitle>
<Link
href="/transactions"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View all <ArrowRight className="h-3 w-3" />
</Link>
</CardHeader>
<CardContent>
{transactions.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No transactions yet.</p>
) : (
<div className="space-y-3">
{transactions.map((tx) => {
const date = new Date(tx.date)
const label = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
return (
<div key={tx.id} className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.description}</p>
<p className="text-xs text-muted-foreground">
{label} · {tx.accountName}
</p>
</div>
<span
className={`text-sm font-medium tabular-nums shrink-0 ${
tx.type === 'CREDIT' ? 'text-green-600' : 'text-foreground'
}`}
>
{tx.type === 'CREDIT' ? '+' : '-'}{formatCents(tx.amountCents)}
</span>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell,
} from 'recharts'
import { formatCents, formatCentsAbbrev } from '@/lib/utils/currency'
interface BudgetBar {
name: string
spendCents: number
limitCents: number
color: string | null
}
function ChartTooltip({ active, payload, label }: {
active?: boolean
payload?: Array<{ name: string; value: number }>
label?: string
}) {
if (!active || !payload?.length) return null
return (
<div className="rounded-md border bg-card p-3 shadow-md text-sm">
<p className="font-medium mb-1">{label}</p>
{payload.map((p) => (
<p key={p.name} className="text-muted-foreground">
{p.name}: {formatCents(p.value)}
</p>
))}
</div>
)
}
export function BudgetChart({ data }: { data: BudgetBar[] }) {
if (data.length === 0) {
return (
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
No active budgets.
</div>
)
}
const chartHeight = Math.max(200, data.length * 60 + 60)
return (
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart
layout="vertical"
data={data}
margin={{ top: 4, right: 24, left: 8, bottom: 0 }}
barGap={4}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" horizontal={false} />
<XAxis
type="number"
tickFormatter={formatCentsAbbrev}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
width={90}
/>
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="spendCents" name="Spent" radius={[0, 4, 4, 0]} maxBarSize={20}>
{data.map((d, i) => (
<Cell key={i} fill={d.color ?? '#6366f1'} />
))}
</Bar>
<Bar dataKey="limitCents" name="Limit" fill="#e2e8f0" radius={[0, 4, 4, 0]} maxBarSize={20} />
</BarChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts'
import { formatCents, formatCentsAbbrev } from '@/lib/utils/currency'
interface DataPoint {
label: string
creditsCents: number
debitsCents: number
}
function ChartTooltip({ active, payload, label }: {
active?: boolean
payload?: Array<{ name: string; value: number; color: string }>
label?: string
}) {
if (!active || !payload?.length) return null
return (
<div className="rounded-md border bg-card p-3 shadow-md text-sm">
<p className="font-medium mb-1">{label}</p>
{payload.map((p) => (
<p key={p.name} style={{ color: p.color }}>
{p.name}: {formatCents(p.value)}
</p>
))}
</div>
)
}
export function CashFlowChart({ data }: { data: DataPoint[] }) {
if (data.length === 0) {
return (
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
No bank transaction data yet.
</div>
)
}
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 4, right: 16, left: 16, bottom: 0 }} barGap={4}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
<YAxis
tickFormatter={formatCentsAbbrev}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
width={64}
/>
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="creditsCents" name="Income" fill="#22c55e" radius={[4, 4, 0, 0]} maxBarSize={40} />
<Bar dataKey="debitsCents" name="Spending" fill="#ef4444" radius={[4, 4, 0, 0]} maxBarSize={40} />
</BarChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import {
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
} from 'recharts'
import { formatCents } from '@/lib/utils/currency'
interface DataPoint {
category: string
totalCents: number
}
const COLORS = [
'#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6',
'#14b8a6', '#f97316', '#ec4899', '#0ea5e9', '#84cc16',
]
function ChartTooltip({ active, payload }: {
active?: boolean
payload?: Array<{ name: string; value: number; payload: DataPoint }>
}) {
if (!active || !payload?.length) return null
const item = payload[0]
return (
<div className="rounded-md border bg-card p-3 shadow-md text-sm">
<p className="font-medium">{item.payload.category}</p>
<p className="text-muted-foreground">{formatCents(item.value)}</p>
</div>
)
}
export function CategoryBreakdownChart({ data }: { data: DataPoint[] }) {
if (data.length === 0) {
return (
<div className="flex h-[320px] items-center justify-center text-sm text-muted-foreground">
No spending this month.
</div>
)
}
return (
<ResponsiveContainer width="100%" height={320}>
<PieChart>
<Pie
data={data}
dataKey="totalCents"
nameKey="category"
cx="50%"
cy="45%"
innerRadius={70}
outerRadius={110}
paddingAngle={2}
>
{data.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip content={<ChartTooltip />} />
<Legend
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12 }}
formatter={(value) => <span className="text-foreground">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import { formatCents, formatCentsAbbrev } from '@/lib/utils/currency'
interface DataPoint {
label: string
totalCents: number
}
function ChartTooltip({ active, payload, label }: {
active?: boolean
payload?: Array<{ value: number }>
label?: string
}) {
if (!active || !payload?.length) return null
return (
<div className="rounded-md border bg-card p-3 shadow-md text-sm">
<p className="font-medium mb-1">{label}</p>
<p className="text-foreground">Total: {formatCents(payload[0].value)}</p>
</div>
)
}
export function MonthlySpendingChart({ data }: { data: DataPoint[] }) {
if (data.length === 0) {
return (
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
No spending data yet.
</div>
)
}
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
<YAxis
tickFormatter={formatCentsAbbrev}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
width={64}
/>
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="totalCents" name="Spending" fill="#8b5cf6" radius={[4, 4, 0, 0]} maxBarSize={48} />
</BarChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import { formatCents, formatCentsAbbrev } from '@/lib/utils/currency'
interface DataPoint {
label: string
totalCents: number
}
function ChartTooltip({ active, payload, label }: {
active?: boolean
payload?: Array<{ value: number }>
label?: string
}) {
if (!active || !payload?.length) return null
return (
<div className="rounded-md border bg-card p-3 shadow-md text-sm">
<p className="font-medium mb-1">{label}</p>
<p className="text-primary">{formatCents(payload[0].value)}</p>
</div>
)
}
export function NetWorthTrendChart({ data }: { data: DataPoint[] }) {
if (data.length === 0) {
return (
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
No snapshot data yet upload transactions to populate this chart.
</div>
)
}
return (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
<defs>
<linearGradient id="netWorthGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
<YAxis
tickFormatter={formatCentsAbbrev}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
width={64}
/>
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone"
dataKey="totalCents"
name="Net Worth"
stroke="#6366f1"
strokeWidth={2}
fill="url(#netWorthGradient)"
dot={false}
activeDot={{ r: 4 }}
/>
</AreaChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import {
LayoutDashboard, CreditCard, ArrowLeftRight,
Upload, PiggyBank, TrendingUp, LogOut,
} from 'lucide-react'
import { Separator } from '@/components/ui/separator'
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/accounts', label: 'Accounts', icon: CreditCard },
{ href: '/transactions', label: 'Transactions', icon: ArrowLeftRight },
{ href: '/upload', label: 'Upload', icon: Upload },
{ href: '/budgets', label: 'Budgets', icon: PiggyBank },
{ href: '/graphs', label: 'Graphs', icon: TrendingUp },
]
export function Sidebar() {
const pathname = usePathname()
return (
<aside className="flex w-56 shrink-0 flex-col border-r bg-card">
<div className="flex h-14 items-center px-4">
<span className="font-semibold text-lg">Finance</span>
</div>
<Separator />
<nav className="flex-1 space-y-1 p-2">
{navItems.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
pathname.startsWith(href)
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
))}
</nav>
<Separator />
<div className="p-2">
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LogOut className="h-4 w-4 shrink-0" />
Sign out
</button>
</div>
</aside>
)
}

View File

@@ -0,0 +1,154 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { formatCents } from '@/lib/utils/currency'
import type { TransactionRow } from './TransactionTable'
interface BudgetOption { id: string; name: string; color: string | null }
interface Props {
transaction: TransactionRow | null
onOpenChange: (open: boolean) => void
budgets: BudgetOption[]
}
export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Props) {
const router = useRouter()
const [category, setCategory] = useState('')
const [notes, setNotes] = useState('')
const [budgetId, setBudgetId] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (transaction) {
setCategory(transaction.category ?? '')
setNotes(transaction.notes ?? '')
setBudgetId(transaction.budgetId ?? '')
setError('')
}
}, [transaction])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!transaction) return
setLoading(true)
setError('')
const res = await fetch(`/api/transactions/${transaction.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
category: category.trim() || null,
notes: notes.trim() || null,
budgetId: budgetId || null,
}),
})
if (!res.ok) {
setError('Failed to save. Please try again.')
setLoading(false)
return
}
router.refresh()
onOpenChange(false)
setLoading(false)
}
if (!transaction) return null
const date = new Date(transaction.date).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
})
return (
<Dialog open={!!transaction} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-semibold leading-tight">
{transaction.description}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : transaction.type === 'TRANSFER' ? 'text-muted-foreground' : ''}>
{transaction.type === 'TRANSFER' ? '⇄ ' : transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
</span>
</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Input
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g. Groceries"
/>
</div>
<div className="space-y-2">
<Label htmlFor="budget">Budget</Label>
<Select value={budgetId} onValueChange={(v) => setBudgetId(v ?? '')}>
<SelectTrigger id="budget">
<SelectValue placeholder="No budget">
{budgetId
? (budgets.find((b) => b.id === budgetId)?.name ?? 'No budget')
: 'No budget'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="">No budget</SelectItem>
{budgets.map((b) => (
<SelectItem key={b.id} value={b.id}>
<span className="flex items-center gap-2">
{b.color && (
<span
className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: b.color }}
/>
)}
{b.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional notes…"
rows={3}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Saving…' : 'Save'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,122 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { X } from 'lucide-react'
interface AccountOption { id: string; name: string }
export function TransactionFilters({ accounts }: { accounts: AccountOption[] }) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const sp = (key: string) => searchParams.get(key) ?? ''
const [search, setSearch] = useState(sp('search'))
const searchParamsRef = useRef(searchParams)
useEffect(() => { searchParamsRef.current = searchParams }, [searchParams])
const push = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParamsRef.current.toString())
for (const [k, v] of Object.entries(updates)) {
if (v) params.set(k, v); else params.delete(k)
}
params.delete('page')
router.replace(`${pathname}?${params.toString()}`)
},
[pathname, router],
)
// Debounce search → URL
useEffect(() => {
const t = setTimeout(() => push({ search }), 400)
return () => clearTimeout(t)
}, [search, push])
function reset() {
setSearch('')
router.replace(pathname)
}
const hasFilters = !!(sp('accountId') || sp('dateFrom') || sp('dateTo') || sp('type') || sp('search'))
return (
<div className="flex flex-wrap items-end gap-3 pb-4">
<div className="space-y-1">
<Label className="text-xs">Account</Label>
<Select value={sp('accountId')} onValueChange={(v) => push({ accountId: v ?? '' })}>
<SelectTrigger className="h-8 w-44 text-sm">
<SelectValue placeholder="All accounts">
{accounts.find((a) => a.id === sp('accountId'))?.name ?? 'All accounts'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="">All accounts</SelectItem>
{accounts.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">From</Label>
<Input
type="date"
className="h-8 w-36 text-sm"
value={sp('dateFrom')}
onChange={(e) => push({ dateFrom: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">To</Label>
<Input
type="date"
className="h-8 w-36 text-sm"
value={sp('dateTo')}
onChange={(e) => push({ dateTo: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Type</Label>
<Select value={sp('type')} onValueChange={(v) => push({ type: v ?? '' })}>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All types</SelectItem>
<SelectItem value="DEBIT">Debit</SelectItem>
<SelectItem value="CREDIT">Credit</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 min-w-40 space-y-1">
<Label className="text-xs">Search</Label>
<Input
className="h-8 text-sm"
placeholder="Search descriptions…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{hasFilters && (
<Button variant="ghost" size="sm" className="h-8" onClick={reset}>
<X className="h-3.5 w-3.5 mr-1" />
Reset
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,416 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X, ArrowLeftRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { formatCents } from '@/lib/utils/currency'
import { EditTransactionDialog } from './EditTransactionDialog'
import { cn } from '@/lib/utils'
export type TransactionRow = {
id: string
date: string
description: string
amountCents: number
type: string
category: string | null
notes: string | null
accountId: string
budgetId: string | null
account: { name: string; type: string }
budget: { id: string; name: string; color: string | null } | null
}
type BudgetOption = { id: string; name: string; color: string | null }
interface Props {
transactions: TransactionRow[]
total: number
page: number
limit: number
showAccount?: boolean
budgets: BudgetOption[]
}
export function TransactionTable({
transactions,
total,
page,
limit,
showAccount = true,
budgets,
}: Props) {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const [editing, setEditing] = useState<TransactionRow | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | 'transfer' | null>(null)
const [budgetTarget, setBudgetTarget] = useState<string>('__none__')
const [notesValue, setNotesValue] = useState('')
const [working, setWorking] = useState(false)
const totalPages = Math.ceil(total / limit)
const allIds = transactions.map((t) => t.id)
const allSelected = allIds.length > 0 && allIds.every((id) => selected.has(id))
const someSelected = selected.size > 0
function toggleAll() {
if (allSelected) {
setSelected((prev) => {
const next = new Set(prev)
allIds.forEach((id) => next.delete(id))
return next
})
} else {
setSelected((prev) => new Set([...prev, ...allIds]))
}
}
function toggleOne(id: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function clearSelection() {
setSelected(new Set())
}
function pageHref(p: number) {
const params = new URLSearchParams(searchParams.toString())
params.set('page', String(p))
return `${pathname}?${params.toString()}`
}
async function bulkPost(body: object) {
setWorking(true)
const res = await fetch('/api/transactions/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
setWorking(false)
if (res.ok) {
clearSelection()
setBulkDialog(null)
router.refresh()
}
}
async function handleDelete() {
await bulkPost({ action: 'delete', ids: [...selected] })
}
async function handleAssignBudget() {
await bulkPost({
action: 'assignBudget',
ids: [...selected],
budgetId: budgetTarget === '__none__' ? null : budgetTarget,
})
}
async function handleAddNotes() {
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
}
async function handleMarkTransfer() {
await bulkPost({ action: 'markTransfer', ids: [...selected] })
}
if (transactions.length === 0 && !someSelected) {
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
No transactions found.
</div>
)
}
const colSpan = showAccount ? 8 : 7
return (
<>
{/* Bulk action bar */}
{someSelected && (
<div className="flex items-center gap-3 rounded-md border bg-muted/50 px-4 py-2 mb-2">
<span className="text-sm font-medium">{selected.size} selected</span>
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="outline"
onClick={() => { setNotesValue(''); setBulkDialog('notes') }}
>
<StickyNote className="h-3.5 w-3.5 mr-1.5" />Notes
</Button>
<Button
size="sm"
variant="outline"
onClick={() => { setBudgetTarget('__none__'); setBulkDialog('budget') }}
>
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setBulkDialog('transfer')}
>
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />Transfer
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setBulkDialog('delete')}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />Delete
</Button>
<Button size="sm" variant="ghost" onClick={clearSelection}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8 px-3">
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={allSelected}
onChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-28">Date</TableHead>
<TableHead>Description</TableHead>
{showAccount && <TableHead className="w-36">Account</TableHead>}
<TableHead className="w-32 text-right">Amount</TableHead>
<TableHead className="w-32">Category</TableHead>
<TableHead className="w-32">Budget</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((tx) => {
const isCredit = tx.type === 'CREDIT'
const isTransfer = tx.type === 'TRANSFER'
const isSelected = selected.has(tx.id)
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
})
return (
<TableRow key={tx.id} className={isSelected ? 'bg-muted/40' : undefined}>
<TableCell className="px-3">
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={isSelected}
onChange={() => toggleOne(tx.id)}
aria-label="Select transaction"
/>
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{dateStr}
</TableCell>
<TableCell className="max-w-xs">
<div className="truncate text-sm">{tx.description}</div>
{tx.notes && (
<div className="truncate text-xs text-muted-foreground">{tx.notes}</div>
)}
</TableCell>
{showAccount && (
<TableCell className="text-sm text-muted-foreground">
{tx.account.name}
</TableCell>
)}
<TableCell className="text-right tabular-nums font-medium">
<span className={cn('text-sm', isTransfer ? 'text-muted-foreground' : isCredit ? 'text-green-600' : '')}>
{isTransfer ? '⇄ ' : isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{tx.category ?? '—'}
</TableCell>
<TableCell>
{tx.budget ? (
<span className="flex items-center gap-1.5 text-sm">
{tx.budget.color && (
<span
className="inline-block h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: tx.budget.color }}
/>
)}
{tx.budget.name}
</span>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<button
onClick={() => setEditing(tx)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
<span className="sr-only">Edit</span>
</button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between py-3">
<p className="text-sm text-muted-foreground">
{total.toLocaleString()} transaction{total !== 1 ? 's' : ''}
{totalPages > 1 && ` · page ${page} of ${totalPages}`}
</p>
{totalPages > 1 && (
<div className="flex items-center gap-1">
{page > 1 ? (
<Link
href={pageHref(page - 1)}
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-accent transition-colors"
>
<ChevronLeft className="h-4 w-4" />Prev
</Link>
) : (
<span className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm font-medium opacity-50 cursor-not-allowed">
<ChevronLeft className="h-4 w-4" />Prev
</span>
)}
{page < totalPages ? (
<Link
href={pageHref(page + 1)}
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-accent transition-colors"
>
Next<ChevronRight className="h-4 w-4" />
</Link>
) : (
<span className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm font-medium opacity-50 cursor-not-allowed">
Next<ChevronRight className="h-4 w-4" />
</span>
)}
</div>
)}
</div>
<EditTransactionDialog
transaction={editing}
onOpenChange={(open) => { if (!open) setEditing(null) }}
budgets={budgets}
/>
{/* Delete confirm dialog */}
<Dialog open={bulkDialog === 'delete'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete {selected.size} transaction{selected.size !== 1 ? 's' : ''}?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">This cannot be undone.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDelete} disabled={working}>
{working ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Assign budget dialog */}
<Dialog open={bulkDialog === 'budget'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Assign budget</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}.
</p>
<Select value={budgetTarget} onValueChange={(v) => setBudgetTarget(v ?? '__none__')}>
<SelectTrigger>
<SelectValue>
{budgetTarget === '__none__'
? 'No budget'
: (budgets.find((b) => b.id === budgetTarget)?.name ?? 'No budget')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No budget</SelectItem>
{budgets.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button onClick={handleAssignBudget} disabled={working}>
{working ? 'Saving…' : 'Apply'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Mark as transfer dialog */}
<Dialog open={bulkDialog === 'transfer'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Mark as transfer?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{selected.size} transaction{selected.size !== 1 ? 's' : ''} will be marked as transfers
and excluded from cash flow calculations.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button onClick={handleMarkTransfer} disabled={working}>
{working ? 'Saving…' : 'Mark as transfer'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add notes dialog */}
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Set notes</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}. Leave blank to clear.
</p>
<Textarea
placeholder="Add a note…"
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
maxLength={500}
rows={3}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button onClick={handleAddNotes} disabled={working}>
{working ? 'Saving…' : 'Apply'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ArrowLeftRight } from 'lucide-react'
import { TransferRulesDialog } from './TransferRulesDialog'
interface Rule {
id: string
pattern: string
}
export function TransferRulesButton({ rules }: { rules: Rule[] }) {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<ArrowLeftRight className="h-4 w-4 mr-2" />
Transfer rules
</Button>
<TransferRulesDialog open={open} onOpenChange={setOpen} rules={rules} />
</>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Trash2, Plus } from 'lucide-react'
interface Rule {
id: string
pattern: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
rules: Rule[]
}
export function TransferRulesDialog({ open, onOpenChange, rules }: Props) {
const router = useRouter()
const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false)
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!pattern.trim()) return
setAdding(true)
await fetch('/api/transfer-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim() }),
})
setPattern('')
setAdding(false)
router.refresh()
}
async function handleDelete(id: string) {
await fetch(`/api/transfer-rules/${id}`, { method: 'DELETE' })
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Transfer rules</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Transactions whose description matches a pattern (case-insensitive) are automatically
marked as transfers on upload and excluded from cash flow.
</p>
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : (
<ul className="space-y-1.5">
{rules.map((rule) => (
<li
key={rule.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="font-mono truncate">{rule.pattern}</span>
<button
onClick={() => handleDelete(rule.id)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete rule"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<Input
placeholder="e.g. Fidelity Transfer, Schwab"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
src/components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,146 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import type { ColumnMapping } from '@/lib/validations/upload'
interface Props {
headers: string[]
sampleRows: Record<string, string>[]
onSubmit: (mapping: ColumnMapping) => void
loading: boolean
}
export function ColumnMapper({ headers, sampleRows, onSubmit, loading }: Props) {
const [strategy, setStrategy] = useState<'A' | 'B'>('A')
const [dateCol, setDateCol] = useState('')
const [descCol, setDescCol] = useState('')
const [amountCol, setAmountCol] = useState('')
const [invertSign, setInvertSign] = useState(false)
const [debitCol, setDebitCol] = useState('')
const [creditCol, setCreditCol] = useState('')
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const mapping: ColumnMapping =
strategy === 'A'
? { strategy, dateColumn: dateCol, descriptionColumn: descCol, amountColumn: amountCol, invertAmountSign: invertSign }
: { strategy, dateColumn: dateCol, descriptionColumn: descCol, debitColumn: debitCol, creditColumn: creditCol }
onSubmit(mapping)
}
const canSubmit = dateCol && descCol && (strategy === 'A' ? amountCol : debitCol && creditCol)
return (
<div className="space-y-6">
<div>
<h2 className="font-semibold">Map columns</h2>
<p className="text-sm text-muted-foreground mt-1">
We couldn&apos;t auto-detect the bank format. Map your CSV columns to the required fields.
</p>
</div>
{/* Sample rows preview */}
{sampleRows.length > 0 && (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{headers.map((h) => <TableHead key={h}>{h}</TableHead>)}
</TableRow>
</TableHeader>
<TableBody>
{sampleRows.map((row, i) => (
<TableRow key={i}>
{headers.map((h) => <TableCell key={h} className="text-xs">{row[h] ?? ''}</TableCell>)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Date column</Label>
<Select value={dateCol} onValueChange={(v) => setDateCol(v ?? '')} required>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>{headers.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description column</Label>
<Select value={descCol} onValueChange={(v) => setDescCol(v ?? '')} required>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>{headers.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Amount format</Label>
<Select value={strategy} onValueChange={(v) => setStrategy(v as 'A' | 'B')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="A">Single amount column</SelectItem>
<SelectItem value="B">Separate debit and credit columns</SelectItem>
</SelectContent>
</Select>
</div>
{strategy === 'A' && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Amount column</Label>
<Select value={amountCol} onValueChange={(v) => setAmountCol(v ?? '')} required>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>{headers.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Sign convention</Label>
<Select value={invertSign ? 'invert' : 'normal'} onValueChange={(v) => setInvertSign(v === 'invert')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal">Positive = spending (debit)</SelectItem>
<SelectItem value="invert">Negative = spending (debit)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{strategy === 'B' && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Debit column</Label>
<Select value={debitCol} onValueChange={(v) => setDebitCol(v ?? '')} required>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>{headers.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Credit column</Label>
<Select value={creditCol} onValueChange={(v) => setCreditCol(v ?? '')} required>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>{headers.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
)}
<Button type="submit" disabled={!canSubmit || loading}>
{loading ? 'Importing…' : 'Import'}
</Button>
</form>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { useRef, useState } from 'react'
import { Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Props {
onFile: (file: File) => void
disabled?: boolean
}
export function UploadDropzone({ onFile, disabled }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
if (disabled) return
const file = e.dataTransfer.files[0]
if (file) onFile(file)
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) onFile(file)
e.target.value = ''
}
return (
<div
onClick={() => !disabled && inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={cn(
'flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-12 transition-colors',
dragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50',
disabled && 'pointer-events-none opacity-50',
)}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">Drop a CSV file here or click to browse</p>
<p className="text-xs text-muted-foreground mt-1">Max 10 MB · .csv files only</p>
</div>
<input
ref={inputRef}
type="file"
accept=".csv"
className="hidden"
onChange={handleChange}
disabled={disabled}
/>
</div>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertCircle } from 'lucide-react'
import { UploadDropzone } from './UploadDropzone'
import { ColumnMapper } from './ColumnMapper'
import { UploadPreview } from './UploadPreview'
import type { ColumnMapping } from '@/lib/validations/upload'
interface AccountOption {
id: string
name: string
}
interface Props {
accounts: AccountOption[]
}
type Stage =
| { status: 'idle' }
| { status: 'uploading' }
| { status: 'mapping'; headers: string[]; sampleRows: Record<string, string>[]; file: File }
| { status: 'result'; fileName: string; detected?: string; importedCount: number; skippedCount: number }
| { status: 'error'; message: string }
export function UploadForm({ accounts }: Props) {
const [accountId, setAccountId] = useState(accounts[0]?.id ?? '')
const [stage, setStage] = useState<Stage>({ status: 'idle' })
async function submit(file: File, columnMapping?: ColumnMapping) {
if (!accountId) return
setStage({ status: 'uploading' })
const formData = new FormData()
formData.append('file', file)
formData.append('accountId', accountId)
if (columnMapping) formData.append('columnMapping', JSON.stringify(columnMapping))
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData })
const data = await res.json()
if (!res.ok) {
setStage({ status: 'error', message: data.error ?? 'Upload failed' })
return
}
if (data.requiresMapping) {
setStage({ status: 'mapping', headers: data.headers, sampleRows: data.sampleRows, file })
return
}
setStage({
status: 'result',
fileName: data.fileName,
detected: data.detected,
importedCount: data.importedCount,
skippedCount: data.skippedCount,
})
} catch {
setStage({ status: 'error', message: 'Network error. Please try again.' })
}
}
function handleFile(file: File) {
submit(file)
}
function handleMapping(mapping: ColumnMapping) {
if (stage.status === 'mapping') {
submit(stage.file, mapping)
}
}
if (stage.status === 'result') {
return (
<UploadPreview
{...stage}
onReset={() => setStage({ status: 'idle' })}
/>
)
}
return (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="account">Account</Label>
{accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">
No active accounts found. Create one in Accounts first.
</p>
) : (
<Select value={accountId} onValueChange={(v) => setAccountId(v ?? '')}>
<SelectTrigger id="account" className="w-72">
<SelectValue placeholder="Select account">
{accounts.find((a) => a.id === accountId)?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accounts.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{stage.status === 'error' && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{stage.message}</AlertDescription>
</Alert>
)}
{stage.status === 'mapping' ? (
<ColumnMapper
headers={stage.headers}
sampleRows={stage.sampleRows}
onSubmit={handleMapping}
loading={false}
/>
) : (
<UploadDropzone
onFile={handleFile}
disabled={!accountId || stage.status === 'uploading'}
/>
)}
{stage.status === 'uploading' && (
<p className="text-sm text-muted-foreground text-center">Importing</p>
)}
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { CheckCircle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
interface Props {
fileName: string
detected?: string
importedCount: number
skippedCount: number
onReset: () => void
}
export function UploadPreview({ fileName, detected, importedCount, skippedCount, onReset }: Props) {
return (
<div className="space-y-4">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertTitle>Import complete {fileName}</AlertTitle>
<AlertDescription className="mt-1 space-y-1">
{detected && <p>Detected: <span className="font-medium">{detected}</span></p>}
<p>{importedCount} transaction{importedCount !== 1 ? 's' : ''} imported</p>
{skippedCount > 0 && (
<p className="text-muted-foreground">{skippedCount} duplicate{skippedCount !== 1 ? 's' : ''} skipped</p>
)}
</AlertDescription>
</Alert>
<Button variant="outline" onClick={onReset}>Upload another file</Button>
</div>
)
}

21
src/lib/auth.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NextAuthConfig } from 'next-auth'
// Edge-compatible config — no Node.js-only imports (no Prisma, no bcrypt).
// Used by middleware for JWT verification. Providers are added in auth.ts.
export const authConfig = {
session: { strategy: 'jwt' as const, maxAge: 60 * 60 },
secret: process.env.NEXTAUTH_SECRET,
trustHost: true,
pages: { signIn: '/login' },
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id
return token
},
session({ session, token }) {
if (token.id) session.user.id = token.id as string
return session
},
},
providers: [],
} satisfies NextAuthConfig

37
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,37 @@
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { authConfig } from '@/lib/auth.config'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials)
if (!parsed.success) return null
const { email, password } = parsed.data
const user = await prisma.user.findUnique({ where: { email } })
if (!user) return null
const valid = await bcrypt.compare(password, user.passwordHash)
if (!valid) return null
return { id: user.id, email: user.email }
},
}),
],
})

View File

@@ -0,0 +1,79 @@
export type ParseStrategy = 'A' | 'B'
export interface NormalizerConfig {
strategy: ParseStrategy
dateColumn: string
descriptionColumn: string
categoryColumn?: string
// Strategy A
amountColumn?: string
invertAmountSign?: boolean
// Strategy B
debitColumn?: string
creditColumn?: string
}
export interface BankProfile extends NormalizerConfig {
id: string
name: string
accountType: 'BANK' | 'CREDIT_CARD'
detectColumns: string[]
}
export const bankProfiles: BankProfile[] = [
{
id: 'discover-savings',
name: 'Discover High Yield Savings',
accountType: 'BANK',
strategy: 'B',
dateColumn: 'Transaction Date',
descriptionColumn: 'Transaction Description',
debitColumn: 'Debit',
creditColumn: 'Credit',
detectColumns: ['Transaction Date', 'Transaction Description', 'Debit', 'Credit', 'Balance'],
},
{
id: 'discover-cc',
name: 'Discover Credit Card',
accountType: 'CREDIT_CARD',
strategy: 'A',
dateColumn: 'Trans. Date',
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: true, // negative = DEBIT (charge), positive = CREDIT (payment/refund)
categoryColumn: 'Category',
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
},
{
id: 'huntington-checking',
name: 'Huntington Checking',
accountType: 'BANK',
strategy: 'A',
dateColumn: 'Date',
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: true, // negative = DEBIT (withdrawal), positive = CREDIT (deposit)
detectColumns: ['Date', 'Description', 'Amount', 'Split', 'Tags'],
},
{
id: 'fidelity',
name: 'Fidelity',
accountType: 'BANK',
strategy: 'A',
dateColumn: 'Run Date',
descriptionColumn: 'Description',
amountColumn: 'Amount($)',
invertAmountSign: true, // negative = DEBIT (purchase/withdrawal), positive = CREDIT (deposit/dividend)
detectColumns: ['Run Date', 'Description', 'Amount($)'],
},
]
export function detectProfile(headers: string[]): BankProfile | null {
const headerSet = new Set(headers.map((h) => h.trim()))
for (const profile of bankProfiles) {
if (profile.detectColumns.every((col) => headerSet.has(col))) {
return profile
}
}
return null
}

96
src/lib/csv/normalizer.ts Normal file
View File

@@ -0,0 +1,96 @@
import type { NormalizerConfig } from './bank-profiles'
export interface NormalizedRow {
date: Date
description: string
amountCents: number
type: 'DEBIT' | 'CREDIT'
category?: string
}
export function parseCents(raw: string): number {
const cleaned = raw.replace(/[$,\s]/g, '')
if (!cleaned || cleaned === '-') return 0
return Math.round(parseFloat(cleaned) * 100)
}
function parseDate(raw: string): Date {
const trimmed = raw.trim()
// MM/DD/YYYY
if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(trimmed)) {
const [m, d, y] = trimmed.split('/')
return new Date(parseInt(y), parseInt(m) - 1, parseInt(d))
}
const date = new Date(trimmed)
if (!isNaN(date.getTime())) return date
throw new Error(`Cannot parse date: ${raw}`)
}
function strategyA(
raw: string,
invertAmountSign: boolean,
): { amountCents: number; type: 'DEBIT' | 'CREDIT' } {
const cents = parseCents(raw)
const isPositive = cents >= 0
// invertAmountSign=false: positive=DEBIT (e.g. Discover CC)
// invertAmountSign=true: positive=CREDIT, negative=DEBIT (e.g. Huntington, Fidelity)
const type = invertAmountSign
? isPositive ? 'CREDIT' : 'DEBIT'
: isPositive ? 'DEBIT' : 'CREDIT'
return { amountCents: Math.abs(cents), type }
}
function strategyB(
debitRaw: string,
creditRaw: string,
): { amountCents: number; type: 'DEBIT' | 'CREDIT' } {
const debit = parseCents(debitRaw)
if (debit > 0) return { amountCents: debit, type: 'DEBIT' }
return { amountCents: Math.abs(parseCents(creditRaw)), type: 'CREDIT' }
}
export function normalizeRows(
rows: Record<string, string>[],
accountId: string,
config: NormalizerConfig,
): NormalizedRow[] {
const out: NormalizedRow[] = []
for (const row of rows) {
try {
const date = parseDate(row[config.dateColumn] ?? '')
const description = (row[config.descriptionColumn] ?? '').trim()
if (!description) continue
let amountCents: number
let type: 'DEBIT' | 'CREDIT'
if (config.strategy === 'A' && config.amountColumn) {
const r = strategyA(row[config.amountColumn] ?? '0', config.invertAmountSign ?? false)
amountCents = r.amountCents
type = r.type
} else if (config.strategy === 'B' && config.debitColumn && config.creditColumn) {
const r = strategyB(row[config.debitColumn] ?? '', row[config.creditColumn] ?? '')
amountCents = r.amountCents
type = r.type
} else {
continue
}
if (amountCents === 0) continue
const rawCategory = config.categoryColumn ? (row[config.categoryColumn] ?? '').trim() : ''
out.push({
date,
description,
amountCents,
type,
category: rawCategory || undefined,
})
} catch {
// skip unparseable rows
}
}
return out
}

26
src/lib/csv/parser.ts Normal file
View File

@@ -0,0 +1,26 @@
import Papa from 'papaparse'
import { detectProfile, BankProfile } from './bank-profiles'
export type ParsedRow = Record<string, string>
export type ParseResult =
| { detected: BankProfile; headers: string[]; rows: ParsedRow[] }
| { requiresMapping: true; headers: string[]; sampleRows: ParsedRow[] }
export function parseCsvContent(content: string): ParseResult {
const result = Papa.parse<ParsedRow>(content, {
header: true,
skipEmptyLines: true,
transformHeader: (h) => h.trim(),
})
const headers = (result.meta.fields ?? []).map((h) => h.trim())
const rows = result.data
const detected = detectProfile(headers)
if (detected) {
return { detected, headers, rows }
}
return { requiresMapping: true, headers, sampleRows: rows.slice(0, 5) }
}

15
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Pool } from 'pg'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@/generated/prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
function createPrismaClient() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter })
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Some files were not shown because too many files have changed in this diff Show More