mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:28:48 +02:00
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
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:
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -24,6 +24,10 @@ jobs:
|
||||
ENVEOF
|
||||
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
|
||||
run: |
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.prod build
|
||||
|
||||
184
backend/app/api/v1/endpoints/analytics.py
Normal file
184
backend/app/api/v1/endpoints/analytics.py
Normal 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
|
||||
]
|
||||
29
backend/app/api/v1/endpoints/exchange_rate.py
Normal file
29
backend/app/api/v1/endpoints/exchange_rate.py
Normal 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)
|
||||
149
backend/app/api/v1/endpoints/import_transactions.py
Normal file
149
backend/app/api/v1/endpoints/import_transactions.py
Normal 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)
|
||||
66
backend/app/api/v1/endpoints/tokens.py
Normal file
66
backend/app/api/v1/endpoints/tokens.py
Normal 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()
|
||||
@@ -1,7 +1,9 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
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.db import get_session
|
||||
@@ -17,6 +19,24 @@ from app.models.models import (
|
||||
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]:
|
||||
categories = session.exec(select(Category)).all()
|
||||
merchant_lower = merchant.lower()
|
||||
@@ -33,6 +53,8 @@ def list_transactions(
|
||||
source: Optional[TransactionSource] = None,
|
||||
search: Optional[str] = None,
|
||||
category_id: Optional[int] = None,
|
||||
cycle_year: Optional[int] = None,
|
||||
cycle_month: Optional[int] = None,
|
||||
limit: int = Query(default=50, le=500),
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session),
|
||||
@@ -45,10 +67,72 @@ def list_transactions(
|
||||
query = query.where(Transaction.category_id == category_id)
|
||||
if 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)
|
||||
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])
|
||||
def recent_transactions(
|
||||
limit: int = Query(default=5, le=20),
|
||||
@@ -71,6 +155,16 @@ def create_transaction(
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
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:
|
||||
tx.category_id = auto_categorize(tx.merchant, session)
|
||||
session.add(tx)
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
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.include_router(auth.router)
|
||||
api_router.include_router(accounts.router)
|
||||
api_router.include_router(categories.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)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlmodel import Session, select
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
||||
# Try JWT first
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
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)
|
||||
return username
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,8 @@ class Settings(BaseSettings):
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 30 # 30 days
|
||||
ADMIN_USERNAME: str = "admin"
|
||||
ADMIN_PASSWORD: str = "admin"
|
||||
BCCR_API_EMAIL: str = ""
|
||||
BCCR_API_TOKEN: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -19,12 +19,28 @@ class TransactionSource(str, enum.Enum):
|
||||
class Currency(str, enum.Enum):
|
||||
CRC = "CRC"
|
||||
USD = "USD"
|
||||
BTC = "BTC"
|
||||
XMR = "XMR"
|
||||
|
||||
|
||||
class Bank(str, enum.Enum):
|
||||
BAC = "BAC"
|
||||
BCR = "BCR"
|
||||
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 ---
|
||||
@@ -64,8 +80,10 @@ class CategoryUpdate(SQLModel):
|
||||
class AccountBase(SQLModel):
|
||||
bank: Bank
|
||||
currency: Currency
|
||||
label: str = Field(description="e.g. 'BAC Colones', 'BAC Dólares'")
|
||||
label: str
|
||||
balance: float = 0.0
|
||||
account_type: AccountType = AccountType.BANK
|
||||
next_payment: Optional[float] = None
|
||||
|
||||
|
||||
class Account(AccountBase, table=True):
|
||||
@@ -85,6 +103,7 @@ class AccountRead(AccountBase):
|
||||
class AccountUpdate(SQLModel):
|
||||
balance: Optional[float] = None
|
||||
label: Optional[str] = None
|
||||
next_payment: Optional[float] = None
|
||||
|
||||
|
||||
# --- Transaction ---
|
||||
@@ -99,7 +118,7 @@ class TransactionBase(SQLModel):
|
||||
card_type: Optional[str] = None
|
||||
card_last4: 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
|
||||
source: TransactionSource = TransactionSource.CREDIT_CARD
|
||||
bank: Bank = Bank.BAC
|
||||
@@ -133,3 +152,46 @@ class TransactionUpdate(SQLModel):
|
||||
source: Optional[TransactionSource] = None
|
||||
notes: Optional[str] = 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlmodel import Session, select
|
||||
|
||||
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 = [
|
||||
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"),
|
||||
@@ -23,10 +23,25 @@ DEFAULT_CATEGORIES = [
|
||||
]
|
||||
|
||||
DEFAULT_ACCOUNTS = [
|
||||
(Bank.BAC, Currency.CRC, "BAC Colones", 0.0),
|
||||
(Bank.BAC, Currency.USD, "BAC Dólares", 0.0),
|
||||
(Bank.BCR, Currency.CRC, "BCR Colones", 0.0),
|
||||
(Bank.DAVIVIENDA, Currency.CRC, "Davivienda Colones", 0.0),
|
||||
# Bank accounts
|
||||
(Bank.BAC, Currency.CRC, "BAC", AccountType.BANK),
|
||||
(Bank.BAC, Currency.USD, "BAC", AccountType.BANK),
|
||||
(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()
|
||||
if not existing_acc:
|
||||
for bank, currency, label, balance in DEFAULT_ACCOUNTS:
|
||||
session.add(Account(bank=bank, currency=currency, label=label, balance=balance))
|
||||
for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
|
||||
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
|
||||
session.commit()
|
||||
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
95
backend/app/services/exchange_rate.py
Normal file
95
backend/app/services/exchange_rate.py
Normal 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()
|
||||
)
|
||||
@@ -7,3 +7,4 @@ passlib[bcrypt]
|
||||
python-multipart
|
||||
python-dotenv
|
||||
alembic
|
||||
httpx
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: wealthysmart-frontend-dev
|
||||
ports:
|
||||
- "5174:5173"
|
||||
- "5175:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="WealthySmart — Smart personal finance management" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -18,6 +18,11 @@ server {
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# No cache for service worker
|
||||
location /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Cache immutable assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^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": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
318
frontend/pnpm-lock.yaml
generated
318
frontend/pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^7.12.0
|
||||
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:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
@@ -220,6 +223,17 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
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':
|
||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||
|
||||
@@ -348,6 +362,12 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -513,6 +533,33 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -524,6 +571,9 @@ packages:
|
||||
'@types/react@19.2.14':
|
||||
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':
|
||||
resolution: {integrity: sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -540,6 +590,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -551,6 +605,53 @@ packages:
|
||||
csstype@3.2.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -583,11 +684,17 @@ packages:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.45.1:
|
||||
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
|
||||
|
||||
esbuild@0.27.4:
|
||||
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -645,6 +752,16 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@@ -763,6 +880,21 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -784,6 +916,25 @@ packages:
|
||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -806,6 +957,9 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -815,6 +969,14 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
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:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -954,6 +1116,18 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@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': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.1':
|
||||
@@ -1031,6 +1205,10 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.1':
|
||||
optional: true
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.18':
|
||||
optional: true
|
||||
|
||||
@@ -1151,6 +1329,30 @@ snapshots:
|
||||
tailwindcss: 4.2.2
|
||||
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/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
@@ -1161,6 +1363,8 @@ snapshots:
|
||||
dependencies:
|
||||
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))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||
@@ -1184,6 +1388,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -1192,6 +1398,46 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@@ -1222,6 +1468,8 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
es-toolkit@1.45.1: {}
|
||||
|
||||
esbuild@0.27.4:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.4
|
||||
@@ -1251,6 +1499,8 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.27.4
|
||||
'@esbuild/win32-x64': 0.27.4
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
@@ -1302,6 +1552,12 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.4: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
@@ -1388,6 +1644,17 @@ snapshots:
|
||||
react: 19.2.4
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -1404,6 +1671,34 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -1445,6 +1740,8 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -1452,6 +1749,27 @@ snapshots:
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
|
||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
27
frontend/public/manifest.json
Normal file
27
frontend/public/manifest.json
Normal 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
52
frontend/public/sw.js
Normal 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)));
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import { ThemeProvider } from './ThemeContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Transactions from './pages/Transactions';
|
||||
import Transfers from './pages/Transfers';
|
||||
import Analytics from './pages/Analytics';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -29,6 +31,7 @@ function AppRoutes() {
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/transactions" element={<Transactions />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/transfers" element={<Transfers />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -38,9 +41,11 @@ function AppRoutes() {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
31
frontend/src/ThemeContext.tsx
Normal file
31
frontend/src/ThemeContext.tsx
Normal 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);
|
||||
@@ -38,6 +38,8 @@ export interface Account {
|
||||
currency: string;
|
||||
label: string;
|
||||
balance: number;
|
||||
account_type: string;
|
||||
next_payment: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +50,12 @@ export interface Category {
|
||||
auto_match_patterns: string | null;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
duplicates: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
amount: number;
|
||||
|
||||
54
frontend/src/components/BillingCycleSelector.tsx
Normal file
54
frontend/src/components/BillingCycleSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/ConfirmDialog.tsx
Normal file
55
frontend/src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,29 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
CreditCard,
|
||||
BarChart3,
|
||||
ArrowLeftRight,
|
||||
LogOut,
|
||||
Wallet,
|
||||
Menu,
|
||||
X,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/transactions', icon: CreditCard, label: 'Transactions' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const { logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
@@ -28,16 +34,16 @@ export default function Layout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white">
|
||||
<div className="min-h-screen bg-surface text-text-primary">
|
||||
{/* 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="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-slate-950" strokeWidth={2.5} />
|
||||
<div 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-[#FEFAE0]" strokeWidth={2.5} />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -51,8 +57,8 @@ export default function Layout() {
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -63,15 +69,21 @@ export default function Layout() {
|
||||
</nav>
|
||||
|
||||
<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
|
||||
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" />
|
||||
</button>
|
||||
<button
|
||||
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" />}
|
||||
</button>
|
||||
@@ -80,7 +92,7 @@ export default function Layout() {
|
||||
|
||||
{/* Mobile nav */}
|
||||
{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 }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
@@ -90,8 +102,8 @@ export default function Layout() {
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -101,7 +113,7 @@ export default function Layout() {
|
||||
))}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-800/50 w-full"
|
||||
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" />
|
||||
Sign out
|
||||
|
||||
141
frontend/src/components/PasteImportModal.tsx
Normal file
141
frontend/src/components/PasteImportModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
category_id: '' as string | number,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
@@ -70,30 +72,39 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 409) {
|
||||
setError('Duplicate transaction: a transaction with this reference already exists.');
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-slate-400 mb-1 uppercase tracking-wider';
|
||||
'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-slate-900 border border-slate-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/60">
|
||||
<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-border">
|
||||
<h3 className="font-semibold">
|
||||
{transaction ? 'Edit Transaction' : 'New Transaction'}
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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="col-span-2">
|
||||
<label className={labelClass}>Merchant</label>
|
||||
@@ -223,14 +234,14 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-slate-400 border border-slate-800 hover:bg-slate-800/50 transition-colors"
|
||||
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="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-400 text-slate-950 transition-colors disabled:opacity-50"
|
||||
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'}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
@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 {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -8,3 +8,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
|
||||
273
frontend/src/pages/Analytics.tsx
Normal file
273
frontend/src/pages/Analytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
TrendingDown,
|
||||
RefreshCw,
|
||||
CreditCard,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import api, { type Account, type Transaction } from '../api';
|
||||
|
||||
function formatAmount(amount: number, currency: string) {
|
||||
const abs = Math.abs(amount);
|
||||
if (currency === 'BTC') return abs.toFixed(8);
|
||||
if (currency === 'XMR') return abs.toFixed(4);
|
||||
if (currency === 'USD') {
|
||||
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
@@ -19,16 +24,87 @@ function formatAmount(amount: number, currency: string) {
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { 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() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [recent, setRecent] = useState<Transaction[]>([]);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -44,18 +120,41 @@ export default function Dashboard() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
api.get('/exchange-rate/').then((r) => setExchangeRate(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const totalCRC = accounts
|
||||
.filter((a) => a.currency === 'CRC')
|
||||
.reduce((s, a) => s + a.balance, 0);
|
||||
const totalUSD = accounts
|
||||
.filter((a) => a.currency === 'USD')
|
||||
.reduce((s, a) => s + a.balance, 0);
|
||||
const startEditing = (account: Account) => {
|
||||
setEditingId(account.id);
|
||||
setEditValue(String(account.balance));
|
||||
};
|
||||
const cancelEditing = () => { setEditingId(null); setEditValue(''); };
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -63,129 +162,195 @@ export default function Dashboard() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Financial overview</p>
|
||||
<p className="text-sm text-text-muted mt-1">Financial overview</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="p-2 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800/50 transition-colors"
|
||||
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' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Account balances */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{accounts.map((account, i) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="relative group animate-fade-in"
|
||||
>
|
||||
<div className="absolute -inset-[1px] rounded-xl bg-gradient-to-br from-emerald-500/20 to-cyan-500/20 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative bg-slate-900/60 border border-slate-800/60 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
{account.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-600 bg-slate-800/60 px-2 py-0.5 rounded">
|
||||
{account.bank}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight">
|
||||
{formatAmount(account.balance, account.currency)}
|
||||
</p>
|
||||
{/* 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">
|
||||
{accts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
|
||||
<TotalCard total={total} currency={currency} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Totals */}
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="bg-gradient-to-br from-emerald-500/10 to-cyan-500/5 border border-emerald-500/20 rounded-xl p-5"
|
||||
>
|
||||
<span className="text-xs font-medium text-emerald-400/80 uppercase tracking-wider">
|
||||
Total CRC
|
||||
</span>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight text-emerald-400 mt-3">
|
||||
{formatAmount(totalCRC, 'CRC')}
|
||||
{/* 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
|
||||
key={account.id}
|
||||
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="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-red-700 dark:text-red-400/80 uppercase tracking-wider">
|
||||
Balance
|
||||
</span>
|
||||
<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">
|
||||
{bankShort}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-2xl font-bold font-mono tracking-tight text-red-700 dark:text-red-400 cursor-pointer group/balance"
|
||||
onClick={() => startEditing(account)}
|
||||
>
|
||||
{editingId === account.id ? (
|
||||
<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>
|
||||
) : (
|
||||
formatAmount(account.balance, account.currency)
|
||||
)}
|
||||
</p>
|
||||
{account.next_payment != null && (
|
||||
<p className="text-sm font-mono text-red-600/70 dark:text-red-400/60 mt-2">
|
||||
Next payment: {formatAmount(account.next_payment, account.currency)}
|
||||
</p>
|
||||
)}
|
||||
</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
|
||||
className="bg-gradient-to-br from-cyan-500/10 to-emerald-500/5 border border-cyan-500/20 rounded-xl p-5 animate-fade-in"
|
||||
>
|
||||
<span className="text-xs font-medium text-cyan-400/80 uppercase tracking-wider">
|
||||
Total USD
|
||||
</span>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight text-cyan-400 mt-3">
|
||||
{formatAmount(totalUSD, 'USD')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent transactions */}
|
||||
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/40">
|
||||
<div className="bg-surface-card border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
|
||||
<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>
|
||||
</div>
|
||||
<Link
|
||||
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
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recent.length === 0 && !loading ? (
|
||||
<div className="px-5 py-12 text-center text-slate-600 text-sm">
|
||||
No transactions yet. Add your first one!
|
||||
</div>
|
||||
<div className="px-5 py-12 text-center text-text-faint text-sm">No transactions yet. Add your first one!</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-800/40">
|
||||
{recent.map((tx, i) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between px-5 py-3.5 hover:bg-slate-800/20 transition-colors animate-fade-in"
|
||||
>
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{recent.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-surface-hover transition-colors animate-fade-in">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
tx.transaction_type === 'DEVOLUCION'
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
<div 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' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-text-muted">
|
||||
{formatDate(tx.date)}
|
||||
{tx.category && (
|
||||
<span className="ml-2 text-slate-600">
|
||||
{tx.category.name}
|
||||
</span>
|
||||
)}
|
||||
{tx.category && <span className="ml-2 text-text-faint">{tx.category.name}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono text-sm font-medium flex-shrink-0 ml-4 ${
|
||||
tx.transaction_type === 'DEVOLUCION'
|
||||
? 'text-emerald-400'
|
||||
: 'text-white'
|
||||
}`}
|
||||
>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
||||
{formatAmount(tx.amount, tx.currency)}
|
||||
<span 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' ? '+' : '-'}{formatAmount(tx.amount, tx.currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -29,46 +29,46 @@ export default function Login() {
|
||||
};
|
||||
|
||||
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="flex items-center justify-center gap-2.5 mb-8">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
||||
<Wallet className="w-6 h-6 text-slate-950" strokeWidth={2.5} />
|
||||
<div 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-[#FEFAE0]" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-2xl font-bold tracking-tight text-white">
|
||||
Wealthy<span className="text-emerald-400">Smart</span>
|
||||
<span className="text-2xl font-bold tracking-tight">
|
||||
Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors"
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
{error}
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@ export default function Login() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 text-slate-950 font-semibold px-6 py-3 rounded-lg transition-colors"
|
||||
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 && <ArrowRight className="w-4 h-4" />}
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ChevronDown,
|
||||
ClipboardPaste,
|
||||
} from 'lucide-react';
|
||||
|
||||
import api, { type Transaction, type Category } from '../api';
|
||||
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) {
|
||||
const abs = Math.abs(amount);
|
||||
@@ -27,7 +31,11 @@ export default function Transactions() {
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -35,12 +43,16 @@ export default function Transactions() {
|
||||
const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' };
|
||||
if (search) params.search = search;
|
||||
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 });
|
||||
setTransactions(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, categoryFilter]);
|
||||
}, [search, categoryFilter, cycle]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/categories/').then((r) => setCategories(r.data));
|
||||
@@ -51,47 +63,72 @@ export default function Transactions() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchTransactions]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this transaction?')) return;
|
||||
await api.delete(`/transactions/${id}`);
|
||||
fetchTransactions();
|
||||
const handleDelete = async () => {
|
||||
if (deleteId === null) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/transactions/${deleteId}`);
|
||||
setDeleteId(null);
|
||||
fetchTransactions();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const total = transactions.reduce((sum, tx) => {
|
||||
const signed = tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount;
|
||||
return sum + signed;
|
||||
}, 0);
|
||||
const totalCRC = transactions
|
||||
.filter((tx) => tx.currency === 'CRC')
|
||||
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
|
||||
const totalUSD = transactions
|
||||
.filter((tx) => tx.currency === 'USD')
|
||||
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Credit Card Transactions</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{transactions.length} transactions · Total:{' '}
|
||||
<span className="font-mono text-white">{formatAmount(total, 'CRC')}</span>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{transactions.length} transactions
|
||||
{totalCRC !== 0 && (
|
||||
<> · <span className="font-mono text-text-primary">{formatAmount(totalCRC, 'CRC')}</span></>
|
||||
)}
|
||||
{totalUSD !== 0 && (
|
||||
<> · <span className="font-mono text-text-primary">{formatAmount(totalUSD, 'USD')}</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Transaction
|
||||
</button>
|
||||
<div 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
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
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" />
|
||||
Add Transaction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing cycle */}
|
||||
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900/60 border border-slate-800/60 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 transition-colors"
|
||||
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..."
|
||||
/>
|
||||
</div>
|
||||
@@ -99,7 +136,7 @@ export default function Transactions() {
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="appearance-none bg-slate-900/60 border border-slate-800/60 rounded-lg pl-4 pr-10 py-2.5 text-sm text-white focus:outline-none focus:border-emerald-500/50 transition-colors"
|
||||
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>
|
||||
{categories.map((c) => (
|
||||
@@ -108,42 +145,42 @@ export default function Transactions() {
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* 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">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800/40">
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
<tr className="border-b border-border-subtle">
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Date
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
<tbody className="divide-y divide-border-subtle">
|
||||
|
||||
{transactions.map((tx) => (
|
||||
<tr
|
||||
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">
|
||||
<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', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -156,8 +193,8 @@ export default function Transactions() {
|
||||
<div
|
||||
className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
tx.transaction_type === 'DEVOLUCION'
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: 'bg-red-500/10 text-red-500 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? (
|
||||
@@ -171,19 +208,19 @@ export default function Transactions() {
|
||||
</td>
|
||||
<td className="px-5 py-3 hidden md:table-cell">
|
||||
{tx.category ? (
|
||||
<span className="text-xs bg-slate-800/60 text-slate-400 px-2 py-1 rounded">
|
||||
<span className="text-xs bg-surface-hover text-text-secondary px-2 py-1 rounded">
|
||||
{tx.category.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">—</span>
|
||||
<span className="text-xs text-text-faint">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right whitespace-nowrap">
|
||||
<span
|
||||
className={`font-mono font-medium ${
|
||||
tx.transaction_type === 'DEVOLUCION'
|
||||
? 'text-emerald-400'
|
||||
: 'text-white'
|
||||
? 'text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
||||
@@ -197,13 +234,13 @@ export default function Transactions() {
|
||||
setEditing(tx);
|
||||
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" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(tx.id)}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors"
|
||||
onClick={() => setDeleteId(tx.id)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -216,7 +253,7 @@ export default function Transactions() {
|
||||
</div>
|
||||
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
@@ -230,6 +267,23 @@ export default function Transactions() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plus, Search, Pencil, Trash2, ArrowLeftRight } from 'lucide-react';
|
||||
|
||||
import api, { type Transaction } from '../api';
|
||||
import TransactionModal from '../components/TransactionModal';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
|
||||
function formatAmount(amount: number, currency: string) {
|
||||
const abs = Math.abs(amount);
|
||||
@@ -21,6 +22,8 @@ export default function Transfers() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -39,10 +42,16 @@ export default function Transfers() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchTransactions]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this transaction?')) return;
|
||||
await api.delete(`/transactions/${id}`);
|
||||
fetchTransactions();
|
||||
const handleDelete = async () => {
|
||||
if (deleteId === null) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/transactions/${deleteId}`);
|
||||
setDeleteId(null);
|
||||
fetchTransactions();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
@@ -59,7 +68,7 @@ export default function Transfers() {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||
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" />
|
||||
Add {sourceTab === 'CASH' ? 'Cash Expense' : 'Transfer'}
|
||||
@@ -67,15 +76,15 @@ export default function Transfers() {
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setSourceTab(tab)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
sourceTab === tab
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-slate-500 hover:text-white'
|
||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: 'text-text-muted hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab === 'CASH' ? 'Cash' : 'Transfers'}
|
||||
@@ -85,20 +94,20 @@ export default function Transfers() {
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900/60 border border-slate-800/60 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 transition-colors"
|
||||
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..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="px-5 py-16 text-center text-slate-600 text-sm">
|
||||
<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-slate-700" />
|
||||
<div className="px-5 py-16 text-center text-text-faint text-sm">
|
||||
<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-text-faint" />
|
||||
No {sourceTab.toLowerCase()} transactions yet
|
||||
</div>
|
||||
) : (
|
||||
@@ -106,25 +115,25 @@ export default function Transfers() {
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-slate-800/20 transition-colors group"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-surface-hover transition-colors group"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{new Date(tx.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
{tx.category && (
|
||||
<span className="ml-2 bg-slate-800/60 text-slate-400 px-2 py-0.5 rounded">
|
||||
<span className="ml-2 bg-surface-hover text-text-secondary px-2 py-0.5 rounded">
|
||||
{tx.category.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0 ml-4">
|
||||
<span className="font-mono text-sm font-medium text-white">
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{formatAmount(tx.amount, tx.currency)}
|
||||
</span>
|
||||
<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);
|
||||
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" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(tx.id)}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors"
|
||||
onClick={() => setDeleteId(tx.id)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -159,6 +168,16 @@ export default function Transfers() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,12 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user