From 13161b8e49d99470dd468cfe386d411c80e7cc42 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 21 Mar 2026 11:33:38 -0600 Subject: [PATCH] Add budget module: FastAPI backend + React frontend Backend: FastAPI + PostgreSQL with models for accounts, transactions, and categories. Auto-categorization from merchant patterns, token auth, CRUD endpoints, and seed data for 16 categories and 4 bank accounts. Frontend: Login, Dashboard (account balances + recent charges), Transactions (full CRUD table with search/filter), Cash & Transfers view. Dark theme with emerald/cyan accents, responsive layout. Infrastructure: Updated docker-compose for backend + db services, nginx proxy config for API routing, deploy workflow with secrets. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy.yml | 12 +- .gitignore | 2 + backend/Dockerfile | 6 + backend/Dockerfile.prod | 6 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/v1/__init__.py | 0 backend/app/api/v1/endpoints/__init__.py | 0 backend/app/api/v1/endpoints/accounts.py | 51 ++++ backend/app/api/v1/endpoints/auth.py | 22 ++ backend/app/api/v1/endpoints/categories.py | 61 +++++ backend/app/api/v1/endpoints/transactions.py | 111 +++++++++ backend/app/api/v1/router.py | 9 + backend/app/auth.py | 27 +++ backend/app/config.py | 15 ++ backend/app/db.py | 14 ++ backend/app/main.py | 34 +++ backend/app/models/__init__.py | 0 backend/app/models/models.py | 135 +++++++++++ backend/app/seed.py | 45 ++++ backend/requirements.txt | 9 + docker-compose.prod.yml | 53 ++++ docker-compose.yml | 37 ++- frontend/nginx.conf | 10 + frontend/src/App.tsx | 149 ++++-------- frontend/src/AuthContext.tsx | 36 +++ frontend/src/api.ts | 69 ++++++ frontend/src/components/Layout.tsx | 118 +++++++++ frontend/src/components/TransactionModal.tsx | 242 +++++++++++++++++++ frontend/src/index.css | 9 + frontend/src/pages/Dashboard.tsx | 197 +++++++++++++++ frontend/src/pages/Login.tsx | 89 +++++++ frontend/src/pages/Transactions.tsx | 235 ++++++++++++++++++ frontend/src/pages/Transfers.tsx | 164 +++++++++++++ 34 files changed, 1855 insertions(+), 112 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/Dockerfile.prod create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/endpoints/__init__.py create mode 100644 backend/app/api/v1/endpoints/accounts.py create mode 100644 backend/app/api/v1/endpoints/auth.py create mode 100644 backend/app/api/v1/endpoints/categories.py create mode 100644 backend/app/api/v1/endpoints/transactions.py create mode 100644 backend/app/api/v1/router.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/models.py create mode 100644 backend/app/seed.py create mode 100644 backend/requirements.txt create mode 100644 frontend/src/AuthContext.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/TransactionModal.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Transactions.tsx create mode 100644 frontend/src/pages/Transfers.tsx diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4edb584..23d7365 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,6 +14,12 @@ jobs: - name: Write .env.prod run: | cat > .env.prod << 'ENVEOF' + POSTGRES_USER=${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB=${{ secrets.POSTGRES_DB }} + SECRET_KEY=${{ secrets.SECRET_KEY }} + ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }} + ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }} ENVEOF @@ -24,10 +30,10 @@ jobs: - name: Wait for health run: | - echo "Waiting for frontend..." + echo "Waiting for backend..." for i in $(seq 1 30); do - if docker inspect wealthysmart-frontend-prod --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then - echo "Frontend is healthy" + if docker inspect wealthysmart-backend-prod --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + echo "Backend is healthy" break fi sleep 2 diff --git a/.gitignore b/.gitignore index 59ff8a6..cdcd084 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +__pycache__/ +*.pyc .env .env.* !.env.example diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fecce0d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..fc2e612 --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/endpoints/__init__.py b/backend/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/endpoints/accounts.py b/backend/app/api/v1/endpoints/accounts.py new file mode 100644 index 0000000..fd6a805 --- /dev/null +++ b/backend/app/api/v1/endpoints/accounts.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from app.auth import get_current_user +from app.db import get_session +from app.models.models import Account, AccountCreate, AccountRead, AccountUpdate + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.get("/", response_model=list[AccountRead]) +def list_accounts( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + return session.exec(select(Account)).all() + + +@router.post("/", response_model=AccountRead, status_code=201) +def create_account( + data: AccountCreate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + account = Account.model_validate(data) + session.add(account) + session.commit() + session.refresh(account) + return account + + +@router.patch("/{account_id}", response_model=AccountRead) +def update_account( + account_id: int, + data: AccountUpdate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + account = session.get(Account, account_id) + if not account: + raise HTTPException(status_code=404, detail="Account not found") + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(account, key, value) + account.updated_at = datetime.utcnow() + session.add(account) + session.commit() + session.refresh(account) + return account diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..6ec50cd --- /dev/null +++ b/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from fastapi import Depends + +from app.auth import create_access_token +from app.config import settings + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login") +def login(form_data: OAuth2PasswordRequestForm = Depends()): + if ( + form_data.username != settings.ADMIN_USERNAME + or form_data.password != settings.ADMIN_PASSWORD + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + token = create_access_token(form_data.username) + return {"access_token": token, "token_type": "bearer"} diff --git a/backend/app/api/v1/endpoints/categories.py b/backend/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..a1a225b --- /dev/null +++ b/backend/app/api/v1/endpoints/categories.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from app.auth import get_current_user +from app.db import get_session +from app.models.models import Category, CategoryCreate, CategoryRead, CategoryUpdate + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.get("/", response_model=list[CategoryRead]) +def list_categories( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + return session.exec(select(Category)).all() + + +@router.post("/", response_model=CategoryRead, status_code=201) +def create_category( + data: CategoryCreate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + category = Category.model_validate(data) + session.add(category) + session.commit() + session.refresh(category) + return category + + +@router.patch("/{category_id}", response_model=CategoryRead) +def update_category( + category_id: int, + data: CategoryUpdate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + category = session.get(Category, category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(category, key, value) + session.add(category) + session.commit() + session.refresh(category) + return category + + +@router.delete("/{category_id}", status_code=204) +def delete_category( + category_id: int, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + category = session.get(Category, category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + session.delete(category) + session.commit() diff --git a/backend/app/api/v1/endpoints/transactions.py b/backend/app/api/v1/endpoints/transactions.py new file mode 100644 index 0000000..9f40581 --- /dev/null +++ b/backend/app/api/v1/endpoints/transactions.py @@ -0,0 +1,111 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, col, select + +from app.auth import get_current_user +from app.db import get_session +from app.models.models import ( + Category, + Transaction, + TransactionCreate, + TransactionRead, + TransactionSource, + TransactionUpdate, +) + +router = APIRouter(prefix="/transactions", tags=["transactions"]) + + +def auto_categorize(merchant: str, session: Session) -> Optional[int]: + categories = session.exec(select(Category)).all() + merchant_lower = merchant.lower() + for cat in categories: + if cat.auto_match_patterns: + patterns = [p.strip().lower() for p in cat.auto_match_patterns.split(",")] + if any(p in merchant_lower for p in patterns if p): + return cat.id + return None + + +@router.get("/", response_model=list[TransactionRead]) +def list_transactions( + source: Optional[TransactionSource] = None, + search: Optional[str] = None, + category_id: Optional[int] = None, + limit: int = Query(default=50, le=500), + offset: int = 0, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + query = select(Transaction) + if source: + query = query.where(Transaction.source == source) + if category_id: + query = query.where(Transaction.category_id == category_id) + if search: + query = query.where(col(Transaction.merchant).ilike(f"%{search}%")) + query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit) + return session.exec(query).all() + + +@router.get("/recent", response_model=list[TransactionRead]) +def recent_transactions( + limit: int = Query(default=5, le=20), + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + query = ( + select(Transaction) + .where(Transaction.source == TransactionSource.CREDIT_CARD) + .order_by(col(Transaction.date).desc()) + .limit(limit) + ) + return session.exec(query).all() + + +@router.post("/", response_model=TransactionRead, status_code=201) +def create_transaction( + data: TransactionCreate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + tx = Transaction.model_validate(data) + if tx.category_id is None: + tx.category_id = auto_categorize(tx.merchant, session) + session.add(tx) + session.commit() + session.refresh(tx) + return tx + + +@router.patch("/{transaction_id}", response_model=TransactionRead) +def update_transaction( + transaction_id: int, + data: TransactionUpdate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + tx = session.get(Transaction, transaction_id) + if not tx: + raise HTTPException(status_code=404, detail="Transaction not found") + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(tx, key, value) + session.add(tx) + session.commit() + session.refresh(tx) + return tx + + +@router.delete("/{transaction_id}", status_code=204) +def delete_transaction( + transaction_id: int, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + tx = session.get(Transaction, transaction_id) + if not tx: + raise HTTPException(status_code=404, detail="Transaction not found") + session.delete(tx) + session.commit() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..48f8237 --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints import accounts, auth, categories, transactions + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(auth.router) +api_router.include_router(accounts.router) +api_router.include_router(categories.router) +api_router.include_router(transactions.router) diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..5a62f65 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,27 @@ +from datetime import datetime, timedelta + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt + +from app.config import settings + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + +ALGORITHM = "HS256" + + +def create_access_token(subject: str) -> str: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def get_current_user(token: str = Depends(oauth2_scheme)) -> str: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return username + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..60e51a7 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart" + SECRET_KEY: str = "change-me-in-production" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 30 # 30 days + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD: str = "admin" + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..b743046 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,14 @@ +from sqlmodel import SQLModel, Session, create_engine + +from app.config import settings + +engine = create_engine(settings.DATABASE_URL) + + +def init_db(): + SQLModel.metadata.create_all(engine) + + +def get_session(): + with Session(engine) as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..4d1ea1e --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,34 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.router import api_router +from app.config import settings +from app.db import init_db +from app.seed import seed_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + seed_db() + yield + + +app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router) + + +@app.get("/") +def root(): + return {"app": "WealthySmart", "version": "0.1.0"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..020c434 --- /dev/null +++ b/backend/app/models/models.py @@ -0,0 +1,135 @@ +import enum +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, Relationship, SQLModel + + +class TransactionType(str, enum.Enum): + COMPRA = "COMPRA" + DEVOLUCION = "DEVOLUCION" + + +class TransactionSource(str, enum.Enum): + CREDIT_CARD = "CREDIT_CARD" + CASH = "CASH" + TRANSFER = "TRANSFER" + + +class Currency(str, enum.Enum): + CRC = "CRC" + USD = "USD" + + +class Bank(str, enum.Enum): + BAC = "BAC" + BCR = "BCR" + DAVIVIENDA = "DAVIVIENDA" + + +# --- Category --- + + +class CategoryBase(SQLModel): + name: str = Field(index=True, unique=True) + icon: str = "tag" + auto_match_patterns: Optional[str] = Field( + default=None, + description="Comma-separated merchant substrings for auto-matching", + ) + + +class Category(CategoryBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + transactions: list["Transaction"] = Relationship(back_populates="category") + + +class CategoryCreate(CategoryBase): + pass + + +class CategoryRead(CategoryBase): + id: int + + +class CategoryUpdate(SQLModel): + name: Optional[str] = None + icon: Optional[str] = None + auto_match_patterns: Optional[str] = None + + +# --- Account --- + + +class AccountBase(SQLModel): + bank: Bank + currency: Currency + label: str = Field(description="e.g. 'BAC Colones', 'BAC Dólares'") + balance: float = 0.0 + + +class Account(AccountBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +class AccountCreate(AccountBase): + pass + + +class AccountRead(AccountBase): + id: int + updated_at: datetime + + +class AccountUpdate(SQLModel): + balance: Optional[float] = None + label: Optional[str] = None + + +# --- Transaction --- + + +class TransactionBase(SQLModel): + amount: float + currency: Currency = Currency.CRC + merchant: str + city: Optional[str] = None + date: datetime + card_type: Optional[str] = None + card_last4: Optional[str] = None + authorization_code: Optional[str] = None + reference: Optional[str] = None + transaction_type: TransactionType = TransactionType.COMPRA + source: TransactionSource = TransactionSource.CREDIT_CARD + bank: Bank = Bank.BAC + notes: Optional[str] = None + category_id: Optional[int] = Field(default=None, foreign_key="category.id") + + +class Transaction(TransactionBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + category: Optional[Category] = Relationship(back_populates="transactions") + + +class TransactionCreate(TransactionBase): + pass + + +class TransactionRead(TransactionBase): + id: int + created_at: datetime + category: Optional[CategoryRead] = None + + +class TransactionUpdate(SQLModel): + amount: Optional[float] = None + currency: Optional[Currency] = None + merchant: Optional[str] = None + city: Optional[str] = None + date: Optional[datetime] = None + transaction_type: Optional[TransactionType] = None + source: Optional[TransactionSource] = None + notes: Optional[str] = None + category_id: Optional[int] = None diff --git a/backend/app/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..36be337 --- /dev/null +++ b/backend/app/seed.py @@ -0,0 +1,45 @@ +from sqlmodel import Session, select + +from app.db import engine +from app.models.models import Account, Bank, Category, Currency + +DEFAULT_CATEGORIES = [ + ("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"), + ("Food & Delivery", "utensils", "uber eats,rappi,mcdonalds,subway,pizza,restaurant,soda,cafe,coyote ugly,el rodeo,steak house"), + ("Utilities", "zap", "c.n.f.l,cnfl,ice,aya,claro cr telecomunicaciones"), + ("Transportation", "car", "gasolina,gasolinera,uber rides,didi,parqueo,parking,peaje,estacion de servicio,estac.de serv"), + ("Shopping", "shopping-bag", "amazon,ebay,ticotek,construplaza,epa,novex,novedades chayfer,total imports,tiendalaliga,gnc live well"), + ("Entertainment", "film", "netflix,disney,cine,steam,playstation,blizzard,diablo"), + ("Health", "heart-pulse", "farmacia,hospital,clinica,laboratorio,optica,medicina regenerativa,neumi,doer fitness,kettlebell,lacrosse"), + ("Education", "graduation-cap", "universidad,udemy,coursera,libro"), + ("Housing", "home", "hipoteca,alquiler,municipalidad,condominio,bac san jose pensiones"), + ("Insurance", "shield", "seguro,ins"), + ("Subscriptions", "repeat", "cloudflare,github,google one,apple,icloud,spotify,openai,claude.ai,cursor,netcup"), + ("Telecom", "phone", "liberty,tigo,kolbi"), + ("Parking & Fees", "circle-parking", "centro comercial curridabat,debito compass,cobro administr,compass"), + ("Auto", "car-front", "auto lavado,lavado"), + ("Lab & Medical", "microscope", "laboratorio echandi"), + ("Other", "tag", ""), +] + +DEFAULT_ACCOUNTS = [ + (Bank.BAC, Currency.CRC, "BAC Colones", 0.0), + (Bank.BAC, Currency.USD, "BAC Dólares", 0.0), + (Bank.BCR, Currency.CRC, "BCR Colones", 0.0), + (Bank.DAVIVIENDA, Currency.CRC, "Davivienda Colones", 0.0), +] + + +def seed_db(): + with Session(engine) as session: + existing = session.exec(select(Category)).first() + if not existing: + for name, icon, patterns in DEFAULT_CATEGORIES: + session.add(Category(name=name, icon=icon, auto_match_patterns=patterns)) + session.commit() + + existing_acc = session.exec(select(Account)).first() + if not existing_acc: + for bank, currency, label, balance in DEFAULT_ACCOUNTS: + session.add(Account(bank=bank, currency=currency, label=label, balance=balance)) + session.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2a6d145 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi[standard] +uvicorn[standard] +sqlmodel +psycopg2-binary +python-jose[cryptography] +passlib[bcrypt] +python-multipart +python-dotenv +alembic diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a20bd31..9674edf 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,4 +1,47 @@ services: + db: + image: postgres:16-alpine + container_name: wealthysmart-db-prod + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - wealthysmart-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + container_name: wealthysmart-backend-prod + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + SECRET_KEY: ${SECRET_KEY} + ADMIN_USERNAME: ${ADMIN_USERNAME} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + expose: + - "8000" + networks: + - wealthysmart-network + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + frontend: build: context: ./frontend @@ -12,7 +55,10 @@ services: expose: - "80" networks: + - wealthysmart-network - nginx-prod-network + depends_on: + - backend healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"] interval: 30s @@ -20,5 +66,12 @@ services: retries: 3 networks: + wealthysmart-network: + driver: bridge + name: wealthysmart-network-prod nginx-prod-network: external: true + +volumes: + postgres_data: + name: wealthysmart-postgres-prod-data diff --git a/docker-compose.yml b/docker-compose.yml index 3a9a33c..1ff1fb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,46 @@ services: + db: + image: postgres:16-alpine + container_name: wealthysmart-db-dev + environment: + POSTGRES_USER: wealthy_user + POSTGRES_PASSWORD: wealthy_pass + POSTGRES_DB: wealthysmart + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wealthy_user -d wealthysmart"] + interval: 5s + timeout: 3s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: wealthysmart-backend-dev + environment: + DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart + ports: + - "8000:8000" + volumes: + - ./backend:/app + depends_on: + db: + condition: service_healthy + frontend: build: context: ./frontend dockerfile: Dockerfile container_name: wealthysmart-frontend-dev ports: - - "5173:5173" + - "5174:5173" volumes: - ./frontend:/app - /app/node_modules + +volumes: + postgres_data: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a20fee7..77d1921 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -8,6 +8,16 @@ server { try_files $uri $uri/ /index.html; } + # Proxy API to backend (same docker network) + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + # Cache immutable assets location /assets/ { expires 1y; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 169357a..788f2a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,113 +1,46 @@ -import { - TrendingUp, - Shield, - Smartphone, - BarChart3, - Wallet, - ArrowRight, -} from 'lucide-react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './AuthContext'; +import Layout from './components/Layout'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import Transactions from './pages/Transactions'; +import Transfers from './pages/Transfers'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuth(); + return isAuthenticated ? <>{children} : ; +} + +function AppRoutes() { + const { isAuthenticated } = useAuth(); -function App() { return ( -
- {/* Nav */} - - - {/* Hero */} -
-
-
- - Personal Finance, Simplified -
-

- Take control of your{' '} - - financial future - -

-

- Budget tracking, investment management, and financial insights — all - in one place. Built to replace spreadsheets with something smarter. -

-

- Track every dollar with precision, monitor your investments in real - time, and uncover meaningful insights about your financial habits - without juggling multiple tools. This platform centralizes your - entire financial picture, turning scattered data into clear, - actionable intelligence. Instead of manually updating spreadsheets - and second-guessing your numbers, you get automated tracking, - intelligent categorization, and forward-looking analysis that helps - you make better decisions faster. It's not just a replacement for - spreadsheets—it's a system designed to actively improve how you - manage, grow, and understand your money. -

-
-
- Get Started - -
-
-
-
- - {/* Features */} -
-
- {[ - { - icon: BarChart3, - title: 'Budget Tracking', - desc: 'Track income, expenses, and savings across multiple accounts and currencies with real-time balance updates.', - }, - { - icon: Shield, - title: 'Investments & Pensions', - desc: 'Monitor your portfolio, pension funds, and long-term savings all in a single dashboard.', - }, - { - icon: Smartphone, - title: 'Mobile First', - desc: 'Check and edit your finances on the go. Designed to work seamlessly on any device.', - }, - ].map(({ icon: Icon, title, desc }) => ( -
-
- -
-

{title}

-

{desc}

-
- ))} -
-
- - {/* Footer */} -
-
- © {new Date().getFullYear()} WealthySmart - wealth.cescalante.dev -
-
-
+ + : } + /> + + + + } + > + } /> + } /> + } /> + + ); } -export default App; +export default function App() { + return ( + + + + + + ); +} diff --git a/frontend/src/AuthContext.tsx b/frontend/src/AuthContext.tsx new file mode 100644 index 0000000..a925f2d --- /dev/null +++ b/frontend/src/AuthContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; + +interface AuthCtx { + isAuthenticated: boolean; + logout: () => void; + setAuthenticated: (v: boolean) => void; +} + +const AuthContext = createContext({ + isAuthenticated: false, + logout: () => {}, + setAuthenticated: () => {}, +}); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token')); + + useEffect(() => { + const check = () => setAuthenticated(!!localStorage.getItem('token')); + window.addEventListener('storage', check); + return () => window.removeEventListener('storage', check); + }, []); + + const logout = () => { + localStorage.removeItem('token'); + setAuthenticated(false); + }; + + return ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..c12cb44 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || '/api/v1', +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/login'; + } + return Promise.reject(err); + }, +); + +export default api; + +export async function login(username: string, password: string) { + const form = new URLSearchParams(); + form.append('username', username); + form.append('password', password); + const { data } = await api.post('/auth/login', form); + localStorage.setItem('token', data.access_token); + return data; +} + +export interface Account { + id: number; + bank: string; + currency: string; + label: string; + balance: number; + updated_at: string; +} + +export interface Category { + id: number; + name: string; + icon: string; + auto_match_patterns: string | null; +} + +export interface Transaction { + id: number; + amount: number; + currency: string; + merchant: string; + city: string | null; + date: string; + card_type: string | null; + card_last4: string | null; + authorization_code: string | null; + reference: string | null; + transaction_type: string; + source: string; + bank: string; + notes: string | null; + category_id: number | null; + category: Category | null; + created_at: string; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..634dd99 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,118 @@ +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { + LayoutDashboard, + CreditCard, + ArrowLeftRight, + LogOut, + Wallet, + Menu, + X, +} from 'lucide-react'; +import { useState } from 'react'; +import { useAuth } from '../AuthContext'; + +const navItems = [ + { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, + { to: '/transactions', icon: CreditCard, label: 'Transactions' }, + { to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' }, +]; + +export default function Layout() { + const { logout } = useAuth(); + const navigate = useNavigate(); + const [mobileOpen, setMobileOpen] = useState(false); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( +
+ {/* Top bar */} +
+
+
+
+ +
+ + WealthySmart + +
+ + {/* Desktop nav */} + + +
+ + +
+
+ + {/* Mobile nav */} + {mobileOpen && ( +
+ {navItems.map(({ to, icon: Icon, label }) => ( + setMobileOpen(false)} + className={({ isActive }) => + `flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${ + isActive + ? 'bg-emerald-500/10 text-emerald-400' + : 'text-slate-400 hover:text-white hover:bg-slate-800/50' + }` + } + > + + {label} + + ))} + +
+ )} +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/TransactionModal.tsx b/frontend/src/components/TransactionModal.tsx new file mode 100644 index 0000000..d34a7d8 --- /dev/null +++ b/frontend/src/components/TransactionModal.tsx @@ -0,0 +1,242 @@ +import { useEffect, useState } from 'react'; +import { X } from 'lucide-react'; +import api, { type Category, type Transaction } from '../api'; + +interface Props { + transaction?: Transaction | null; + source: 'CREDIT_CARD' | 'CASH' | 'TRANSFER'; + onClose: () => void; + onSaved: () => void; +} + +export default function TransactionModal({ transaction, source, onClose, onSaved }: Props) { + const [categories, setCategories] = useState([]); + const [form, setForm] = useState({ + merchant: '', + amount: '', + currency: 'CRC', + date: new Date().toISOString().slice(0, 16), + transaction_type: 'COMPRA', + source, + bank: 'BAC', + city: '', + card_type: '', + card_last4: '', + notes: '', + category_id: '' as string | number, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + api.get('/categories/').then((r) => setCategories(r.data)); + }, []); + + useEffect(() => { + if (transaction) { + setForm({ + merchant: transaction.merchant, + amount: String(transaction.amount), + currency: transaction.currency, + date: transaction.date.slice(0, 16), + transaction_type: transaction.transaction_type, + source: transaction.source as typeof source, + bank: transaction.bank, + city: transaction.city || '', + card_type: transaction.card_type || '', + card_last4: transaction.card_last4 || '', + notes: transaction.notes || '', + category_id: transaction.category_id || '', + }); + } + }, [transaction]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + const payload = { + ...form, + amount: parseFloat(form.amount), + category_id: form.category_id ? Number(form.category_id) : null, + city: form.city || null, + card_type: form.card_type || null, + card_last4: form.card_last4 || null, + notes: form.notes || null, + }; + if (transaction) { + await api.patch(`/transactions/${transaction.id}`, payload); + } else { + await api.post('/transactions/', payload); + } + onSaved(); + onClose(); + } catch (err) { + console.error(err); + } finally { + setSaving(false); + } + }; + + const inputClass = + 'w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors'; + const labelClass = 'block text-xs font-medium text-slate-400 mb-1 uppercase tracking-wider'; + + return ( +
+
+
+

+ {transaction ? 'Edit Transaction' : 'New Transaction'} +

+ +
+ +
+
+
+ + setForm({ ...form, merchant: e.target.value })} + placeholder="e.g. AUTO MERCADO ON LINE" + required + /> +
+
+ + setForm({ ...form, amount: e.target.value })} + placeholder="0.00" + required + /> +
+
+ + +
+
+ + setForm({ ...form, date: e.target.value })} + required + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + setForm({ ...form, city: e.target.value })} + placeholder="SAN JOSE, Costa Rica" + /> +
+ {source === 'CREDIT_CARD' && ( + <> +
+ + setForm({ ...form, card_type: e.target.value })} + placeholder="MASTER" + /> +
+
+ + setForm({ ...form, card_last4: e.target.value })} + placeholder="6585" + maxLength={4} + /> +
+ + )} +
+ + setForm({ ...form, notes: e.target.value })} + placeholder="Optional notes" + /> +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d4b5078..d2c0c92 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1 +1,10 @@ @import 'tailwindcss'; + +@keyframes fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.animate-fade-in { + animation: fade-in 0.4s ease-out both; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..161cc29 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + ArrowRight, + TrendingUp, + TrendingDown, + RefreshCw, + CreditCard, +} from 'lucide-react'; + +import api, { type Account, type Transaction } from '../api'; + +function formatAmount(amount: number, currency: string) { + const abs = Math.abs(amount); + if (currency === 'USD') { + return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function formatDate(dateStr: string) { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); +} + +export default function Dashboard() { + const [accounts, setAccounts] = useState([]); + const [recent, setRecent] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchData = async () => { + setLoading(true); + try { + const [accRes, txRes] = await Promise.all([ + api.get('/accounts/'), + api.get('/transactions/recent?limit=5'), + ]); + setAccounts(accRes.data); + setRecent(txRes.data); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const totalCRC = accounts + .filter((a) => a.currency === 'CRC') + .reduce((s, a) => s + a.balance, 0); + const totalUSD = accounts + .filter((a) => a.currency === 'USD') + .reduce((s, a) => s + a.balance, 0); + + return ( +
+ {/* Header */} +
+
+

Dashboard

+

Financial overview

+
+ +
+ + {/* Account balances */} +
+ {accounts.map((account, i) => ( +
+
+
+
+ + {account.label} + + + {account.bank} + +
+

+ {formatAmount(account.balance, account.currency)} +

+
+
+ ))} + + {/* Totals */} + {accounts.length > 0 && ( + <> +
+ + Total CRC + +

+ {formatAmount(totalCRC, 'CRC')} +

+
+
+ + Total USD + +

+ {formatAmount(totalUSD, 'USD')} +

+
+ + )} +
+ + {/* Recent transactions */} +
+
+
+ +

Recent Charges

+
+ + View all + + +
+ + {recent.length === 0 && !loading ? ( +
+ No transactions yet. Add your first one! +
+ ) : ( +
+ {recent.map((tx, i) => ( +
+
+
+ {tx.transaction_type === 'DEVOLUCION' ? ( + + ) : ( + + )} +
+
+

{tx.merchant}

+

+ {formatDate(tx.date)} + {tx.category && ( + + {tx.category.name} + + )} +

+
+
+ + {tx.transaction_type === 'DEVOLUCION' ? '+' : '-'} + {formatAmount(tx.amount, tx.currency)} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..8460194 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Wallet, ArrowRight, AlertCircle } from 'lucide-react'; + +import { login } from '../api'; +import { useAuth } from '../AuthContext'; + +export default function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const { setAuthenticated } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await login(username, password); + setAuthenticated(true); + navigate('/'); + } catch { + setError('Invalid credentials'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+ + WealthySmart + +
+ +
+
+ + setUsername(e.target.value)} + className="w-full bg-slate-900 border border-slate-800 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors" + placeholder="Enter username" + autoFocus + /> +
+
+ + setPassword(e.target.value)} + className="w-full bg-slate-900 border border-slate-800 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors" + placeholder="Enter password" + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+
+
+ ); +} diff --git a/frontend/src/pages/Transactions.tsx b/frontend/src/pages/Transactions.tsx new file mode 100644 index 0000000..b866819 --- /dev/null +++ b/frontend/src/pages/Transactions.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + Plus, + Search, + Pencil, + Trash2, + TrendingUp, + TrendingDown, + ChevronDown, +} from 'lucide-react'; + +import api, { type Transaction, type Category } from '../api'; +import TransactionModal from '../components/TransactionModal'; + +function formatAmount(amount: number, currency: string) { + const abs = Math.abs(amount); + if (currency === 'USD') { + return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +export default function Transactions() { + const [transactions, setTransactions] = useState([]); + const [categories, setCategories] = useState([]); + const [search, setSearch] = useState(''); + const [categoryFilter, setCategoryFilter] = useState(''); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + + const fetchTransactions = useCallback(async () => { + setLoading(true); + try { + const params: Record = { source: 'CREDIT_CARD', limit: '200' }; + if (search) params.search = search; + if (categoryFilter) params.category_id = categoryFilter; + const { data } = await api.get('/transactions/', { params }); + setTransactions(data); + } finally { + setLoading(false); + } + }, [search, categoryFilter]); + + useEffect(() => { + api.get('/categories/').then((r) => setCategories(r.data)); + }, []); + + useEffect(() => { + const timer = setTimeout(fetchTransactions, 300); + return () => clearTimeout(timer); + }, [fetchTransactions]); + + const handleDelete = async (id: number) => { + if (!confirm('Delete this transaction?')) return; + await api.delete(`/transactions/${id}`); + fetchTransactions(); + }; + + const total = transactions.reduce((sum, tx) => { + const signed = tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount; + return sum + signed; + }, 0); + + return ( +
+
+
+

Credit Card Transactions

+

+ {transactions.length} transactions · Total:{' '} + {formatAmount(total, 'CRC')} +

+
+ +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="w-full bg-slate-900/60 border border-slate-800/60 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 transition-colors" + placeholder="Search merchants..." + /> +
+
+ + +
+
+ + {/* Table */} +
+
+ + + + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + + ))} + +
+ Date + + Merchant + + Category + + Amount + + Actions +
+ + {new Date(tx.date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + +
+
+ {tx.transaction_type === 'DEVOLUCION' ? ( + + ) : ( + + )} +
+ {tx.merchant} +
+
+ {tx.category ? ( + + {tx.category.name} + + ) : ( + + )} + + + {tx.transaction_type === 'DEVOLUCION' ? '+' : '-'} + {formatAmount(tx.amount, tx.currency)} + + +
+ + +
+
+
+ + {transactions.length === 0 && !loading && ( +
+ No transactions found +
+ )} +
+ + {modalOpen && ( + setModalOpen(false)} + onSaved={fetchTransactions} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/Transfers.tsx b/frontend/src/pages/Transfers.tsx new file mode 100644 index 0000000..7a5db08 --- /dev/null +++ b/frontend/src/pages/Transfers.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Plus, Search, Pencil, Trash2, ArrowLeftRight } from 'lucide-react'; + +import api, { type Transaction } from '../api'; +import TransactionModal from '../components/TransactionModal'; + +function formatAmount(amount: number, currency: string) { + const abs = Math.abs(amount); + if (currency === 'USD') { + return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +type SourceTab = 'CASH' | 'TRANSFER'; + +export default function Transfers() { + const [transactions, setTransactions] = useState([]); + const [search, setSearch] = useState(''); + const [sourceTab, setSourceTab] = useState('CASH'); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + + const fetchTransactions = useCallback(async () => { + setLoading(true); + try { + const params: Record = { source: sourceTab, limit: '200' }; + if (search) params.search = search; + const { data } = await api.get('/transactions/', { params }); + setTransactions(data); + } finally { + setLoading(false); + } + }, [search, sourceTab]); + + useEffect(() => { + const timer = setTimeout(fetchTransactions, 300); + return () => clearTimeout(timer); + }, [fetchTransactions]); + + const handleDelete = async (id: number) => { + if (!confirm('Delete this transaction?')) return; + await api.delete(`/transactions/${id}`); + fetchTransactions(); + }; + + return ( +
+
+
+

Cash & Transfers

+

+ Track non-credit-card expenses +

+
+ +
+ + {/* Source tabs */} +
+ {(['CASH', 'TRANSFER'] as const).map((tab) => ( + + ))} +
+ + {/* Search */} +
+ + setSearch(e.target.value)} + className="w-full bg-slate-900/60 border border-slate-800/60 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 transition-colors" + placeholder="Search..." + /> +
+ + {/* List */} +
+ {transactions.length === 0 && !loading ? ( +
+ + No {sourceTab.toLowerCase()} transactions yet +
+ ) : ( + <> + {transactions.map((tx) => ( +
+
+

{tx.merchant}

+

+ {new Date(tx.date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + {tx.category && ( + + {tx.category.name} + + )} +

+
+
+ + {formatAmount(tx.amount, tx.currency)} + +
+ + +
+
+
+ ))} + + )} +
+ + {modalOpen && ( + setModalOpen(false)} + onSaved={fetchTransactions} + /> + )} +
+ ); +}