mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:28:48 +02:00
Add budget module: FastAPI backend + React frontend
Some checks failed
Deploy to VPS / deploy (push) Failing after 7s
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:
12
.github/workflows/deploy.yml
vendored
12
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
6
backend/Dockerfile
Normal file
6
backend/Dockerfile
Normal 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
6
backend/Dockerfile.prod
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/endpoints/__init__.py
Normal file
0
backend/app/api/v1/endpoints/__init__.py
Normal file
51
backend/app/api/v1/endpoints/accounts.py
Normal file
51
backend/app/api/v1/endpoints/accounts.py
Normal 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
|
||||
22
backend/app/api/v1/endpoints/auth.py
Normal file
22
backend/app/api/v1/endpoints/auth.py
Normal 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"}
|
||||
61
backend/app/api/v1/endpoints/categories.py
Normal file
61
backend/app/api/v1/endpoints/categories.py
Normal 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()
|
||||
111
backend/app/api/v1/endpoints/transactions.py
Normal file
111
backend/app/api/v1/endpoints/transactions.py
Normal 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()
|
||||
9
backend/app/api/v1/router.py
Normal file
9
backend/app/api/v1/router.py
Normal 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
27
backend/app/auth.py
Normal 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
15
backend/app/config.py
Normal 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
14
backend/app/db.py
Normal 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
34
backend/app/main.py
Normal 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"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
135
backend/app/models/models.py
Normal file
135
backend/app/models/models.py
Normal 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
45
backend/app/seed.py
Normal 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
9
backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi[standard]
|
||||
uvicorn[standard]
|
||||
sqlmodel
|
||||
psycopg2-binary
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
python-multipart
|
||||
python-dotenv
|
||||
alembic
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white">
|
||||
{/* Nav */}
|
||||
<nav className="border-b border-slate-800/60 backdrop-blur-sm sticky top-0 z-50 bg-slate-950/80">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<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">
|
||||
<Wallet className="w-5 h-5 text-slate-950" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-xl font-bold tracking-tight">
|
||||
Wealthy<span className="text-emerald-400">Smart</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-slate-500 border border-slate-800 rounded-full px-3 py-1">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 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>© {new Date().getFullYear()} WealthySmart</span>
|
||||
<span>wealth.cescalante.dev</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/transactions" element={<Transactions />} />
|
||||
<Route path="/transfers" element={<Transfers />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
36
frontend/src/AuthContext.tsx
Normal file
36
frontend/src/AuthContext.tsx
Normal 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
69
frontend/src/api.ts
Normal 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;
|
||||
}
|
||||
118
frontend/src/components/Layout.tsx
Normal file
118
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
frontend/src/components/TransactionModal.tsx
Normal file
242
frontend/src/components/TransactionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
197
frontend/src/pages/Dashboard.tsx
Normal file
197
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/pages/Login.tsx
Normal file
89
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
frontend/src/pages/Transactions.tsx
Normal file
235
frontend/src/pages/Transactions.tsx
Normal 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 · 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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/pages/Transfers.tsx
Normal file
164
frontend/src/pages/Transfers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user