Add budget module: FastAPI backend + React frontend
Some checks failed
Deploy to VPS / deploy (push) Failing after 7s

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) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-21 11:33:38 -06:00
parent cfd2eba849
commit 13161b8e49
34 changed files with 1855 additions and 112 deletions

View File

@@ -14,6 +14,12 @@ jobs:
- name: Write .env.prod - name: Write .env.prod
run: | run: |
cat > .env.prod << 'ENVEOF' 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 }} LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
ENVEOF ENVEOF
@@ -24,10 +30,10 @@ jobs:
- name: Wait for health - name: Wait for health
run: | run: |
echo "Waiting for frontend..." echo "Waiting for backend..."
for i in $(seq 1 30); do for i in $(seq 1 30); do
if docker inspect wealthysmart-frontend-prod --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then if docker inspect wealthysmart-backend-prod --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then
echo "Frontend is healthy" echo "Backend is healthy"
break break
fi fi
sleep 2 sleep 2

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
node_modules/ node_modules/
dist/ dist/
__pycache__/
*.pyc
.env .env
.env.* .env.*
!.env.example !.env.example

6
backend/Dockerfile Normal file
View File

@@ -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"]

6
backend/Dockerfile.prod Normal file
View File

@@ -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"]

0
backend/app/__init__.py Normal file
View File

View File

View File

View File

View File

@@ -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

View File

@@ -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"}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

27
backend/app/auth.py Normal file
View File

@@ -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)

15
backend/app/config.py Normal file
View File

@@ -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()

14
backend/app/db.py Normal file
View File

@@ -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

34
backend/app/main.py Normal file
View File

@@ -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"}

View File

View File

@@ -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

45
backend/app/seed.py Normal file
View File

@@ -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()

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi[standard]
uvicorn[standard]
sqlmodel
psycopg2-binary
python-jose[cryptography]
passlib[bcrypt]
python-multipart
python-dotenv
alembic

View File

@@ -1,4 +1,47 @@
services: 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: frontend:
build: build:
context: ./frontend context: ./frontend
@@ -12,7 +55,10 @@ services:
expose: expose:
- "80" - "80"
networks: networks:
- wealthysmart-network
- nginx-prod-network - nginx-prod-network
depends_on:
- backend
healthcheck: healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"] test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
interval: 30s interval: 30s
@@ -20,5 +66,12 @@ services:
retries: 3 retries: 3
networks: networks:
wealthysmart-network:
driver: bridge
name: wealthysmart-network-prod
nginx-prod-network: nginx-prod-network:
external: true external: true
volumes:
postgres_data:
name: wealthysmart-postgres-prod-data

View File

@@ -1,11 +1,46 @@
services: 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: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: wealthysmart-frontend-dev container_name: wealthysmart-frontend-dev
ports: ports:
- "5173:5173" - "5174:5173"
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
volumes:
postgres_data:

View File

@@ -8,6 +8,16 @@ server {
try_files $uri $uri/ /index.html; 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 # Cache immutable assets
location /assets/ { location /assets/ {
expires 1y; expires 1y;

View File

@@ -1,113 +1,46 @@
import { import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
TrendingUp, import { AuthProvider, useAuth } from './AuthContext';
Shield, import Layout from './components/Layout';
Smartphone, import Login from './pages/Login';
BarChart3, import Dashboard from './pages/Dashboard';
Wallet, import Transactions from './pages/Transactions';
ArrowRight, import Transfers from './pages/Transfers';
} from 'lucide-react';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
function AppRoutes() {
const { isAuthenticated } = useAuth();
function App() {
return ( return (
<div className="min-h-screen bg-slate-950 text-white"> <Routes>
{/* Nav */} <Route
<nav className="border-b border-slate-800/60 backdrop-blur-sm sticky top-0 z-50 bg-slate-950/80"> path="/login"
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between"> element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
<div className="flex items-center gap-2.5"> />
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center"> <Route
<Wallet className="w-5 h-5 text-slate-950" strokeWidth={2.5} /> element={
</div> <ProtectedRoute>
<span className="text-xl font-bold tracking-tight"> <Layout />
Wealthy<span className="text-emerald-400">Smart</span> </ProtectedRoute>
</span> }
</div> >
<span className="text-xs font-medium text-slate-500 border border-slate-800 rounded-full px-3 py-1"> <Route path="/" element={<Dashboard />} />
Coming Soon <Route path="/transactions" element={<Transactions />} />
</span> <Route path="/transfers" element={<Transfers />} />
</div> </Route>
</nav> </Routes>
{/* Hero */}
<section className="max-w-6xl mx-auto px-6 pt-24 pb-20">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 text-emerald-400 text-sm font-medium mb-6 bg-emerald-400/10 rounded-full px-4 py-1.5">
<TrendingUp className="w-4 h-4" />
Personal Finance, Simplified
</div>
<h1 className="text-5xl sm:text-6xl font-extrabold leading-[1.1] tracking-tight mb-6">
Take control of your{' '}
<span className="bg-gradient-to-r from-emerald-400 to-cyan-400 bg-clip-text text-transparent">
financial future
</span>
</h1>
<p className="text-lg text-slate-400 leading-relaxed mb-4 max-w-xl">
Budget tracking, investment management, and financial insights all
in one place. Built to replace spreadsheets with something smarter.
</p>
<p className="text-base text-slate-500 leading-relaxed mb-10 max-w-xl">
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.
</p>
<div className="flex items-center gap-3">
<div className="inline-flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-6 py-3 rounded-lg transition-colors">
Get Started
<ArrowRight className="w-4 h-4" />
</div>
</div>
</div>
</section>
{/* Features */}
<section className="max-w-6xl mx-auto px-6 pb-24">
<div className="grid md:grid-cols-3 gap-6">
{[
{
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 }) => (
<div
key={title}
className="group border border-slate-800/60 rounded-xl p-6 hover:border-emerald-500/30 transition-colors bg-slate-900/40"
>
<div className="w-10 h-10 rounded-lg bg-emerald-400/10 flex items-center justify-center mb-4 group-hover:bg-emerald-400/20 transition-colors">
<Icon className="w-5 h-5 text-emerald-400" />
</div>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-sm text-slate-400 leading-relaxed">{desc}</p>
</div>
))}
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-800/60 py-8">
<div className="max-w-6xl mx-auto px-6 flex items-center justify-between text-sm text-slate-500">
<span>&copy; {new Date().getFullYear()} WealthySmart</span>
<span>wealth.cescalante.dev</span>
</div>
</footer>
</div>
); );
} }
export default App; export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
);
}

View File

@@ -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<AuthCtx>({
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 (
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

69
frontend/src/api.ts Normal file
View File

@@ -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;
}

View File

@@ -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 (
<div className="min-h-screen bg-slate-950 text-white">
{/* Top bar */}
<header className="border-b border-slate-800/60 backdrop-blur-sm sticky top-0 z-50 bg-slate-950/90">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
<Wallet className="w-4 h-4 text-slate-950" strokeWidth={2.5} />
</div>
<span className="text-lg font-bold tracking-tight hidden sm:inline">
Wealthy<span className="text-emerald-400">Smart</span>
</span>
</div>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 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'
}`
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2">
<button
onClick={handleLogout}
className="hidden md:flex items-center gap-2 text-slate-500 hover:text-slate-300 text-sm transition-colors"
>
<LogOut className="w-4 h-4" />
</button>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="md:hidden text-slate-400"
>
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile nav */}
{mobileOpen && (
<div className="md:hidden border-t border-slate-800/60 px-4 pb-4 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
onClick={() => 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'
}`
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-800/50 w-full"
>
<LogOut className="w-4 h-4" />
Sign out
</button>
</div>
)}
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<Outlet />
</main>
</div>
);
}

View File

@@ -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<Category[]>([]);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/60">
<h3 className="font-semibold">
{transaction ? 'Edit Transaction' : 'New Transaction'}
</h3>
<button onClick={onClose} className="text-slate-500 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className={labelClass}>Merchant</label>
<input
className={inputClass}
value={form.merchant}
onChange={(e) => setForm({ ...form, merchant: e.target.value })}
placeholder="e.g. AUTO MERCADO ON LINE"
required
/>
</div>
<div>
<label className={labelClass}>Amount</label>
<input
className={inputClass}
type="number"
step="0.01"
value={form.amount}
onChange={(e) => setForm({ ...form, amount: e.target.value })}
placeholder="0.00"
required
/>
</div>
<div>
<label className={labelClass}>Currency</label>
<select
className={inputClass}
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
>
<option value="CRC">CRC ()</option>
<option value="USD">USD ($)</option>
</select>
</div>
<div>
<label className={labelClass}>Date</label>
<input
className={inputClass}
type="datetime-local"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>Type</label>
<select
className={inputClass}
value={form.transaction_type}
onChange={(e) => setForm({ ...form, transaction_type: e.target.value })}
>
<option value="COMPRA">Compra</option>
<option value="DEVOLUCION">Devolución</option>
</select>
</div>
<div>
<label className={labelClass}>Category</label>
<select
className={inputClass}
value={form.category_id}
onChange={(e) => setForm({ ...form, category_id: e.target.value })}
>
<option value="">Auto-detect</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Bank</label>
<select
className={inputClass}
value={form.bank}
onChange={(e) => setForm({ ...form, bank: e.target.value })}
>
<option value="BAC">BAC</option>
<option value="BCR">BCR</option>
<option value="DAVIVIENDA">Davivienda</option>
</select>
</div>
<div>
<label className={labelClass}>City</label>
<input
className={inputClass}
value={form.city}
onChange={(e) => setForm({ ...form, city: e.target.value })}
placeholder="SAN JOSE, Costa Rica"
/>
</div>
{source === 'CREDIT_CARD' && (
<>
<div>
<label className={labelClass}>Card Type</label>
<input
className={inputClass}
value={form.card_type}
onChange={(e) => setForm({ ...form, card_type: e.target.value })}
placeholder="MASTER"
/>
</div>
<div>
<label className={labelClass}>Card Last 4</label>
<input
className={inputClass}
value={form.card_last4}
onChange={(e) => setForm({ ...form, card_last4: e.target.value })}
placeholder="6585"
maxLength={4}
/>
</div>
</>
)}
<div className="col-span-2">
<label className={labelClass}>Notes</label>
<input
className={inputClass}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional notes"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-slate-400 border border-slate-800 hover:bg-slate-800/50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-400 text-slate-950 transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1 +1,10 @@
@import 'tailwindcss'; @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;
}

View File

@@ -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<Account[]>([]);
const [recent, setRecent] = useState<Transaction[]>([]);
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-slate-500 mt-1">Financial overview</p>
</div>
<button
onClick={fetchData}
className="p-2 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800/50 transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Account balances */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{accounts.map((account, i) => (
<div
key={account.id}
className="relative group animate-fade-in"
>
<div className="absolute -inset-[1px] rounded-xl bg-gradient-to-br from-emerald-500/20 to-cyan-500/20 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative bg-slate-900/60 border border-slate-800/60 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
{account.label}
</span>
<span className="text-[10px] font-mono text-slate-600 bg-slate-800/60 px-2 py-0.5 rounded">
{account.bank}
</span>
</div>
<p className="text-2xl font-bold font-mono tracking-tight">
{formatAmount(account.balance, account.currency)}
</p>
</div>
</div>
))}
{/* Totals */}
{accounts.length > 0 && (
<>
<div
className="bg-gradient-to-br from-emerald-500/10 to-cyan-500/5 border border-emerald-500/20 rounded-xl p-5"
>
<span className="text-xs font-medium text-emerald-400/80 uppercase tracking-wider">
Total CRC
</span>
<p className="text-2xl font-bold font-mono tracking-tight text-emerald-400 mt-3">
{formatAmount(totalCRC, 'CRC')}
</p>
</div>
<div
className="bg-gradient-to-br from-cyan-500/10 to-emerald-500/5 border border-cyan-500/20 rounded-xl p-5 animate-fade-in"
>
<span className="text-xs font-medium text-cyan-400/80 uppercase tracking-wider">
Total USD
</span>
<p className="text-2xl font-bold font-mono tracking-tight text-cyan-400 mt-3">
{formatAmount(totalUSD, 'USD')}
</p>
</div>
</>
)}
</div>
{/* Recent transactions */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/40">
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-slate-500" />
<h2 className="font-semibold text-sm">Recent Charges</h2>
</div>
<Link
to="/transactions"
className="flex items-center gap-1 text-xs font-medium text-emerald-400 hover:text-emerald-300 transition-colors"
>
View all
<ArrowRight className="w-3 h-3" />
</Link>
</div>
{recent.length === 0 && !loading ? (
<div className="px-5 py-12 text-center text-slate-600 text-sm">
No transactions yet. Add your first one!
</div>
) : (
<div className="divide-y divide-slate-800/40">
{recent.map((tx, i) => (
<div
key={tx.id}
className="flex items-center justify-between px-5 py-3.5 hover:bg-slate-800/20 transition-colors animate-fade-in"
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
tx.transaction_type === 'DEVOLUCION'
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-red-500/10 text-red-400'
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-slate-500">
{formatDate(tx.date)}
{tx.category && (
<span className="ml-2 text-slate-600">
{tx.category.name}
</span>
)}
</p>
</div>
</div>
<span
className={`font-mono text-sm font-medium flex-shrink-0 ml-4 ${
tx.transaction_type === 'DEVOLUCION'
? 'text-emerald-400'
: 'text-white'
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-slate-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm animate-fade-in">
<div className="flex items-center justify-center gap-2.5 mb-8">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
<Wallet className="w-6 h-6 text-slate-950" strokeWidth={2.5} />
</div>
<span className="text-2xl font-bold tracking-tight text-white">
Wealthy<span className="text-emerald-400">Smart</span>
</span>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 text-slate-950 font-semibold px-6 py-3 rounded-lg transition-colors"
>
{loading ? 'Signing in...' : 'Sign in'}
{!loading && <ArrowRight className="w-4 h-4" />}
</button>
</form>
</div>
</div>
);
}

View File

@@ -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<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const fetchTransactions = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { 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 (
<div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Credit Card Transactions</h1>
<p className="text-sm text-slate-500 mt-1">
{transactions.length} transactions &middot; Total:{' '}
<span className="font-mono text-white">{formatAmount(total, 'CRC')}</span>
</p>
</div>
<button
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<Plus className="w-4 h-4" />
Add Transaction
</button>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
value={search}
onChange={(e) => 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..."
/>
</div>
<div className="relative">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="appearance-none bg-slate-900/60 border border-slate-800/60 rounded-lg pl-4 pr-10 py-2.5 text-sm text-white focus:outline-none focus:border-emerald-500/50 transition-colors"
>
<option value="">All Categories</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
</div>
</div>
{/* Table */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800/40">
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
Date
</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
Merchant
</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider hidden md:table-cell">
Category
</th>
<th className="text-right px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
Amount
</th>
<th className="text-right px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider w-20">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{transactions.map((tx) => (
<tr
key={tx.id}
className="hover:bg-slate-800/20 transition-colors group"
>
<td className="px-5 py-3 whitespace-nowrap">
<span className="font-mono text-slate-400 text-xs">
{new Date(tx.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<div
className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
tx.transaction_type === 'DEVOLUCION'
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-red-500/10 text-red-400'
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
</div>
<span className="truncate max-w-[200px] sm:max-w-none">{tx.merchant}</span>
</div>
</td>
<td className="px-5 py-3 hidden md:table-cell">
{tx.category ? (
<span className="text-xs bg-slate-800/60 text-slate-400 px-2 py-1 rounded">
{tx.category.name}
</span>
) : (
<span className="text-xs text-slate-600"></span>
)}
</td>
<td className="px-5 py-3 text-right whitespace-nowrap">
<span
className={`font-mono font-medium ${
tx.transaction_type === 'DEVOLUCION'
? 'text-emerald-400'
: 'text-white'
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditing(tx);
setModalOpen(true);
}}
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{transactions.length === 0 && !loading && (
<div className="px-5 py-16 text-center text-slate-600 text-sm">
No transactions found
</div>
)}
</div>
{modalOpen && (
<TransactionModal
transaction={editing}
source="CREDIT_CARD"
onClose={() => setModalOpen(false)}
onSaved={fetchTransactions}
/>
)}
</div>
);
}

View File

@@ -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<Transaction[]>([]);
const [search, setSearch] = useState('');
const [sourceTab, setSourceTab] = useState<SourceTab>('CASH');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const fetchTransactions = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { 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 (
<div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Cash & Transfers</h1>
<p className="text-sm text-slate-500 mt-1">
Track non-credit-card expenses
</p>
</div>
<button
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<Plus className="w-4 h-4" />
Add {sourceTab === 'CASH' ? 'Cash Expense' : 'Transfer'}
</button>
</div>
{/* Source tabs */}
<div className="flex gap-1 bg-slate-900/40 border border-slate-800/60 rounded-lg p-1 w-fit">
{(['CASH', 'TRANSFER'] as const).map((tab) => (
<button
key={tab}
onClick={() => setSourceTab(tab)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
sourceTab === tab
? 'bg-emerald-500/10 text-emerald-400'
: 'text-slate-500 hover:text-white'
}`}
>
{tab === 'CASH' ? 'Cash' : 'Transfers'}
</button>
))}
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
value={search}
onChange={(e) => 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..."
/>
</div>
{/* List */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl divide-y divide-slate-800/30">
{transactions.length === 0 && !loading ? (
<div className="px-5 py-16 text-center text-slate-600 text-sm">
<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-slate-700" />
No {sourceTab.toLowerCase()} transactions yet
</div>
) : (
<>
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between px-5 py-4 hover:bg-slate-800/20 transition-colors group"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-slate-500 mt-0.5">
{new Date(tx.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
{tx.category && (
<span className="ml-2 bg-slate-800/60 text-slate-400 px-2 py-0.5 rounded">
{tx.category.name}
</span>
)}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-4">
<span className="font-mono text-sm font-medium text-white">
{formatAmount(tx.amount, tx.currency)}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditing(tx);
setModalOpen(true);
}}
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
))}
</>
)}
</div>
{modalOpen && (
<TransactionModal
transaction={editing}
source={sourceTab}
onClose={() => setModalOpen(false)}
onSaved={fetchTransactions}
/>
)}
</div>
);
}