Add accounts expansion, analytics, exchange rates, API tokens, PWA support, and UI overhaul
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s

- Expand Account model with account_type (pension, savings, liability, crypto), new banks/currencies (BTC, XMR, FCL, ROP, VOL, MEMP, MPAT, MORTGAGE), and next_payment field
- Add exchange rate endpoint (BCCR integration), analytics endpoint, paste-import for transactions, and API token management
- Add PWA manifest, service worker, and app icons
- Redesign dashboard, transactions, transfers, and login pages with theme support
- Add billing cycle selector, confirm dialog, and paste import modal components
- One-time DB reset in deploy workflow for schema migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-21 18:23:47 -06:00
parent 1257b0dd61
commit 0a8e00e227
39 changed files with 2247 additions and 220 deletions

View File

@@ -24,6 +24,10 @@ jobs:
ENVEOF ENVEOF
sed -i 's/^[[:space:]]*//' .env.prod sed -i 's/^[[:space:]]*//' .env.prod
- name: Reset database (one-time schema migration)
run: |
docker compose -f docker-compose.prod.yml --env-file .env.prod down -v || true
- name: Build and deploy - name: Build and deploy
run: | run: |
docker compose -f docker-compose.prod.yml --env-file .env.prod build docker compose -f docker-compose.prod.yml --env-file .env.prod build

View File

@@ -0,0 +1,184 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session, func, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Category, Transaction
from app.api.v1.endpoints.transactions import get_cycle_range
router = APIRouter(prefix="/analytics", tags=["analytics"])
class CategorySpending(BaseModel):
category_id: Optional[int]
category_name: str
total: float
count: int
percentage: float
class MonthlyTrend(BaseModel):
year: int
month: int
label: str
total_crc: float
total_usd: float
count: int
class DailySpending(BaseModel):
date: str
total: float
count: int
@router.get("/by-category", response_model=list[CategorySpending])
def spending_by_category(
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
query = (
select(
Transaction.category_id,
func.sum(Transaction.amount).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == "COMPRA")
.group_by(Transaction.category_id)
)
if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)
rows = session.exec(query).all()
grand_total = sum(r[1] for r in rows) or 1
results = []
for category_id, total, count in rows:
cat_name = "Uncategorized"
if category_id:
cat = session.get(Category, category_id)
if cat:
cat_name = cat.name
results.append(
CategorySpending(
category_id=category_id,
category_name=cat_name,
total=float(total),
count=count,
percentage=round(float(total) / grand_total * 100, 1),
)
)
return sorted(results, key=lambda x: x.total, reverse=True)
@router.get("/monthly-trend", response_model=list[MonthlyTrend])
def monthly_trend(
months: int = 6,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Monthly spending totals using billing cycle boundaries (18th-18th)."""
now = datetime.now()
results = []
month_names = [
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
y, m = now.year, now.month
for _ in range(months):
start, end = get_cycle_range(y, m)
row = session.exec(
select(
func.count(),
func.coalesce(
func.sum(
func.case(
(Transaction.currency == "CRC", Transaction.amount),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
func.case(
(Transaction.currency == "USD", Transaction.amount),
else_=0,
)
),
0,
),
)
.where(
Transaction.transaction_type == "COMPRA",
Transaction.date >= start,
Transaction.date < end,
)
).first()
count = row[0] if row else 0
total_crc = float(row[1]) if row else 0.0
total_usd = float(row[2]) if row else 0.0
end_month = m + 1 if m < 12 else 1
label = f"{month_names[m]} - {month_names[end_month]}"
results.append(
MonthlyTrend(
year=y,
month=m,
label=label,
total_crc=total_crc,
total_usd=total_usd,
count=count,
)
)
# Previous month
if m == 1:
y, m = y - 1, 12
else:
m -= 1
return list(reversed(results))
@router.get("/daily-spending", response_model=list[DailySpending])
def daily_spending(
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
query = (
select(
func.date(Transaction.date).label("day"),
func.sum(Transaction.amount).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == "COMPRA")
.group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date))
)
if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)
rows = session.exec(query).all()
return [
DailySpending(date=str(day), total=float(total), count=count)
for day, total, count in rows
]

View File

@@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from app.auth import get_current_user
from app.db import get_session
from app.models.models import ExchangeRateRead
from app.services.exchange_rate import get_current_rate, get_rate_history
router = APIRouter(prefix="/exchange-rate", tags=["exchange-rate"])
@router.get("/", response_model=ExchangeRateRead)
def current_rate(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rate = get_current_rate(session)
if not rate:
raise HTTPException(status_code=503, detail="Exchange rate unavailable")
return rate
@router.get("/history", response_model=list[ExchangeRateRead])
def rate_history(
days: int = 30,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
return get_rate_history(session, days)

View File

@@ -0,0 +1,149 @@
import hashlib
import re
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
Bank,
Currency,
Transaction,
TransactionSource,
TransactionType,
)
from app.api.v1.endpoints.transactions import auto_categorize
router = APIRouter(prefix="/import", tags=["import"])
class PasteImportRequest(BaseModel):
text: str
bank: Bank = Bank.BAC
source: TransactionSource = TransactionSource.CREDIT_CARD
class PasteImportResult(BaseModel):
imported: int
duplicates: int
errors: list[str]
def make_reference_hash(date: datetime, merchant: str, amount: float, currency: str) -> str:
raw = f"{date.isoformat()}|{merchant.strip().upper()}|{amount}|{currency}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def parse_bac_line(line: str) -> Optional[dict]:
"""Parse a single BAC statement line.
Format: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CURRENCY
"""
line = line.strip()
if not line:
return None
parts = re.split(r"\t+", line)
if len(parts) < 3:
# Try multiple spaces as delimiter
parts = re.split(r"\s{2,}", line)
if len(parts) < 3:
return None
# Parse date
date_str = parts[0].strip()
try:
date = datetime.strptime(date_str, "%d/%m/%Y")
except ValueError:
return None
# Parse merchant\city\country
merchant_raw = parts[1].strip()
merchant_parts = merchant_raw.split("\\")
merchant = merchant_parts[0].strip()
city = merchant_parts[1].strip() if len(merchant_parts) > 1 else None
# Parse amount + currency
amount_str = parts[2].strip()
# Extract currency (last 3 chars)
match = re.match(r"^([\d,.-]+)\s*(CRC|USD)$", amount_str, re.IGNORECASE)
if not match:
return None
amount_raw = match.group(1).replace(",", "")
currency = match.group(2).upper()
amount = float(amount_raw)
# Determine transaction type
is_refund = amount < 0 or "PAGO RECIBIDO" in merchant.upper()
tx_type = TransactionType.DEVOLUCION if is_refund else TransactionType.COMPRA
amount = abs(amount)
return {
"date": date,
"merchant": merchant,
"city": city,
"amount": amount,
"currency": currency,
"transaction_type": tx_type,
}
@router.post("/paste", response_model=PasteImportResult)
def paste_import(
req: PasteImportRequest,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
imported = 0
duplicates = 0
errors: list[str] = []
lines = req.text.strip().splitlines()
for i, line in enumerate(lines, 1):
if not line.strip():
continue
parsed = parse_bac_line(line)
if parsed is None:
errors.append(f"Line {i}: could not parse")
continue
# Generate reference hash for duplicate detection
ref_hash = make_reference_hash(
parsed["date"], parsed["merchant"], parsed["amount"], parsed["currency"]
)
# Check for duplicates
existing = session.exec(
select(Transaction).where(Transaction.reference == ref_hash)
).first()
if existing:
duplicates += 1
continue
tx = Transaction(
amount=parsed["amount"],
currency=Currency(parsed["currency"]),
merchant=parsed["merchant"],
city=parsed["city"],
date=parsed["date"],
transaction_type=parsed["transaction_type"],
source=req.source,
bank=req.bank,
reference=ref_hash,
)
tx.category_id = auto_categorize(tx.merchant, session)
session.add(tx)
imported += 1
if imported > 0:
session.commit()
return PasteImportResult(imported=imported, duplicates=duplicates, errors=errors)

View File

@@ -0,0 +1,66 @@
import secrets
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user, hash_token
from app.db import get_session
from app.models.models import APIToken, APITokenCreate, APITokenRead
router = APIRouter(prefix="/tokens", tags=["tokens"])
class TokenCreatedResponse(BaseModel):
token: str
name: str
expires_at: Optional[datetime]
@router.post("/", response_model=TokenCreatedResponse, status_code=201)
def create_token(
data: APITokenCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
plaintext = secrets.token_urlsafe(32)
expires_at = None
if data.expires_days:
expires_at = datetime.utcnow() + timedelta(days=data.expires_days)
api_token = APIToken(
name=data.name,
token_hash=hash_token(plaintext),
expires_at=expires_at,
)
session.add(api_token)
session.commit()
session.refresh(api_token)
return TokenCreatedResponse(token=plaintext, name=api_token.name, expires_at=api_token.expires_at)
@router.get("/", response_model=list[APITokenRead])
def list_tokens(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
return list(session.exec(
select(APIToken).where(APIToken.is_active == True).order_by(APIToken.created_at.desc())
).all())
@router.delete("/{token_id}", status_code=204)
def revoke_token(
token_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
token = session.get(APIToken, token_id)
if not token:
raise HTTPException(status_code=404, detail="Token not found")
token.is_active = False
session.add(token)
session.commit()

View File

@@ -1,7 +1,9 @@
from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, col, select from pydantic import BaseModel
from sqlmodel import Session, col, func, select
from app.auth import get_current_user from app.auth import get_current_user
from app.db import get_session from app.db import get_session
@@ -17,6 +19,24 @@ from app.models.models import (
router = APIRouter(prefix="/transactions", tags=["transactions"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
class BillingCycle(BaseModel):
year: int
month: int
label: str
count: int
total: float
def auto_categorize(merchant: str, session: Session) -> Optional[int]: def auto_categorize(merchant: str, session: Session) -> Optional[int]:
categories = session.exec(select(Category)).all() categories = session.exec(select(Category)).all()
merchant_lower = merchant.lower() merchant_lower = merchant.lower()
@@ -33,6 +53,8 @@ def list_transactions(
source: Optional[TransactionSource] = None, source: Optional[TransactionSource] = None,
search: Optional[str] = None, search: Optional[str] = None,
category_id: Optional[int] = None, category_id: Optional[int] = None,
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
limit: int = Query(default=50, le=500), limit: int = Query(default=50, le=500),
offset: int = 0, offset: int = 0,
session: Session = Depends(get_session), session: Session = Depends(get_session),
@@ -45,10 +67,72 @@ def list_transactions(
query = query.where(Transaction.category_id == category_id) query = query.where(Transaction.category_id == category_id)
if search: if search:
query = query.where(col(Transaction.merchant).ilike(f"%{search}%")) query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit) query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
return session.exec(query).all() return session.exec(query).all()
@router.get("/cycles", response_model=list[BillingCycle])
def list_billing_cycles(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Return available billing cycles based on transaction dates."""
# Get date range of all transactions
result = session.exec(
select(func.min(Transaction.date), func.max(Transaction.date))
).first()
if not result or not result[0]:
return []
min_date, max_date = result
cycles = []
# Determine which cycle the min_date falls into
if min_date.day < 18:
# Falls in previous month's cycle
if min_date.month == 1:
y, m = min_date.year - 1, 12
else:
y, m = min_date.year, min_date.month - 1
else:
y, m = min_date.year, min_date.month
while True:
start, end = get_cycle_range(y, m)
if start > max_date:
break
# Count transactions in this cycle
count_result = session.exec(
select(func.count(), func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.date >= start, Transaction.date < end
)
).first()
count = count_result[0] if count_result else 0
total = float(count_result[1]) if count_result else 0.0
if count > 0:
month_names = [
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
end_month = m + 1 if m < 12 else 1
end_year = y if m < 12 else y + 1
label = f"{month_names[m]} 18 - {month_names[end_month]} 18, {end_year}"
cycles.append(BillingCycle(year=y, month=m, label=label, count=count, total=total))
# Next month
if m == 12:
y, m = y + 1, 1
else:
m += 1
return list(reversed(cycles))
@router.get("/recent", response_model=list[TransactionRead]) @router.get("/recent", response_model=list[TransactionRead])
def recent_transactions( def recent_transactions(
limit: int = Query(default=5, le=20), limit: int = Query(default=5, le=20),
@@ -71,6 +155,16 @@ def create_transaction(
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
tx = Transaction.model_validate(data) tx = Transaction.model_validate(data)
# Duplicate detection by reference
if tx.reference:
existing = session.exec(
select(Transaction).where(Transaction.reference == tx.reference)
).first()
if existing:
raise HTTPException(
status_code=409,
detail=f"Duplicate transaction: reference '{tx.reference}' already exists (id={existing.id})",
)
if tx.category_id is None: if tx.category_id is None:
tx.category_id = auto_categorize(tx.merchant, session) tx.category_id = auto_categorize(tx.merchant, session)
session.add(tx) session.add(tx)

View File

@@ -1,9 +1,22 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import accounts, auth, categories, transactions from app.api.v1.endpoints import (
accounts,
analytics,
auth,
categories,
exchange_rate,
import_transactions,
tokens,
transactions,
)
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(accounts.router) api_router.include_router(accounts.router)
api_router.include_router(categories.router) api_router.include_router(categories.router)
api_router.include_router(transactions.router) api_router.include_router(transactions.router)
api_router.include_router(import_transactions.router)
api_router.include_router(exchange_rate.router)
api_router.include_router(tokens.router)
api_router.include_router(analytics.router)

View File

@@ -1,8 +1,10 @@
import hashlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from sqlmodel import Session, select
from app.config import settings from app.config import settings
@@ -16,7 +18,12 @@ def create_access_token(subject: str) -> str:
return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM) return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def get_current_user(token: str = Depends(oauth2_scheme)) -> str: def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
# Try JWT first
try: try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub") username: str = payload.get("sub")
@@ -24,4 +31,23 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return username return username
except JWTError: except JWTError:
pass
# Fallback: check API token
from app.db import get_session
from app.models.models import APIToken
token_hash = hash_token(token)
with next(get_session()) as session:
api_token = session.exec(
select(APIToken).where(
APIToken.token_hash == token_hash,
APIToken.is_active == True,
)
).first()
if api_token:
if api_token.expires_at and api_token.expires_at < datetime.utcnow():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
return f"api:{api_token.name}"
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -7,6 +7,8 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 30 # 30 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 30 # 30 days
ADMIN_USERNAME: str = "admin" ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "admin" ADMIN_PASSWORD: str = "admin"
BCCR_API_EMAIL: str = ""
BCCR_API_TOKEN: str = ""
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -19,12 +19,28 @@ class TransactionSource(str, enum.Enum):
class Currency(str, enum.Enum): class Currency(str, enum.Enum):
CRC = "CRC" CRC = "CRC"
USD = "USD" USD = "USD"
BTC = "BTC"
XMR = "XMR"
class Bank(str, enum.Enum): class Bank(str, enum.Enum):
BAC = "BAC" BAC = "BAC"
BCR = "BCR" BCR = "BCR"
DAVIVIENDA = "DAVIVIENDA" DAVIVIENDA = "DAVIVIENDA"
FCL = "FCL"
ROP = "ROP"
VOL = "VOL"
MEMP = "MEMP"
MPAT = "MPAT"
MORTGAGE = "MORTGAGE"
class AccountType(str, enum.Enum):
BANK = "BANK"
PENSION = "PENSION"
CRYPTO = "CRYPTO"
SAVINGS = "SAVINGS"
LIABILITY = "LIABILITY"
# --- Category --- # --- Category ---
@@ -64,8 +80,10 @@ class CategoryUpdate(SQLModel):
class AccountBase(SQLModel): class AccountBase(SQLModel):
bank: Bank bank: Bank
currency: Currency currency: Currency
label: str = Field(description="e.g. 'BAC Colones', 'BAC Dólares'") label: str
balance: float = 0.0 balance: float = 0.0
account_type: AccountType = AccountType.BANK
next_payment: Optional[float] = None
class Account(AccountBase, table=True): class Account(AccountBase, table=True):
@@ -85,6 +103,7 @@ class AccountRead(AccountBase):
class AccountUpdate(SQLModel): class AccountUpdate(SQLModel):
balance: Optional[float] = None balance: Optional[float] = None
label: Optional[str] = None label: Optional[str] = None
next_payment: Optional[float] = None
# --- Transaction --- # --- Transaction ---
@@ -99,7 +118,7 @@ class TransactionBase(SQLModel):
card_type: Optional[str] = None card_type: Optional[str] = None
card_last4: Optional[str] = None card_last4: Optional[str] = None
authorization_code: Optional[str] = None authorization_code: Optional[str] = None
reference: Optional[str] = None reference: Optional[str] = Field(default=None, index=True)
transaction_type: TransactionType = TransactionType.COMPRA transaction_type: TransactionType = TransactionType.COMPRA
source: TransactionSource = TransactionSource.CREDIT_CARD source: TransactionSource = TransactionSource.CREDIT_CARD
bank: Bank = Bank.BAC bank: Bank = Bank.BAC
@@ -133,3 +152,46 @@ class TransactionUpdate(SQLModel):
source: Optional[TransactionSource] = None source: Optional[TransactionSource] = None
notes: Optional[str] = None notes: Optional[str] = None
category_id: Optional[int] = None category_id: Optional[int] = None
# --- Exchange Rate ---
class ExchangeRate(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
date: datetime
buy_rate: float
sell_rate: float
fetched_at: datetime = Field(default_factory=datetime.utcnow)
class ExchangeRateRead(SQLModel):
buy_rate: float
sell_rate: float
date: datetime
fetched_at: datetime
# --- API Token ---
class APIToken(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
token_hash: str = Field(index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: Optional[datetime] = None
is_active: bool = True
class APITokenCreate(SQLModel):
name: str
expires_days: Optional[int] = None
class APITokenRead(SQLModel):
id: int
name: str
created_at: datetime
expires_at: Optional[datetime]
is_active: bool

View File

@@ -1,7 +1,7 @@
from sqlmodel import Session, select from sqlmodel import Session, select
from app.db import engine from app.db import engine
from app.models.models import Account, Bank, Category, Currency from app.models.models import Account, AccountType, Bank, Category, Currency
DEFAULT_CATEGORIES = [ DEFAULT_CATEGORIES = [
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"), ("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"),
@@ -23,10 +23,25 @@ DEFAULT_CATEGORIES = [
] ]
DEFAULT_ACCOUNTS = [ DEFAULT_ACCOUNTS = [
(Bank.BAC, Currency.CRC, "BAC Colones", 0.0), # Bank accounts
(Bank.BAC, Currency.USD, "BAC Dólares", 0.0), (Bank.BAC, Currency.CRC, "BAC", AccountType.BANK),
(Bank.BCR, Currency.CRC, "BCR Colones", 0.0), (Bank.BAC, Currency.USD, "BAC", AccountType.BANK),
(Bank.DAVIVIENDA, Currency.CRC, "Davivienda Colones", 0.0), (Bank.BCR, Currency.CRC, "BCR", AccountType.BANK),
(Bank.BCR, Currency.USD, "BCR", AccountType.BANK),
(Bank.DAVIVIENDA, Currency.CRC, "DAV", AccountType.BANK),
(Bank.DAVIVIENDA, Currency.USD, "DAV", AccountType.BANK),
# Pensions (CRC)
(Bank.FCL, Currency.CRC, "FCL", AccountType.PENSION),
(Bank.ROP, Currency.CRC, "ROP", AccountType.PENSION),
(Bank.VOL, Currency.CRC, "VOL", AccountType.PENSION),
# Savings (CRC)
(Bank.MEMP, Currency.CRC, "MEMP", AccountType.SAVINGS),
(Bank.MPAT, Currency.CRC, "MPAT", AccountType.SAVINGS),
# Liabilities
(Bank.MORTGAGE, Currency.USD, "Mortgage", AccountType.LIABILITY),
# Crypto
(Bank.BAC, Currency.BTC, "BTC", AccountType.CRYPTO),
(Bank.BAC, Currency.XMR, "XMR", AccountType.CRYPTO),
] ]
@@ -40,6 +55,6 @@ def seed_db():
existing_acc = session.exec(select(Account)).first() existing_acc = session.exec(select(Account)).first()
if not existing_acc: if not existing_acc:
for bank, currency, label, balance in DEFAULT_ACCOUNTS: for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
session.add(Account(bank=bank, currency=currency, label=label, balance=balance)) session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
session.commit() session.commit()

View File

View File

@@ -0,0 +1,95 @@
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
import httpx
from sqlmodel import Session, col, select
from app.config import settings
from app.models.models import ExchangeRate
# BCCR indicators: 317 = buy, 318 = sell
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
_cache: dict[str, tuple[ExchangeRate, datetime]] = {}
CACHE_TTL = timedelta(hours=1)
def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
"""Fetch a single indicator from BCCR API."""
try:
params = {
"Indicador": str(indicator),
"FechaInicio": date_str,
"FechaFinal": date_str,
"Nombre": settings.BCCR_API_EMAIL or "WealthySmart",
"SubNiveles": "N",
"CorreoElectronico": settings.BCCR_API_EMAIL or "no-reply@example.com",
"Token": settings.BCCR_API_TOKEN or "",
}
resp = httpx.get(BCCR_URL, params=params, timeout=10)
resp.raise_for_status()
# Parse XML response
root = ET.fromstring(resp.text)
# The value is in INGC011_DES_DATOS > NUM_VALOR
ns = {"": "http://ws.sdde.bccr.fi.cr"}
for datos in root.iter():
if datos.tag.endswith("NUM_VALOR"):
return float(datos.text.strip().replace(",", "."))
except Exception:
pass
return None
def get_current_rate(session: Session) -> ExchangeRate | None:
"""Get current USD/CRC rate. Uses in-memory cache + DB fallback."""
# Check memory cache
cached = _cache.get("current")
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
return cached[0]
# Check DB for recent rate
one_hour_ago = datetime.utcnow() - CACHE_TTL
db_rate = session.exec(
select(ExchangeRate)
.where(ExchangeRate.fetched_at > one_hour_ago)
.order_by(col(ExchangeRate.fetched_at).desc())
).first()
if db_rate:
_cache["current"] = (db_rate, datetime.utcnow())
return db_rate
# Fetch from BCCR
today = datetime.now().strftime("%d/%m/%Y")
buy = _fetch_bccr_rate(317, today)
sell = _fetch_bccr_rate(318, today)
if buy is None or sell is None:
# Fallback: return most recent DB rate regardless of age
fallback = session.exec(
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
).first()
return fallback
rate = ExchangeRate(
date=datetime.utcnow(),
buy_rate=buy,
sell_rate=sell,
)
session.add(rate)
session.commit()
session.refresh(rate)
_cache["current"] = (rate, datetime.utcnow())
return rate
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
"""Get historical exchange rates."""
cutoff = datetime.utcnow() - timedelta(days=days)
return list(
session.exec(
select(ExchangeRate)
.where(ExchangeRate.date > cutoff)
.order_by(col(ExchangeRate.date).desc())
).all()
)

View File

@@ -7,3 +7,4 @@ passlib[bcrypt]
python-multipart python-multipart
python-dotenv python-dotenv
alembic alembic
httpx

View File

@@ -24,7 +24,7 @@ services:
environment: environment:
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
ports: ports:
- "8000:8000" - "8001:8000"
volumes: volumes:
- ./backend:/app - ./backend:/app
depends_on: depends_on:
@@ -37,7 +37,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: wealthysmart-frontend-dev container_name: wealthysmart-frontend-dev
ports: ports:
- "5174:5173" - "5175:5173"
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules

View File

@@ -1,11 +1,19 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<script>
const t = localStorage.getItem('theme');
if (t === 'light' || (!t && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.remove('dark');
}
</script>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="WealthySmart — Smart personal finance management" /> <meta name="description" content="WealthySmart — Smart personal finance management" />
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#0f172a" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>WealthySmart</title> <title>WealthySmart</title>
</head> </head>
<body> <body>

View File

@@ -18,6 +18,11 @@ server {
proxy_read_timeout 120s; proxy_read_timeout 120s;
} }
# No cache for service worker
location /sw.js {
add_header Cache-Control "no-cache";
}
# Cache immutable assets # Cache immutable assets
location /assets/ { location /assets/ {
expires 1y; expires 1y;

View File

@@ -13,7 +13,8 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.12.0" "react-router-dom": "^7.12.0",
"recharts": "^3.8.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",

318
frontend/pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
react-router-dom: react-router-dom:
specifier: ^7.12.0 specifier: ^7.12.0
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
recharts:
specifier: ^3.8.0
version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1)
devDependencies: devDependencies:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.18 specifier: ^4.1.18
@@ -220,6 +223,17 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/pluginutils@1.0.0-rc.7': '@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
@@ -348,6 +362,12 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/core-darwin-arm64@1.15.18': '@swc/core-darwin-arm64@1.15.18':
resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -513,6 +533,33 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8 vite: ^5.2.0 || ^6 || ^7 || ^8
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -524,6 +571,9 @@ packages:
'@types/react@19.2.14': '@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@vitejs/plugin-react-swc@4.3.0': '@vitejs/plugin-react-swc@4.3.0':
resolution: {integrity: sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==} resolution: {integrity: sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -540,6 +590,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -551,6 +605,53 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
delayed-stream@1.0.0: delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -583,11 +684,17 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.45.1:
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
esbuild@0.27.4: esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -645,6 +752,16 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
jiti@2.6.1: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
@@ -763,6 +880,21 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.4 react: ^19.2.4
react-is@19.2.4:
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-router-dom@7.13.1: react-router-dom@7.13.1:
resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -784,6 +916,25 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
recharts@3.8.0:
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
rollup@4.59.1: rollup@4.59.1:
resolution: {integrity: sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==} resolution: {integrity: sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -806,6 +957,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -815,6 +969,14 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@7.3.1: vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -954,6 +1116,18 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.4
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
'@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.7': {}
'@rollup/rollup-android-arm-eabi@4.59.1': '@rollup/rollup-android-arm-eabi@4.59.1':
@@ -1031,6 +1205,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.1': '@rollup/rollup-win32-x64-msvc@4.59.1':
optional: true optional: true
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@swc/core-darwin-arm64@1.15.18': '@swc/core-darwin-arm64@1.15.18':
optional: true optional: true
@@ -1151,6 +1329,30 @@ snapshots:
tailwindcss: 4.2.2 tailwindcss: 4.2.2
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)': '@types/react-dom@19.2.3(@types/react@19.2.14)':
@@ -1161,6 +1363,8 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/use-sync-external-store@0.0.6': {}
'@vitejs/plugin-react-swc@4.3.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': '@vitejs/plugin-react-swc@4.3.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7 '@rolldown/pluginutils': 1.0.0-rc.7
@@ -1184,6 +1388,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
function-bind: 1.1.2 function-bind: 1.1.2
clsx@2.1.1: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -1192,6 +1398,46 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
decimal.js-light@2.5.1: {}
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@@ -1222,6 +1468,8 @@ snapshots:
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
hasown: 2.0.2 hasown: 2.0.2
es-toolkit@1.45.1: {}
esbuild@0.27.4: esbuild@0.27.4:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4 '@esbuild/aix-ppc64': 0.27.4
@@ -1251,6 +1499,8 @@ snapshots:
'@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4 '@esbuild/win32-x64': 0.27.4
eventemitter3@5.0.4: {}
fdir@6.5.0(picomatch@4.0.3): fdir@6.5.0(picomatch@4.0.3):
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
@@ -1302,6 +1552,12 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
immer@10.2.0: {}
immer@11.1.4: {}
internmap@2.0.3: {}
jiti@2.6.1: {} jiti@2.6.1: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
@@ -1388,6 +1644,17 @@ snapshots:
react: 19.2.4 react: 19.2.4
scheduler: 0.27.0 scheduler: 0.27.0
react-is@19.2.4: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
@@ -1404,6 +1671,34 @@ snapshots:
react@19.2.4: {} react@19.2.4: {}
recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.45.1
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-is: 19.2.4
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.4)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reselect@5.1.1: {}
rollup@4.59.1: rollup@4.59.1:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -1445,6 +1740,8 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -1452,6 +1749,27 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0):
dependencies: dependencies:
esbuild: 0.27.4 esbuild: 0.27.4

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,27 @@
{
"name": "WealthySmart",
"short_name": "WealthySmart",
"description": "Smart personal finance management",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

52
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,52 @@
const CACHE_NAME = 'wealthysmart-v1';
const STATIC_ASSETS = ['/', '/index.html'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Network-first for API calls
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(request).catch(() => caches.match(request)));
return;
}
// Cache-first for static assets
if (url.pathname.startsWith('/assets/')) {
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return res;
}))
);
return;
}
// Network-first for navigation, fallback to cached index.html
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/index.html'))
);
return;
}
// Default: network with cache fallback
event.respondWith(fetch(request).catch(() => caches.match(request)));
});

View File

@@ -1,10 +1,12 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './AuthContext'; import { AuthProvider, useAuth } from './AuthContext';
import { ThemeProvider } from './ThemeContext';
import Layout from './components/Layout'; import Layout from './components/Layout';
import Login from './pages/Login'; import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Transactions from './pages/Transactions'; import Transactions from './pages/Transactions';
import Transfers from './pages/Transfers'; import Transfers from './pages/Transfers';
import Analytics from './pages/Analytics';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
@@ -29,6 +31,7 @@ function AppRoutes() {
> >
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/transactions" element={<Transactions />} /> <Route path="/transactions" element={<Transactions />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/transfers" element={<Transfers />} /> <Route path="/transfers" element={<Transfers />} />
</Route> </Route>
</Routes> </Routes>
@@ -38,9 +41,11 @@ function AppRoutes() {
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<ThemeProvider>
<AuthProvider> <AuthProvider>
<AppRoutes /> <AppRoutes />
</AuthProvider> </AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
); );
} }

View File

@@ -0,0 +1,31 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({ theme: 'dark', toggleTheme: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('theme') as Theme;
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@@ -38,6 +38,8 @@ export interface Account {
currency: string; currency: string;
label: string; label: string;
balance: number; balance: number;
account_type: string;
next_payment: number | null;
updated_at: string; updated_at: string;
} }
@@ -48,6 +50,12 @@ export interface Category {
auto_match_patterns: string | null; auto_match_patterns: string | null;
} }
export interface ImportResult {
imported: number;
duplicates: number;
errors: string[];
}
export interface Transaction { export interface Transaction {
id: number; id: number;
amount: number; amount: number;

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { Calendar, ChevronDown } from 'lucide-react';
import api from '../api';
export interface CycleOption {
year: number;
month: number;
label: string;
count: number;
total: number;
}
interface Props {
value: { year: number; month: number } | null;
onChange: (cycle: { year: number; month: number } | null) => void;
}
export default function BillingCycleSelector({ value, onChange }: Props) {
const [cycles, setCycles] = useState<CycleOption[]>([]);
useEffect(() => {
api.get('/transactions/cycles').then((r) => setCycles(r.data));
}, []);
const selectedKey = value ? `${value.year}-${value.month}` : '';
return (
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-text-muted" />
<div className="relative">
<select
value={selectedKey}
onChange={(e) => {
if (!e.target.value) {
onChange(null);
} else {
const [y, m] = e.target.value.split('-').map(Number);
onChange({ year: y, month: m });
}
}}
className="appearance-none bg-input-bg border border-border rounded-lg pl-3 pr-9 py-2 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
>
<option value="">All time</option>
{cycles.map((c) => (
<option key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
{c.label} ({c.count})
</option>
))}
</select>
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { AlertTriangle, X } from 'lucide-react';
interface Props {
title: string;
message: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
}
export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={onCancel}>
<div
className="bg-surface border border-border rounded-xl w-full max-w-sm animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold">{title}</h3>
<button onClick={onCancel} className="text-text-muted hover:text-text-primary transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-5">
<div className="flex gap-3 items-start">
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-5 h-5 text-red-500 dark:text-red-400" />
</div>
<p className="text-sm text-text-secondary pt-2">{message}</p>
</div>
</div>
<div className="flex gap-3 px-5 pb-5">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-red-500 hover:bg-red-600 text-white transition-colors disabled:opacity-50"
>
{loading ? 'Deleting...' : confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -2,23 +2,29 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { import {
LayoutDashboard, LayoutDashboard,
CreditCard, CreditCard,
BarChart3,
ArrowLeftRight, ArrowLeftRight,
LogOut, LogOut,
Wallet, Wallet,
Menu, Menu,
X, X,
Sun,
Moon,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import { useTheme } from '../ThemeContext';
const navItems = [ const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/transactions', icon: CreditCard, label: 'Transactions' }, { to: '/transactions', icon: CreditCard, label: 'Transactions' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' }, { to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
]; ];
export default function Layout() { export default function Layout() {
const { logout } = useAuth(); const { logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
@@ -28,16 +34,16 @@ export default function Layout() {
}; };
return ( return (
<div className="min-h-screen bg-slate-950 text-white"> <div className="min-h-screen bg-surface text-text-primary">
{/* Top bar */} {/* Top bar */}
<header className="border-b border-slate-800/60 backdrop-blur-sm sticky top-0 z-50 bg-slate-950/90"> <header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-surface/90">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between"> <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="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"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center">
<Wallet className="w-4 h-4 text-slate-950" strokeWidth={2.5} /> <Wallet className="w-4 h-4 text-[#FEFAE0]" strokeWidth={2.5} />
</div> </div>
<span className="text-lg font-bold tracking-tight hidden sm:inline"> <span className="text-lg font-bold tracking-tight hidden sm:inline">
Wealthy<span className="text-emerald-400">Smart</span> Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span>
</span> </span>
</div> </div>
@@ -51,8 +57,8 @@ export default function Layout() {
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ `flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive isActive
? 'bg-emerald-500/10 text-emerald-400' ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'text-slate-400 hover:text-white hover:bg-slate-800/50' : 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
}` }`
} }
> >
@@ -63,15 +69,21 @@ export default function Layout() {
</nav> </nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-colors"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="hidden md:flex items-center gap-2 text-slate-500 hover:text-slate-300 text-sm transition-colors" className="hidden md:flex items-center gap-2 text-text-muted hover:text-text-secondary text-sm transition-colors"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setMobileOpen(!mobileOpen)} onClick={() => setMobileOpen(!mobileOpen)}
className="md:hidden text-slate-400" className="md:hidden text-text-muted"
> >
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />} {mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button> </button>
@@ -80,7 +92,7 @@ export default function Layout() {
{/* Mobile nav */} {/* Mobile nav */}
{mobileOpen && ( {mobileOpen && (
<div className="md:hidden border-t border-slate-800/60 px-4 pb-4 space-y-1"> <div className="md:hidden border-t border-border px-4 pb-4 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => ( {navItems.map(({ to, icon: Icon, label }) => (
<NavLink <NavLink
key={to} key={to}
@@ -90,8 +102,8 @@ export default function Layout() {
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${ `flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive isActive
? 'bg-emerald-500/10 text-emerald-400' ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'text-slate-400 hover:text-white hover:bg-slate-800/50' : 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
}` }`
} }
> >
@@ -101,7 +113,7 @@ export default function Layout() {
))} ))}
<button <button
onClick={handleLogout} 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" className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-text-muted hover:text-text-primary hover:bg-surface-hover w-full"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
Sign out Sign out

View File

@@ -0,0 +1,141 @@
import { useState } from 'react';
import { X, ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import api, { type ImportResult } from '../api';
interface Props {
onClose: () => void;
onImported: () => void;
}
export default function PasteImportModal({ onClose, onImported }: Props) {
const [text, setText] = useState('');
const [bank, setBank] = useState('BAC');
const [source, setSource] = useState('CREDIT_CARD');
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const handleImport = async () => {
if (!text.trim()) return;
setImporting(true);
try {
const { data } = await api.post<ImportResult>('/import/paste', { text, bank, source });
setResult(data);
if (data.imported > 0) onImported();
} catch (err) {
console.error(err);
} finally {
setImporting(false);
}
};
const inputClass =
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
const labelClass = 'block text-xs font-medium text-text-secondary 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-surface border border-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center gap-2">
<ClipboardPaste className="w-4 h-4 text-[#606C38] dark:text-[#7a8a4a]" />
<h3 className="font-semibold">Import Bank Statement</h3>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-4">
{!result ? (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Bank</label>
<select className={inputClass} value={bank} onChange={(e) => setBank(e.target.value)}>
<option value="BAC">BAC</option>
<option value="BCR">BCR</option>
<option value="DAVIVIENDA">Davivienda</option>
</select>
</div>
<div>
<label className={labelClass}>Source</label>
<select className={inputClass} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="CREDIT_CARD">Credit Card</option>
<option value="CASH">Cash</option>
<option value="TRANSFER">Transfer</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Statement Text</label>
<textarea
className={`${inputClass} h-48 font-mono text-xs resize-y`}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Paste bank statement lines here...\n\nFormat: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CRC`}
/>
<p className="text-xs text-text-faint mt-1">
One transaction per line. Tab-separated columns.
</p>
</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-text-secondary border border-border hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={importing || !text.trim()}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
>
{importing ? 'Importing...' : 'Import'}
</button>
</div>
</>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-[#606C38]/10 border border-[#606C38]/20 rounded-lg">
<CheckCircle className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a] flex-shrink-0" />
<div>
<p className="font-medium text-[#606C38] dark:text-[#7a8a4a]">Import Complete</p>
<p className="text-sm text-text-secondary mt-1">
{result.imported} imported
{result.duplicates > 0 && ` · ${result.duplicates} duplicates skipped`}
</p>
</div>
</div>
{result.errors.length > 0 && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-amber-500 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">
{result.errors.length} errors
</span>
</div>
<ul className="text-xs text-text-secondary space-y-1 font-mono max-h-32 overflow-y-auto">
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
<button
onClick={onClose}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors"
>
Done
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -26,6 +26,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
category_id: '' as string | number, category_id: '' as string | number,
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
api.get('/categories/').then((r) => setCategories(r.data)); api.get('/categories/').then((r) => setCategories(r.data));
@@ -53,6 +54,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
setError('');
try { try {
const payload = { const payload = {
...form, ...form,
@@ -70,30 +72,39 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
} }
onSaved(); onSaved();
onClose(); onClose();
} catch (err) { } catch (err: any) {
if (err.response?.status === 409) {
setError('Duplicate transaction: a transaction with this reference already exists.');
} else {
console.error(err); console.error(err);
}
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const inputClass = 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'; 'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
const labelClass = 'block text-xs font-medium text-slate-400 mb-1 uppercase tracking-wider'; const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <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="bg-surface border border-border 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"> <div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold"> <h3 className="font-semibold">
{transaction ? 'Edit Transaction' : 'New Transaction'} {transaction ? 'Edit Transaction' : 'New Transaction'}
</h3> </h3>
<button onClick={onClose} className="text-slate-500 hover:text-white transition-colors"> <button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-5 space-y-4"> <form onSubmit={handleSubmit} className="p-5 space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-500 dark:text-red-400">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className={labelClass}>Merchant</label> <label className={labelClass}>Merchant</label>
@@ -223,14 +234,14 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
<button <button
type="button" type="button"
onClick={onClose} 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" className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={saving} 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" className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
> >
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'} {saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
</button> </button>

View File

@@ -1,5 +1,41 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: #FFFDF5;
--color-surface-secondary: #fefae0;
--color-surface-card: rgba(96, 108, 56, 0.08);
--color-surface-hover: rgba(96, 108, 56, 0.12);
--color-border: rgba(96, 108, 56, 0.25);
--color-border-subtle: rgba(96, 108, 56, 0.15);
--color-text-primary: #283618;
--color-text-secondary: #606C38;
--color-text-muted: #8a9462;
--color-text-faint: #c2c9a7;
--color-input-bg: #f5f1d0;
}
.dark {
--color-surface: #020617;
--color-surface-secondary: #0f172a;
--color-surface-card: rgba(15, 23, 42, 0.6);
--color-surface-hover: rgba(30, 41, 59, 0.3);
--color-border: rgba(30, 41, 59, 0.6);
--color-border-subtle: rgba(30, 41, 59, 0.4);
--color-text-primary: #ffffff;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-faint: #334155;
--color-input-bg: rgba(15, 23, 42, 0.8);
}
body {
background-color: var(--color-surface);
color: var(--color-text-primary);
transition: background-color 0.2s, color 0.2s;
}
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }

View File

@@ -8,3 +8,7 @@ createRoot(document.getElementById('root')!).render(
<App /> <App />
</StrictMode>, </StrictMode>,
); );
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}

View File

@@ -0,0 +1,273 @@
import { useEffect, useState } from 'react';
import {
PieChart,
Pie,
Cell,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
} from 'recharts';
import { BarChart3 } from 'lucide-react';
import api from '../api';
import BillingCycleSelector from '../components/BillingCycleSelector';
import { useTheme } from '../ThemeContext';
interface CategorySpending {
category_id: number | null;
category_name: string;
total: number;
count: number;
percentage: number;
}
interface MonthlyTrend {
year: number;
month: number;
label: string;
total_crc: number;
total_usd: number;
count: number;
}
interface DailySpending {
date: string;
total: number;
count: number;
}
const COLORS = [
'#606C38', '#BC6C25', '#8b5cf6', '#DDA15E', '#ef4444',
'#ec4899', '#283618', '#f97316', '#6366f1', '#8a9462',
'#e879f9', '#c2c9a7', '#fb923c', '#a78bfa', '#7a8a4a',
'#fbbf24',
];
function formatCRC(value: number) {
return `${Math.round(value).toLocaleString('es-CR')}`;
}
export default function Analytics() {
const { theme } = useTheme();
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const [byCategory, setByCategory] = useState<CategorySpending[]>([]);
const [trend, setTrend] = useState<MonthlyTrend[]>([]);
const [daily, setDaily] = useState<DailySpending[]>([]);
useEffect(() => {
const params: Record<string, string> = {};
if (cycle) {
params.cycle_year = String(cycle.year);
params.cycle_month = String(cycle.month);
}
Promise.all([
api.get('/analytics/by-category', { params }),
api.get('/analytics/monthly-trend'),
api.get('/analytics/daily-spending', { params }),
])
.then(([catRes, trendRes, dailyRes]) => {
setByCategory(catRes.data);
setTrend(trendRes.data);
setDaily(dailyRes.data);
})
.catch(console.error);
}, [cycle]);
const tooltipStyle = {
background: theme === 'dark' ? '#1e293b' : '#FEFAE0',
border: `1px solid ${theme === 'dark' ? '#334155' : 'rgba(96,108,56,0.25)'}`,
borderRadius: '8px',
fontSize: '12px',
color: theme === 'dark' ? '#e2e8f0' : '#283618',
};
const tickColor = theme === 'dark' ? '#64748b' : '#8a9462';
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a]" />
<h1 className="text-2xl font-bold">Analytics</h1>
</div>
<p className="text-sm text-text-muted mt-1">Spending breakdown and trends</p>
</div>
<BillingCycleSelector value={cycle} onChange={setCycle} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Spending by Category - Donut */}
<div className="bg-surface-card border border-border rounded-xl p-5">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Spending by Category
</h2>
{byCategory.length === 0 ? (
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
No data for this period
</div>
) : (
<div className="flex flex-col items-center">
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={byCategory}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
strokeWidth={0}
>
{byCategory.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: any) => formatCRC(Number(value))}
/>
</PieChart>
</ResponsiveContainer>
{/* Legend */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
{byCategory.slice(0, 10).map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-text-secondary truncate">{cat.category_name}</span>
<span className="text-text-faint ml-auto">{cat.percentage}%</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Monthly Trend - Bar */}
<div className="bg-surface-card border border-border rounded-xl p-5">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Monthly Spending (CRC)
</h2>
{trend.length === 0 ? (
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
No data
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={trend}>
<XAxis
dataKey="label"
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: any) => formatCRC(Number(value))}
/>
<Bar dataKey="total_crc" fill="#606C38" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Daily Spending - Line */}
<div className="bg-surface-card border border-border rounded-xl p-5 lg:col-span-2">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Daily Spending
</h2>
{daily.length === 0 ? (
<div className="h-48 flex items-center justify-center text-text-faint text-sm">
No data for this period
</div>
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={daily}>
<XAxis
dataKey="date"
tick={{ fill: tickColor, fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => {
const d = new Date(v);
return `${d.getMonth() + 1}/${d.getDate()}`;
}}
/>
<YAxis
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: any) => formatCRC(Number(value))}
labelFormatter={(label) => new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
/>
<Line
type="monotone"
dataKey="total"
stroke="#BC6C25"
strokeWidth={2}
dot={{ fill: '#BC6C25', r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Top categories summary */}
{byCategory.length > 0 && (
<div className="bg-surface-card border border-border rounded-xl p-5">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Top Categories
</h2>
<div className="space-y-3">
{byCategory.slice(0, 8).map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-sm flex-1">{cat.category_name}</span>
<span className="text-xs text-text-muted">{cat.count} txns</span>
<span className="text-sm font-mono font-medium w-32 text-right">
{formatCRC(cat.total)}
</span>
<div className="w-24 bg-surface-hover rounded-full h-1.5">
<div
className="h-1.5 rounded-full"
style={{
width: `${cat.percentage}%`,
background: COLORS[i % COLORS.length],
}}
/>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -6,12 +6,17 @@ import {
TrendingDown, TrendingDown,
RefreshCw, RefreshCw,
CreditCard, CreditCard,
Pencil,
Check,
X,
} from 'lucide-react'; } from 'lucide-react';
import api, { type Account, type Transaction } from '../api'; import api, { type Account, type Transaction } from '../api';
function formatAmount(amount: number, currency: string) { function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount); const abs = Math.abs(amount);
if (currency === 'BTC') return abs.toFixed(8);
if (currency === 'XMR') return abs.toFixed(4);
if (currency === 'USD') { if (currency === 'USD') {
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} }
@@ -19,16 +24,87 @@ function formatAmount(amount: number, currency: string) {
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-US', { return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
month: 'short', }
day: 'numeric',
}); // --- Reusable card for an account balance ---
function AccountCard({
account,
editingId,
editValue,
setEditValue,
startEditing,
saveBalance,
cancelEditing,
}: {
account: Account;
editingId: number | null;
editValue: string;
setEditValue: (v: string) => void;
startEditing: (a: Account) => void;
saveBalance: (id: number) => void;
cancelEditing: () => void;
}) {
const badgeLabel = account.account_type === 'CRYPTO' ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank);
const isEditing = editingId === account.id;
return (
<div className="group animate-fade-in bg-surface dark:bg-slate-900 border border-border dark:border-slate-700 rounded-xl p-5 shadow-sm dark:shadow-none hover:bg-surface-hover dark:hover:bg-slate-800/60 transition-colors h-[104px] flex flex-col justify-between">
<div className="flex items-center justify-end">
<span className="text-sm font-bold font-mono text-text-secondary dark:text-slate-300 bg-surface-secondary dark:bg-slate-800 px-2.5 py-0.5 rounded">
{badgeLabel}
</span>
</div>
{isEditing ? (
<div className="flex items-center gap-2">
<input
type="number"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveBalance(account.id);
if (e.key === 'Escape') cancelEditing();
}}
autoFocus
className="w-full text-2xl font-bold font-mono tracking-tight bg-input-bg border border-[#606C38]/40 rounded-lg px-2 py-1 focus:outline-none focus:border-[#606C38] transition-colors"
/>
<button onClick={() => saveBalance(account.id)} className="p-1 text-[#606C38] dark:text-[#7a8a4a]">
<Check className="w-4 h-4" />
</button>
<button onClick={cancelEditing} className="p-1 text-text-muted hover:text-text-secondary">
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2 group/balance cursor-pointer" onClick={() => startEditing(account)}>
<p className="text-2xl font-bold font-mono tracking-tight">
{formatAmount(account.balance, account.currency)}
</p>
<Pencil className="w-3.5 h-3.5 text-text-faint opacity-0 group-hover/balance:opacity-100 hover:text-[#606C38] dark:hover:text-[#7a8a4a] transition-all" />
</div>
)}
</div>
);
}
// --- Total card ---
function TotalCard({ total, currency }: { total: number; currency: string }) {
return (
<div className="border rounded-xl p-5 shadow-sm dark:shadow-none h-[104px] flex flex-col justify-between bg-[#fdf3e3] dark:bg-[#BC6C25]/10 border-[#e8c08a] dark:border-[#BC6C25]/20 text-[#8a5218] dark:text-[#DDA15E]">
<span className="text-xs font-bold uppercase tracking-wider opacity-80">Total</span>
<p className="text-2xl font-bold font-mono tracking-tight">{formatAmount(total, currency)}</p>
</div>
);
} }
export default function Dashboard() { export default function Dashboard() {
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
const [recent, setRecent] = useState<Transaction[]>([]); const [recent, setRecent] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [editValue, setEditValue] = useState('');
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
@@ -44,18 +120,41 @@ export default function Dashboard() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
api.get('/exchange-rate/').then((r) => setExchangeRate(r.data)).catch(() => {});
}; };
useEffect(() => { useEffect(() => { fetchData(); }, []);
fetchData();
}, []);
const totalCRC = accounts const startEditing = (account: Account) => {
.filter((a) => a.currency === 'CRC') setEditingId(account.id);
.reduce((s, a) => s + a.balance, 0); setEditValue(String(account.balance));
const totalUSD = accounts };
.filter((a) => a.currency === 'USD') const cancelEditing = () => { setEditingId(null); setEditValue(''); };
.reduce((s, a) => s + a.balance, 0); const saveBalance = async (accountId: number) => {
const parsed = parseFloat(editValue);
if (isNaN(parsed)) return cancelEditing();
try {
await api.patch(`/accounts/${accountId}`, { balance: parsed });
setEditingId(null);
setEditValue('');
fetchData();
} catch (e) { console.error(e); }
};
const cardProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing };
// Group accounts by type
const bankAccounts = accounts.filter((a) => a.account_type === 'BANK');
const pensionAccounts = accounts.filter((a) => a.account_type === 'PENSION');
const savingsAccounts = accounts.filter((a) => a.account_type === 'SAVINGS');
const liabilityAccounts = accounts.filter((a) => a.account_type === 'LIABILITY');
const cryptoAccounts = accounts.filter((a) => a.account_type === 'CRYPTO');
const bankOrder = ['BAC', 'BCR', 'DAVIVIENDA'];
// Bank totals for exchange rate combined total
const bankCRC = bankAccounts.filter((a) => a.currency === 'CRC').reduce((s, a) => s + a.balance, 0);
const bankUSD = bankAccounts.filter((a) => a.currency === 'USD').reduce((s, a) => s + a.balance, 0);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -63,129 +162,195 @@ export default function Dashboard() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Dashboard</h1> <h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-slate-500 mt-1">Financial overview</p> <p className="text-sm text-text-muted mt-1">Financial overview</p>
</div> </div>
<button <button
onClick={fetchData} onClick={fetchData}
className="p-2 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800/50 transition-colors" className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-colors"
> >
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button> </button>
</div> </div>
{/* Account balances */} {/* Bank accounts — grouped by currency */}
{(['CRC', 'USD'] as const).map((currency) => {
const accts = bankAccounts
.filter((a) => a.currency === currency)
.sort((a, b) => bankOrder.indexOf(a.bank) - bankOrder.indexOf(b.bank));
if (accts.length === 0) return null;
const total = accts.reduce((s, a) => s + a.balance, 0);
return (
<div key={currency} className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">{currency} Accounts</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{accounts.map((account, i) => ( {accts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
<TotalCard total={total} currency={currency} />
</div>
</div>
);
})}
{/* Pension accounts */}
{pensionAccounts.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Pension</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{pensionAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
<TotalCard
total={pensionAccounts.reduce((s, a) => s + a.balance, 0)}
currency="CRC"
/>
</div>
</div>
)}
{/* Savings accounts */}
{savingsAccounts.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Savings</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{savingsAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
<TotalCard
total={savingsAccounts.reduce((s, a) => s + a.balance, 0)}
currency="CRC"
/>
</div>
</div>
)}
{/* Liabilities */}
{liabilityAccounts.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Liabilities</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{liabilityAccounts.map((account) => {
const bankShort = account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank;
return (
<div <div
key={account.id} key={account.id}
className="relative group animate-fade-in" className="animate-fade-in bg-red-50 dark:bg-red-500/5 border border-red-200 dark:border-red-500/20 rounded-xl p-5 shadow-sm dark:shadow-none hover:bg-red-100/50 dark:hover:bg-red-500/10 transition-colors"
> >
<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="flex items-center justify-between mb-2">
<div className="relative bg-slate-900/60 border border-slate-800/60 rounded-xl p-5"> <span className="text-xs font-bold text-red-700 dark:text-red-400/80 uppercase tracking-wider">
<div className="flex items-center justify-between mb-3"> Balance
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
{account.label}
</span> </span>
<span className="text-[10px] font-mono text-slate-600 bg-slate-800/60 px-2 py-0.5 rounded"> <span className="text-sm font-bold font-mono text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-500/10 px-2.5 py-0.5 rounded">
{account.bank} {bankShort}
</span> </span>
</div> </div>
<p className="text-2xl font-bold font-mono tracking-tight"> <p
{formatAmount(account.balance, account.currency)} className="text-2xl font-bold font-mono tracking-tight text-red-700 dark:text-red-400 cursor-pointer group/balance"
</p> onClick={() => startEditing(account)}
</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"> {editingId === account.id ? (
Total CRC <span className="flex items-center gap-2">
<input
type="number"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveBalance(account.id);
if (e.key === 'Escape') cancelEditing();
}}
autoFocus
className="w-full text-2xl font-bold font-mono tracking-tight bg-input-bg border border-red-500/40 rounded-lg px-2 py-1 focus:outline-none focus:border-red-500 transition-colors text-text-primary"
onClick={(e) => e.stopPropagation()}
/>
<button onClick={(e) => { e.stopPropagation(); saveBalance(account.id); }} className="p-1 text-red-500">
<Check className="w-4 h-4" />
</button>
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} className="p-1 text-text-muted">
<X className="w-4 h-4" />
</button>
</span> </span>
<p className="text-2xl font-bold font-mono tracking-tight text-emerald-400 mt-3"> ) : (
{formatAmount(totalCRC, 'CRC')} formatAmount(account.balance, account.currency)
)}
</p> </p>
</div> {account.next_payment != null && (
<div <p className="text-sm font-mono text-red-600/70 dark:text-red-400/60 mt-2">
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" Next payment: {formatAmount(account.next_payment, account.currency)}
>
<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> </p>
</div>
</>
)} )}
</div> </div>
);
})}
</div>
</div>
)}
{/* Crypto */}
{cryptoAccounts.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Crypto</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{cryptoAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
</div>
</div>
)}
{/* Exchange rate + combined total */}
{exchangeRate && (
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 bg-surface-card border border-border rounded-xl p-4">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">USD/CRC Exchange Rate</span>
<div className="flex items-baseline gap-3 mt-1">
<span className="text-lg font-bold font-mono">Buy: {exchangeRate.buy_rate.toFixed(2)}</span>
<span className="text-lg font-bold font-mono text-text-secondary">Sell: {exchangeRate.sell_rate.toFixed(2)}</span>
</div>
</div>
{accounts.length > 0 && (
<div className="flex-1 bg-gradient-to-br from-violet-500/10 to-[#606C38]/5 border border-violet-500/20 rounded-xl p-4">
<span className="text-xs font-medium text-violet-600 dark:text-violet-400/80 uppercase tracking-wider">Combined Total (CRC)</span>
<p className="text-2xl font-bold font-mono tracking-tight text-violet-600 dark:text-violet-400 mt-1">
{formatAmount(bankCRC + bankUSD * exchangeRate.sell_rate, 'CRC')}
</p>
</div>
)}
</div>
)}
{/* Recent transactions */} {/* Recent transactions */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl"> <div className="bg-surface-card border border-border rounded-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/40"> <div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-slate-500" /> <CreditCard className="w-4 h-4 text-text-muted" />
<h2 className="font-semibold text-sm">Recent Charges</h2> <h2 className="font-semibold text-sm">Recent Charges</h2>
</div> </div>
<Link <Link
to="/transactions" to="/transactions"
className="flex items-center gap-1 text-xs font-medium text-emerald-400 hover:text-emerald-300 transition-colors" className="flex items-center gap-1 text-xs font-medium text-[#606C38] dark:text-[#7a8a4a] hover:text-[#4a5a2a] dark:hover:text-[#8a9462] transition-colors"
> >
View all View all
<ArrowRight className="w-3 h-3" /> <ArrowRight className="w-3 h-3" />
</Link> </Link>
</div> </div>
{recent.length === 0 && !loading ? ( {recent.length === 0 && !loading ? (
<div className="px-5 py-12 text-center text-slate-600 text-sm"> <div className="px-5 py-12 text-center text-text-faint text-sm">No transactions yet. Add your first one!</div>
No transactions yet. Add your first one!
</div>
) : ( ) : (
<div className="divide-y divide-slate-800/40"> <div className="divide-y divide-border-subtle">
{recent.map((tx, i) => ( {recent.map((tx) => (
<div <div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-surface-hover transition-colors animate-fade-in">
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="flex items-center gap-3 min-w-0">
<div <div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${ tx.transaction_type === 'DEVOLUCION' ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]' : 'bg-red-500/10 text-red-500 dark:text-red-400'
tx.transaction_type === 'DEVOLUCION' }`}>
? 'bg-emerald-500/10 text-emerald-400' {tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
: '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>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.merchant}</p> <p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-slate-500"> <p className="text-xs text-text-muted">
{formatDate(tx.date)} {formatDate(tx.date)}
{tx.category && ( {tx.category && <span className="ml-2 text-text-faint">{tx.category.name}</span>}
<span className="ml-2 text-slate-600">
{tx.category.name}
</span>
)}
</p> </p>
</div> </div>
</div> </div>
<span <span className={`font-mono text-sm font-medium flex-shrink-0 ml-4 ${
className={`font-mono text-sm font-medium flex-shrink-0 ml-4 ${ tx.transaction_type === 'DEVOLUCION' ? 'text-[#606C38] dark:text-[#7a8a4a]' : ''
tx.transaction_type === 'DEVOLUCION' }`}>
? 'text-emerald-400' {tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}{formatAmount(tx.amount, tx.currency)}
: 'text-white'
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span> </span>
</div> </div>
))} ))}

View File

@@ -29,46 +29,46 @@ export default function Login() {
}; };
return ( return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center px-4"> <div className="min-h-screen bg-surface flex items-center justify-center px-4">
<div className="w-full max-w-sm animate-fade-in"> <div className="w-full max-w-sm animate-fade-in">
<div className="flex items-center justify-center gap-2.5 mb-8"> <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"> <div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center">
<Wallet className="w-6 h-6 text-slate-950" strokeWidth={2.5} /> <Wallet className="w-6 h-6 text-[#FEFAE0]" strokeWidth={2.5} />
</div> </div>
<span className="text-2xl font-bold tracking-tight text-white"> <span className="text-2xl font-bold tracking-tight">
Wealthy<span className="text-emerald-400">Smart</span> Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span>
</span> </span>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider"> <label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Username Username
</label> </label>
<input <input
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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" className="w-full bg-input-bg border border-border rounded-lg px-4 py-3 text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors"
placeholder="Enter username" placeholder="Enter username"
autoFocus autoFocus
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider"> <label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Password Password
</label> </label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" className="w-full bg-input-bg border border-border rounded-lg px-4 py-3 text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors"
placeholder="Enter password" placeholder="Enter password"
/> />
</div> </div>
{error && ( {error && (
<div className="flex items-center gap-2 text-red-400 text-sm"> <div className="flex items-center gap-2 text-red-500 dark:text-red-400 text-sm">
<AlertCircle className="w-4 h-4" /> <AlertCircle className="w-4 h-4" />
{error} {error}
</div> </div>
@@ -77,7 +77,7 @@ export default function Login() {
<button <button
type="submit" type="submit"
disabled={loading} 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" className="w-full flex items-center justify-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] disabled:opacity-50 text-white dark:text-[#FEFAE0] font-semibold px-6 py-3 rounded-lg transition-colors"
> >
{loading ? 'Signing in...' : 'Sign in'} {loading ? 'Signing in...' : 'Sign in'}
{!loading && <ArrowRight className="w-4 h-4" />} {!loading && <ArrowRight className="w-4 h-4" />}

View File

@@ -7,10 +7,14 @@ import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
ChevronDown, ChevronDown,
ClipboardPaste,
} from 'lucide-react'; } from 'lucide-react';
import api, { type Transaction, type Category } from '../api'; import api, { type Transaction, type Category } from '../api';
import TransactionModal from '../components/TransactionModal'; import TransactionModal from '../components/TransactionModal';
import PasteImportModal from '../components/PasteImportModal';
import ConfirmDialog from '../components/ConfirmDialog';
import BillingCycleSelector from '../components/BillingCycleSelector';
function formatAmount(amount: number, currency: string) { function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount); const abs = Math.abs(amount);
@@ -27,7 +31,11 @@ export default function Transactions() {
const [categoryFilter, setCategoryFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null); const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const fetchTransactions = useCallback(async () => { const fetchTransactions = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -35,12 +43,16 @@ export default function Transactions() {
const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' }; const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' };
if (search) params.search = search; if (search) params.search = search;
if (categoryFilter) params.category_id = categoryFilter; if (categoryFilter) params.category_id = categoryFilter;
if (cycle) {
params.cycle_year = String(cycle.year);
params.cycle_month = String(cycle.month);
}
const { data } = await api.get('/transactions/', { params }); const { data } = await api.get('/transactions/', { params });
setTransactions(data); setTransactions(data);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [search, categoryFilter]); }, [search, categoryFilter, cycle]);
useEffect(() => { useEffect(() => {
api.get('/categories/').then((r) => setCategories(r.data)); api.get('/categories/').then((r) => setCategories(r.data));
@@ -51,47 +63,72 @@ export default function Transactions() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [fetchTransactions]); }, [fetchTransactions]);
const handleDelete = async (id: number) => { const handleDelete = async () => {
if (!confirm('Delete this transaction?')) return; if (deleteId === null) return;
await api.delete(`/transactions/${id}`); setDeleting(true);
try {
await api.delete(`/transactions/${deleteId}`);
setDeleteId(null);
fetchTransactions(); fetchTransactions();
} finally {
setDeleting(false);
}
}; };
const total = transactions.reduce((sum, tx) => { const totalCRC = transactions
const signed = tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount; .filter((tx) => tx.currency === 'CRC')
return sum + signed; .reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
}, 0); const totalUSD = transactions
.filter((tx) => tx.currency === 'USD')
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold">Credit Card Transactions</h1> <h1 className="text-2xl font-bold">Credit Card Transactions</h1>
<p className="text-sm text-slate-500 mt-1"> <p className="text-sm text-text-muted mt-1">
{transactions.length} transactions &middot; Total:{' '} {transactions.length} transactions
<span className="font-mono text-white">{formatAmount(total, 'CRC')}</span> {totalCRC !== 0 && (
<> &middot; <span className="font-mono text-text-primary">{formatAmount(totalCRC, 'CRC')}</span></>
)}
{totalUSD !== 0 && (
<> &middot; <span className="font-mono text-text-primary">{formatAmount(totalUSD, 'USD')}</span></>
)}
</p> </p>
</div> </div>
<div className="flex gap-2">
<button
onClick={() => setImportOpen(true)}
className="flex items-center gap-2 border border-border hover:bg-surface-hover text-text-secondary font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<ClipboardPaste className="w-4 h-4" />
Import
</button>
<button <button
onClick={() => { onClick={() => {
setEditing(null); setEditing(null);
setModalOpen(true); 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" className="flex items-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Transaction Add Transaction
</button> </button>
</div> </div>
</div>
{/* Billing cycle */}
<BillingCycleSelector value={cycle} onChange={setCycle} />
{/* Filters */} {/* Filters */}
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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" className="w-full bg-input-bg border border-border rounded-lg pl-10 pr-4 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 transition-colors"
placeholder="Search merchants..." placeholder="Search merchants..."
/> />
</div> </div>
@@ -99,7 +136,7 @@ export default function Transactions() {
<select <select
value={categoryFilter} value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)} 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" className="appearance-none bg-input-bg border border-border rounded-lg pl-4 pr-10 py-2.5 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
> >
<option value="">All Categories</option> <option value="">All Categories</option>
{categories.map((c) => ( {categories.map((c) => (
@@ -108,42 +145,42 @@ export default function Transactions() {
</option> </option>
))} ))}
</select> </select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" /> <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
</div> </div>
</div> </div>
{/* Table */} {/* Table */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl overflow-hidden"> <div className="bg-surface-card border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-slate-800/40"> <tr className="border-b border-border-subtle">
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider"> <th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
Date Date
</th> </th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider"> <th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
Merchant Merchant
</th> </th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider hidden md:table-cell"> <th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider hidden md:table-cell">
Category Category
</th> </th>
<th className="text-right px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider"> <th className="text-right px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
Amount Amount
</th> </th>
<th className="text-right px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider w-20"> <th className="text-right px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider w-20">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/30"> <tbody className="divide-y divide-border-subtle">
{transactions.map((tx) => ( {transactions.map((tx) => (
<tr <tr
key={tx.id} key={tx.id}
className="hover:bg-slate-800/20 transition-colors group" className="hover:bg-surface-hover transition-colors group"
> >
<td className="px-5 py-3 whitespace-nowrap"> <td className="px-5 py-3 whitespace-nowrap">
<span className="font-mono text-slate-400 text-xs"> <span className="font-mono text-text-secondary text-xs">
{new Date(tx.date).toLocaleDateString('en-US', { {new Date(tx.date).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -156,8 +193,8 @@ export default function Transactions() {
<div <div
className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${ className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
tx.transaction_type === 'DEVOLUCION' tx.transaction_type === 'DEVOLUCION'
? 'bg-emerald-500/10 text-emerald-400' ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'bg-red-500/10 text-red-400' : 'bg-red-500/10 text-red-500 dark:text-red-400'
}`} }`}
> >
{tx.transaction_type === 'DEVOLUCION' ? ( {tx.transaction_type === 'DEVOLUCION' ? (
@@ -171,19 +208,19 @@ export default function Transactions() {
</td> </td>
<td className="px-5 py-3 hidden md:table-cell"> <td className="px-5 py-3 hidden md:table-cell">
{tx.category ? ( {tx.category ? (
<span className="text-xs bg-slate-800/60 text-slate-400 px-2 py-1 rounded"> <span className="text-xs bg-surface-hover text-text-secondary px-2 py-1 rounded">
{tx.category.name} {tx.category.name}
</span> </span>
) : ( ) : (
<span className="text-xs text-slate-600"></span> <span className="text-xs text-text-faint"></span>
)} )}
</td> </td>
<td className="px-5 py-3 text-right whitespace-nowrap"> <td className="px-5 py-3 text-right whitespace-nowrap">
<span <span
className={`font-mono font-medium ${ className={`font-mono font-medium ${
tx.transaction_type === 'DEVOLUCION' tx.transaction_type === 'DEVOLUCION'
? 'text-emerald-400' ? 'text-[#606C38] dark:text-[#7a8a4a]'
: 'text-white' : ''
}`} }`}
> >
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'} {tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
@@ -197,13 +234,13 @@ export default function Transactions() {
setEditing(tx); setEditing(tx);
setModalOpen(true); setModalOpen(true);
}} }}
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors" className="p-1.5 rounded hover:bg-surface-hover text-text-muted hover:text-text-primary transition-colors"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={() => handleDelete(tx.id)} onClick={() => setDeleteId(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors" className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 dark:hover:text-red-400 transition-colors"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
@@ -216,7 +253,7 @@ export default function Transactions() {
</div> </div>
{transactions.length === 0 && !loading && ( {transactions.length === 0 && !loading && (
<div className="px-5 py-16 text-center text-slate-600 text-sm"> <div className="px-5 py-16 text-center text-text-faint text-sm">
No transactions found No transactions found
</div> </div>
)} )}
@@ -230,6 +267,23 @@ export default function Transactions() {
onSaved={fetchTransactions} onSaved={fetchTransactions}
/> />
)} )}
{importOpen && (
<PasteImportModal
onClose={() => setImportOpen(false)}
onImported={fetchTransactions}
/>
)}
{deleteId !== null && (
<ConfirmDialog
title="Delete Transaction"
message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
loading={deleting}
/>
)}
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import { Plus, Search, Pencil, Trash2, ArrowLeftRight } from 'lucide-react';
import api, { type Transaction } from '../api'; import api, { type Transaction } from '../api';
import TransactionModal from '../components/TransactionModal'; import TransactionModal from '../components/TransactionModal';
import ConfirmDialog from '../components/ConfirmDialog';
function formatAmount(amount: number, currency: string) { function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount); const abs = Math.abs(amount);
@@ -21,6 +22,8 @@ export default function Transfers() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null); const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchTransactions = useCallback(async () => { const fetchTransactions = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -39,10 +42,16 @@ export default function Transfers() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [fetchTransactions]); }, [fetchTransactions]);
const handleDelete = async (id: number) => { const handleDelete = async () => {
if (!confirm('Delete this transaction?')) return; if (deleteId === null) return;
await api.delete(`/transactions/${id}`); setDeleting(true);
try {
await api.delete(`/transactions/${deleteId}`);
setDeleteId(null);
fetchTransactions(); fetchTransactions();
} finally {
setDeleting(false);
}
}; };
return ( return (
@@ -50,7 +59,7 @@ export default function Transfers() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold">Cash & Transfers</h1> <h1 className="text-2xl font-bold">Cash & Transfers</h1>
<p className="text-sm text-slate-500 mt-1"> <p className="text-sm text-text-muted mt-1">
Track non-credit-card expenses Track non-credit-card expenses
</p> </p>
</div> </div>
@@ -59,7 +68,7 @@ export default function Transfers() {
setEditing(null); setEditing(null);
setModalOpen(true); 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" className="flex items-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add {sourceTab === 'CASH' ? 'Cash Expense' : 'Transfer'} Add {sourceTab === 'CASH' ? 'Cash Expense' : 'Transfer'}
@@ -67,15 +76,15 @@ export default function Transfers() {
</div> </div>
{/* Source tabs */} {/* Source tabs */}
<div className="flex gap-1 bg-slate-900/40 border border-slate-800/60 rounded-lg p-1 w-fit"> <div className="flex gap-1 bg-surface-card border border-border rounded-lg p-1 w-fit">
{(['CASH', 'TRANSFER'] as const).map((tab) => ( {(['CASH', 'TRANSFER'] as const).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setSourceTab(tab)} onClick={() => setSourceTab(tab)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
sourceTab === tab sourceTab === tab
? 'bg-emerald-500/10 text-emerald-400' ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'text-slate-500 hover:text-white' : 'text-text-muted hover:text-text-primary'
}`} }`}
> >
{tab === 'CASH' ? 'Cash' : 'Transfers'} {tab === 'CASH' ? 'Cash' : 'Transfers'}
@@ -85,20 +94,20 @@ export default function Transfers() {
{/* Search */} {/* Search */}
<div className="relative max-w-sm"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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" className="w-full bg-input-bg border border-border rounded-lg pl-10 pr-4 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 transition-colors"
placeholder="Search..." placeholder="Search..."
/> />
</div> </div>
{/* List */} {/* List */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl divide-y divide-slate-800/30"> <div className="bg-surface-card border border-border rounded-xl divide-y divide-border-subtle">
{transactions.length === 0 && !loading ? ( {transactions.length === 0 && !loading ? (
<div className="px-5 py-16 text-center text-slate-600 text-sm"> <div className="px-5 py-16 text-center text-text-faint text-sm">
<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-slate-700" /> <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-text-faint" />
No {sourceTab.toLowerCase()} transactions yet No {sourceTab.toLowerCase()} transactions yet
</div> </div>
) : ( ) : (
@@ -106,25 +115,25 @@ export default function Transfers() {
{transactions.map((tx) => ( {transactions.map((tx) => (
<div <div
key={tx.id} key={tx.id}
className="flex items-center justify-between px-5 py-4 hover:bg-slate-800/20 transition-colors group" className="flex items-center justify-between px-5 py-4 hover:bg-surface-hover transition-colors group"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.merchant}</p> <p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-slate-500 mt-0.5"> <p className="text-xs text-text-muted mt-0.5">
{new Date(tx.date).toLocaleDateString('en-US', { {new Date(tx.date).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
})} })}
{tx.category && ( {tx.category && (
<span className="ml-2 bg-slate-800/60 text-slate-400 px-2 py-0.5 rounded"> <span className="ml-2 bg-surface-hover text-text-secondary px-2 py-0.5 rounded">
{tx.category.name} {tx.category.name}
</span> </span>
)} )}
</p> </p>
</div> </div>
<div className="flex items-center gap-3 flex-shrink-0 ml-4"> <div className="flex items-center gap-3 flex-shrink-0 ml-4">
<span className="font-mono text-sm font-medium text-white"> <span className="font-mono text-sm font-medium">
{formatAmount(tx.amount, tx.currency)} {formatAmount(tx.amount, tx.currency)}
</span> </span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -133,13 +142,13 @@ export default function Transfers() {
setEditing(tx); setEditing(tx);
setModalOpen(true); setModalOpen(true);
}} }}
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors" className="p-1.5 rounded hover:bg-surface-hover text-text-muted hover:text-text-primary transition-colors"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={() => handleDelete(tx.id)} onClick={() => setDeleteId(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors" className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 dark:hover:text-red-400 transition-colors"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
@@ -159,6 +168,16 @@ export default function Transfers() {
onSaved={fetchTransactions} onSaved={fetchTransactions}
/> />
)} )}
{deleteId !== null && (
<ConfirmDialog
title="Delete Transaction"
message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
loading={deleting}
/>
)}
</div> </div>
); );
} }

View File

@@ -10,4 +10,12 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
}, },
server: {
proxy: {
'/api': {
target: 'http://localhost:8001',
changeOrigin: true,
},
},
},
}); });