diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e3a1995 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..763d785 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Database +DATABASE_URL="postgresql://financeapp:password@localhost:5432/financeapp" + +# NextAuth +NEXTAUTH_SECRET="" +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" diff --git a/.gitignore b/.gitignore index 5ef6a52..8496195 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,9 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files .env* +!.env.example # vercel .vercel @@ -39,3 +40,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..a47bf16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,356 @@ -@AGENTS.md +# 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 1–6 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 // 1–12 + 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="" +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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e87f37d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index e215bc4..3c18a4c 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,120 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Finance App -## Getting Started +Personal finance web app for tracking bank transactions, monitoring net worth, and visualizing spending. Self-hosted via Docker Compose — no cloud services required. -First, run the development server: +## Features -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +- 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 ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +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 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +```sh +# 2. Build and start the stack +docker compose up --build -d -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +# 3. First-run only: apply the schema and create your user +docker compose exec app npx prisma db push +docker compose exec app npx prisma db seed -## Learn More +# 4. Open the app +open http://localhost:3000 +``` -To learn more about Next.js, take a look at the following resources: +Sign in with the `SEED_EMAIL` / `SEED_PASSWORD` you set. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Environment variables -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +| 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 | -## Deploy on Vercel +## Useful commands -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +```sh +# View logs +docker compose logs -f app -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +# Stop the stack +docker compose down + +# Stop and wipe the database volume +docker compose down -v + +# Re-seed after a wipe +docker compose exec app npx prisma db push +docker compose exec app npx prisma db seed + +# 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 | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4db5dc8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + db: + image: postgres:16-alpine + 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: . + ports: + - "127.0.0.1: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 + +volumes: + postgres_data: diff --git a/next.config.ts b/next.config.ts index e9ffa30..02ad140 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,32 @@ -import type { NextConfig } from "next"; +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 = { - /* config options here */ -}; + output: 'standalone', + async headers() { + return [{ source: '/(.*)', headers: securityHeaders }] + }, +} -export default nextConfig; +export default nextConfig diff --git a/package-lock.json b/package-lock.json index 22de079..031d628 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { - "name": "finance-app-scaffold", + "name": "finance-app", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "finance-app-scaffold", + "name": "finance-app", "version": "0.1.0", "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", @@ -17,6 +18,7 @@ "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", @@ -30,12 +32,14 @@ "@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" } }, @@ -829,6 +833,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2046,6 +2492,18 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.7.0.tgz", + "integrity": "sha512-q33Ta8sKbgzEpAy0lx45tAq//yMv0qcb+8nj+TCA3P4wiAY+OBFEFk/NDkZncAfHaNJeGo5WJpJdpbL+ijYx8g==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.7.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", @@ -2093,7 +2551,6 @@ "version": "7.7.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz", "integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -2122,6 +2579,15 @@ "zeptomatch": "2.1.0" } }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.7.0.tgz", + "integrity": "sha512-gZXREeu6mOk7zXfGFJgh86p7Vhj0sXNKp+4Cg1tWYo7V2dfncP2qxS2BiTmbIIha8xPqItkl0WSw38RuSq1HoQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.7.0" + } + }, "node_modules/@prisma/engines": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz", @@ -2956,6 +3422,17 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -5272,6 +5749,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6170,6 +6689,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8868,6 +9402,104 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8972,6 +9604,45 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -10045,6 +10716,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -10574,6 +11254,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -11153,6 +11853,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 70d94be..8d4eebe 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "finance-app-scaffold", + "name": "finance-app", "version": "0.1.0", "private": true, "scripts": { @@ -10,6 +10,7 @@ }, "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", @@ -18,6 +19,7 @@ "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", @@ -26,17 +28,22 @@ "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" } } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..831a20f --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// 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", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..648600f --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,113 @@ +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[] +} + +enum AccountType { + BANK + CREDIT_CARD +} + +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 +} + +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? + dedupeHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([dedupeHash]) + @@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[] + + @@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 + 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]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..15f1c0b --- /dev/null +++ b/prisma/seed.ts @@ -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()) diff --git a/src/app/(app)/accounts/[id]/page.tsx b/src/app/(app)/accounts/[id]/page.tsx new file mode 100644 index 0000000..c6df8aa --- /dev/null +++ b/src/app/(app)/accounts/[id]/page.tsx @@ -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> +} + +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 ( +
+
+
+
+

{account.name}

+ +
+ {account.institution && ( +

{account.institution}

+ )} +
+
+

+ {formatCents(account.currentBalanceCents)} +

+

+ {account.isActive ? 'Active' : 'Inactive'} +

+
+
+ + +
+ ) +} diff --git a/src/app/(app)/accounts/page.tsx b/src/app/(app)/accounts/page.tsx new file mode 100644 index 0000000..2d0bfbf --- /dev/null +++ b/src/app/(app)/accounts/page.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/src/app/(app)/budgets/page.tsx b/src/app/(app)/budgets/page.tsx new file mode 100644 index 0000000..ffe97a7 --- /dev/null +++ b/src/app/(app)/budgets/page.tsx @@ -0,0 +1,47 @@ +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { BudgetList } from '@/components/budgets/BudgetList' + +export default async function BudgetsPage() { + const session = await auth() + if (!session?.user?.id) return null + + const now = new Date() + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) + + const [budgets, spendRows] = await Promise.all([ + prisma.budget.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: '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" = ${session.user.id} + AND t."budgetId" IS NOT NULL + AND t.type = 'DEBIT' + AND t.date >= ${monthStart} + AND t.date <= ${monthEnd} + GROUP BY t."budgetId" + `, + ]) + + const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)])) + + 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, + })) + + return ( +
+ +
+ ) +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..e1fac7b --- /dev/null +++ b/src/app/(app)/dashboard/page.tsx @@ -0,0 +1,104 @@ +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' + +export default async function DashboardPage() { + const session = await auth() + if (!session?.user?.id) return null + + 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)])) + + 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 ( +
+
+

Dashboard

+

{monthLabel()}

+
+ +
+ + +
+ +
+ + +
+
+ ) +} diff --git a/src/app/(app)/graphs/page.tsx b/src/app/(app)/graphs/page.tsx new file mode 100644 index 0000000..cb38279 --- /dev/null +++ b/src/app/(app)/graphs/page.tsx @@ -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() + 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 ( +
+
+

Graphs

+

{monthLabel()}

+
+ +
+ + + Net Worth Trend + + + + + + + + + Cash Flow · Last 6 Months (Bank) + + + + + + + + + Monthly Spending · Last 6 Months (All Accounts) + + + + + + + + + Spending by Category · {monthLabel()} + + + + + + + + + Budget Performance · {monthLabel()} + + + + + +
+
+ ) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..a7acdd0 --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,12 @@ +import { Sidebar } from '@/components/layout/Sidebar' + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/src/app/(app)/transactions/page.tsx b/src/app/(app)/transactions/page.tsx new file mode 100644 index 0000000..db10df0 --- /dev/null +++ b/src/app/(app)/transactions/page.tsx @@ -0,0 +1,84 @@ +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' + +const PAGE_LIMIT = 50 + +type SearchParams = Promise> + +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] = 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' }, + }), + ]) + + // Serialize dates for client components + const rows = transactions.map((tx) => ({ + ...tx, + date: tx.date.toISOString(), + createdAt: undefined, + updatedAt: undefined, + })) + + return ( +
+

Transactions

+ + +
+ ) +} diff --git a/src/app/(app)/upload/page.tsx b/src/app/(app)/upload/page.tsx new file mode 100644 index 0000000..cddc7a8 --- /dev/null +++ b/src/app/(app)/upload/page.tsx @@ -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 ( +
+

Upload Transactions

+

+ Import a bank CSV. Duplicates are silently skipped. +

+ +
+ ) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..d3c376c --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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) { + 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 ( +
+ + + Sign in + + +
+
+ + +
+
+ + +
+ {error && ( +

{error}

+ )} + +
+
+
+
+ ) +} diff --git a/src/app/api/accounts/[id]/route.ts b/src/app/api/accounts/[id]/route.ts new file mode 100644 index 0000000..97383fa --- /dev/null +++ b/src/app/api/accounts/[id]/route.ts @@ -0,0 +1,63 @@ +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.account.delete({ where: { id } }) + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/api/accounts/route.ts b/src/app/api/accounts/route.ts new file mode 100644 index 0000000..c26a02e --- /dev/null +++ b/src/app/api/accounts/route.ts @@ -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 }) +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b2ad247 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/lib/auth' + +export const { GET, POST } = handlers diff --git a/src/app/api/budgets/[id]/route.ts b/src/app/api/budgets/[id]/route.ts new file mode 100644 index 0000000..432561f --- /dev/null +++ b/src/app/api/budgets/[id]/route.ts @@ -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 }) +} diff --git a/src/app/api/budgets/route.ts b/src/app/api/budgets/route.ts new file mode 100644 index 0000000..a3609ca --- /dev/null +++ b/src/app/api/budgets/route.ts @@ -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 }) +} diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..6bd1232 --- /dev/null +++ b/src/app/api/dashboard/route.ts @@ -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, + })), + }) +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..33a62a9 --- /dev/null +++ b/src/app/api/health/route.ts @@ -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 }) + } +} diff --git a/src/app/api/transactions/[id]/route.ts b/src/app/api/transactions/[id]/route.ts new file mode 100644 index 0000000..200c8ef --- /dev/null +++ b/src/app/api/transactions/[id]/route.ts @@ -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) +} diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts new file mode 100644 index 0000000..6d50e75 --- /dev/null +++ b/src/app/api/transactions/route.ts @@ -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) }) +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..8dfd463 --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,172 @@ +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>(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 + } + + const normalized = normalizeRows(allRows, accountId, config) + + 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: normalized.map((r) => ({ + accountId, + uploadId: upload.id, + date: r.date, + description: r.description, + amountCents: r.amountCents, + type: r.type, + dedupeHash: r.dedupeHash, + })), + skipDuplicates: true, + }) + 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( + normalized.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 }) + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..daf6fb4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Finance", + description: "Personal finance tracker", }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..7073c07 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from 'next/navigation' -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); +export default function RootPage() { + redirect('/dashboard') } diff --git a/src/components/accounts/AccountBadge.tsx b/src/components/accounts/AccountBadge.tsx new file mode 100644 index 0000000..436160e --- /dev/null +++ b/src/components/accounts/AccountBadge.tsx @@ -0,0 +1,10 @@ +import { Badge } from '@/components/ui/badge' +import { AccountType } from '@/generated/prisma/client' + +export function AccountBadge({ type }: { type: AccountType }) { + return type === 'BANK' ? ( + Bank + ) : ( + Credit Card + ) +} diff --git a/src/components/accounts/AccountCard.tsx b/src/components/accounts/AccountCard.tsx new file mode 100644 index 0000000..fa276c0 --- /dev/null +++ b/src/components/accounts/AccountCard.tsx @@ -0,0 +1,97 @@ +'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 } from 'lucide-react' +import { AccountBadge } from './AccountBadge' +import { CreateAccountDialog } from './CreateAccountDialog' +import { formatCents } from '@/lib/utils/currency' + +export function AccountCard({ account }: { account: Account }) { + const router = useRouter() + const [editOpen, setEditOpen] = 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 ( + <> + + +
+ + + {account.name} + + + {account.institution && ( +

{account.institution}

+ )} +
+
+ + + + + Account actions + + + setEditOpen(true)}> + + Edit + + + {account.isActive + ? <>Deactivate + : <>Activate + } + + + + + Delete + + + +
+
+ +

+ {formatCents(account.currentBalanceCents)} +

+ {!account.isActive && ( +

Inactive

+ )} +
+
+ + + + ) +} diff --git a/src/components/accounts/AccountList.tsx b/src/components/accounts/AccountList.tsx new file mode 100644 index 0000000..08a89a8 --- /dev/null +++ b/src/components/accounts/AccountList.tsx @@ -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 ( +
+
+
+

Accounts

+

+ {accounts.length} account{accounts.length !== 1 ? 's' : ''} +

+
+ +
+ + {accounts.length === 0 ? ( +
+

No accounts yet.

+ +
+ ) : ( +
+ {accounts.map((account) => ( + + ))} +
+ )} + + +
+ ) +} diff --git a/src/components/accounts/CreateAccountDialog.tsx b/src/components/accounts/CreateAccountDialog.tsx new file mode 100644 index 0000000..7f01d3b --- /dev/null +++ b/src/components/accounts/CreateAccountDialog.tsx @@ -0,0 +1,112 @@ +'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) { + 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 ( + + + + {isEdit ? 'Edit account' : 'Add account'} + +
+
+ + +
+
+ + +
+
+ + +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ) +} diff --git a/src/components/budgets/BudgetCard.tsx b/src/components/budgets/BudgetCard.tsx new file mode 100644 index 0000000..a37f604 --- /dev/null +++ b/src/components/budgets/BudgetCard.tsx @@ -0,0 +1,107 @@ +'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 } from 'lucide-react' +import { BudgetProgress } from './BudgetProgress' +import { CreateBudgetDialog } from './CreateBudgetDialog' +import { formatCents } from '@/lib/utils/currency' + +export interface BudgetWithSpend { + id: string + name: string + limitCents: number | null + color: string | null + isActive: boolean + spendCents: number +} + +export function BudgetCard({ budget }: { budget: BudgetWithSpend }) { + const router = useRouter() + const [editOpen, setEditOpen] = 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 ( + <> + + +
+ {budget.color && ( + + )} + {budget.name} +
+ + + + Budget actions + + + setEditOpen(true)}> + Edit + + + {budget.isActive + ? <>Deactivate + : <>Activate} + + + + Delete + + + +
+ + +
+ + {formatCents(budget.spendCents)} + + {budget.limitCents ? ( + + of {formatCents(budget.limitCents)} + + ) : ( + no limit + )} +
+ + {budget.limitCents ? ( + + ) : ( +

This month

+ )} + + {!budget.isActive && ( +

Inactive

+ )} +
+
+ + + + ) +} diff --git a/src/components/budgets/BudgetList.tsx b/src/components/budgets/BudgetList.tsx new file mode 100644 index 0000000..794f84f --- /dev/null +++ b/src/components/budgets/BudgetList.tsx @@ -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 ( +
+
+
+

Budgets

+

+ {active.length} active budget{active.length !== 1 ? 's' : ''} +

+
+ +
+ + {budgets.length === 0 ? ( +
+

No budgets yet.

+ +
+ ) : ( +
+
+ {active.map((b) => )} +
+ + {inactive.length > 0 && ( +
+

+ Inactive +

+
+ {inactive.map((b) => )} +
+
+ )} +
+ )} + + +
+ ) +} diff --git a/src/components/budgets/BudgetProgress.tsx b/src/components/budgets/BudgetProgress.tsx new file mode 100644 index 0000000..c7ce73e --- /dev/null +++ b/src/components/budgets/BudgetProgress.tsx @@ -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 ( +
+
+
+
+

{Math.round(pct)}%

+
+ ) +} diff --git a/src/components/budgets/CreateBudgetDialog.tsx b/src/components/budgets/CreateBudgetDialog.tsx new file mode 100644 index 0000000..38fcc53 --- /dev/null +++ b/src/components/budgets/CreateBudgetDialog.tsx @@ -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) { + 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 ( + + + + {isEdit ? 'Edit budget' : 'New budget'} + +
+
+ + +
+ +
+ +
+ $ + +
+
+ +
+ +
+ {PALETTE.map((c) => ( +
+
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} diff --git a/src/components/dashboard/BudgetSummary.tsx b/src/components/dashboard/BudgetSummary.tsx new file mode 100644 index 0000000..e7edd2f --- /dev/null +++ b/src/components/dashboard/BudgetSummary.tsx @@ -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 ( + + + Budgets + + Manage + + + + {budgets.length === 0 ? ( +

No active budgets.

+ ) : ( +
+ {budgets.map((b) => ( +
+
+
+ {b.color && ( + + )} + {b.name} +
+ + {formatCents(b.spendCents)} + {b.limitCents ? ` / ${formatCents(b.limitCents)}` : ''} + +
+ {b.limitCents ? ( + + ) : ( +
+ )} +
+ ))} +
+ )} + + + ) +} diff --git a/src/components/dashboard/CashFlowCard.tsx b/src/components/dashboard/CashFlowCard.tsx new file mode 100644 index 0000000..50db074 --- /dev/null +++ b/src/components/dashboard/CashFlowCard.tsx @@ -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 ( + + + + Cash Flow · {monthLabel} + + + +
+ + + {isPositive ? '+' : ''}{formatCents(netCents)} + +
+ +
+
+ + + Income + + {formatCents(creditsCents)} +
+
+ + + Spending + + {formatCents(debitsCents)} +
+
+
+
+ ) +} diff --git a/src/components/dashboard/NetWorthCard.tsx b/src/components/dashboard/NetWorthCard.tsx new file mode 100644 index 0000000..3ce46dd --- /dev/null +++ b/src/components/dashboard/NetWorthCard.tsx @@ -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 + currentBalanceCents: number +} + +interface NetWorthCardProps { + netWorthCents: number + bankAccounts: BankAccount[] +} + +export function NetWorthCard({ netWorthCents, bankAccounts }: NetWorthCardProps) { + return ( + + + Net Worth + + +

{formatCents(netWorthCents)}

+ {bankAccounts.length > 0 && ( +
+ {bankAccounts.map((a) => ( +
+ {a.name} + + {formatCents(a.currentBalanceCents)} + +
+ ))} +
+ )} + {bankAccounts.length === 0 && ( +

No bank accounts yet.

+ )} +
+
+ ) +} diff --git a/src/components/dashboard/RecentTransactions.tsx b/src/components/dashboard/RecentTransactions.tsx new file mode 100644 index 0000000..5445df2 --- /dev/null +++ b/src/components/dashboard/RecentTransactions.tsx @@ -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 ( + + + Recent Transactions + + View all + + + + {transactions.length === 0 ? ( +

No transactions yet.

+ ) : ( +
+ {transactions.map((tx) => { + const date = new Date(tx.date) + const label = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + return ( +
+
+

{tx.description}

+

+ {label} · {tx.accountName} +

+
+ + {tx.type === 'CREDIT' ? '+' : '-'}{formatCents(tx.amountCents)} + +
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/src/components/graphs/BudgetChart.tsx b/src/components/graphs/BudgetChart.tsx new file mode 100644 index 0000000..f969970 --- /dev/null +++ b/src/components/graphs/BudgetChart.tsx @@ -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 ( +
+

{label}

+ {payload.map((p) => ( +

+ {p.name}: {formatCents(p.value)} +

+ ))} +
+ ) +} + +export function BudgetChart({ data }: { data: BudgetBar[] }) { + if (data.length === 0) { + return ( +
+ No active budgets. +
+ ) + } + + const chartHeight = Math.max(200, data.length * 60 + 60) + + return ( + + + + + + } /> + + + {data.map((d, i) => ( + + ))} + + + + + ) +} diff --git a/src/components/graphs/CashFlowChart.tsx b/src/components/graphs/CashFlowChart.tsx new file mode 100644 index 0000000..3d21bc3 --- /dev/null +++ b/src/components/graphs/CashFlowChart.tsx @@ -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 ( +
+

{label}

+ {payload.map((p) => ( +

+ {p.name}: {formatCents(p.value)} +

+ ))} +
+ ) +} + +export function CashFlowChart({ data }: { data: DataPoint[] }) { + if (data.length === 0) { + return ( +
+ No bank transaction data yet. +
+ ) + } + + return ( + + + + + + } /> + + + + + + ) +} diff --git a/src/components/graphs/CategoryBreakdownChart.tsx b/src/components/graphs/CategoryBreakdownChart.tsx new file mode 100644 index 0000000..97af422 --- /dev/null +++ b/src/components/graphs/CategoryBreakdownChart.tsx @@ -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 ( +
+

{item.payload.category}

+

{formatCents(item.value)}

+
+ ) +} + +export function CategoryBreakdownChart({ data }: { data: DataPoint[] }) { + if (data.length === 0) { + return ( +
+ No spending this month. +
+ ) + } + + return ( + + + + {data.map((_, i) => ( + + ))} + + } /> + {value}} + /> + + + ) +} diff --git a/src/components/graphs/MonthlySpendingChart.tsx b/src/components/graphs/MonthlySpendingChart.tsx new file mode 100644 index 0000000..ea82874 --- /dev/null +++ b/src/components/graphs/MonthlySpendingChart.tsx @@ -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 ( +
+

{label}

+

Total: {formatCents(payload[0].value)}

+
+ ) +} + +export function MonthlySpendingChart({ data }: { data: DataPoint[] }) { + if (data.length === 0) { + return ( +
+ No spending data yet. +
+ ) + } + + return ( + + + + + + } /> + + + + ) +} diff --git a/src/components/graphs/NetWorthTrendChart.tsx b/src/components/graphs/NetWorthTrendChart.tsx new file mode 100644 index 0000000..2183021 --- /dev/null +++ b/src/components/graphs/NetWorthTrendChart.tsx @@ -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 ( +
+

{label}

+

{formatCents(payload[0].value)}

+
+ ) +} + +export function NetWorthTrendChart({ data }: { data: DataPoint[] }) { + if (data.length === 0) { + return ( +
+ No snapshot data yet — upload transactions to populate this chart. +
+ ) + } + + return ( + + + + + + + + + + + + } /> + + + + ) +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..cab4cd8 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -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 ( + + ) +} diff --git a/src/components/transactions/EditTransactionDialog.tsx b/src/components/transactions/EditTransactionDialog.tsx new file mode 100644 index 0000000..07723ba --- /dev/null +++ b/src/components/transactions/EditTransactionDialog.tsx @@ -0,0 +1,150 @@ +'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 ( + + + + + {transaction.description} + +

+ {date} · + {transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)} + +

+
+ +
+
+ + setCategory(e.target.value)} + placeholder="e.g. Groceries" + /> +
+ +
+ + +
+ +
+ +