mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:28:48 +02:00
Compare commits
27 Commits
37e04273b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20b4ad102d | ||
|
|
ec716e698f | ||
|
|
f556c392fb | ||
|
|
aa4bb6512f | ||
|
|
6b3069eef4 | ||
|
|
ead8fb8684 | ||
|
|
097fe9c4cf | ||
|
|
c92bfc66fe | ||
|
|
cf8b7be778 | ||
|
|
8b3a19b552 | ||
|
|
5d5727ec4e | ||
|
|
140a75f706 | ||
|
|
7f602a67af | ||
|
|
5f2a4105f3 | ||
|
|
c4768e6912 | ||
|
|
9fe17c0607 | ||
|
|
98d32df763 | ||
|
|
d4d0f65759 | ||
|
|
d929ed6573 | ||
|
|
94a8a894a6 | ||
|
|
9a80f2a997 | ||
|
|
efe6d88286 | ||
|
|
4da00750a8 | ||
|
|
792cef5006 | ||
|
|
78e20f30cb | ||
|
|
51c106dc6c | ||
|
|
0fdb5447b7 |
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -23,6 +23,8 @@ jobs:
|
||||
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
|
||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||
AGENT_MODEL=${{ secrets.AGENT_MODEL }}
|
||||
ENVEOF
|
||||
sed -i 's/^[[:space:]]*//' .env.prod
|
||||
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -2,7 +2,15 @@ node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
*.db.bak
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
docs/legacy_budget_analysis.md
|
||||
|
||||
# Reference clones of framework repos (read-only, not tracked)
|
||||
tech_docs/
|
||||
|
||||
# Claude Code local state
|
||||
.claude/
|
||||
|
||||
24
CLAUDE.md
24
CLAUDE.md
@@ -13,9 +13,33 @@ Personal finance management web app.
|
||||
cd frontend && pnpm install && pnpm run dev
|
||||
```
|
||||
|
||||
## Local Docker
|
||||
|
||||
```bash
|
||||
# Backend + DB containers
|
||||
docker exec wealthysmart-db-dev psql -U wealthy_user -d wealthysmart -c 'SQL;'
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
- Deployed via Gitea Actions (self-hosted runner on VPS)
|
||||
- Push to `main` triggers: GitHub → webhook → Gitea mirror sync → Actions workflow → Docker build & deploy
|
||||
- Domain: wealth.cescalante.dev
|
||||
- Reverse proxy: nginx-proxy + acme-companion (auto TLS)
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **Single server**: `ssh old-vps` — runs everything (WealthySmart, n8n, Forgejo, Vaultwarden, nginx-proxy)
|
||||
- `ssh production` is **NOT valid** — do not use
|
||||
- n8n UI: https://n8n.cescalante.dev — n8n DB queryable via `docker exec portfolio-db-prod psql -U portfolio_user -d n8n`
|
||||
|
||||
## n8n Flows
|
||||
|
||||
Four automated flows on old-vps feed data into WealthySmart:
|
||||
|
||||
1. **BAC Credit Card** — Gmail trigger → POST /transactions/
|
||||
2. **Salary Deposits** — Gmail trigger → POST /transactions/
|
||||
3. **Municipal Receipts** — Cron trigger → POST /municipal-receipts/upload
|
||||
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
|
||||
|
||||
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
|
||||
|
||||
@@ -2,6 +2,6 @@ FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --pre -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
0
backend/app/agent/__init__.py
Normal file
0
backend/app/agent/__init__.py
Normal file
74
backend/app/agent/agent.py
Normal file
74
backend/app/agent/agent.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Microsoft Agent Framework agent wired with OpenAI + WealthySmart tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatCompletionClient
|
||||
|
||||
from app.config import settings
|
||||
from app.agent.tools import TOOLS
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are the WealthySmart assistant, an AI analyst for a
|
||||
personal-finance app owned by a single user (Carlos).
|
||||
|
||||
Context you can rely on:
|
||||
- The user's primary currency is Costa Rican colones (CRC, ₡). USD and EUR
|
||||
balances and transactions are always converted to CRC using the latest
|
||||
exchange rate before being summed.
|
||||
- Credit-card billing cycles run from the 18th of a month to the 18th of the
|
||||
following month. When the user says "this month" or "last month" without
|
||||
qualifiers, assume they mean the calendar month unless they mention
|
||||
"cycle", "corte", or their credit card.
|
||||
- Today's date is {today}. Use it when the user says "this month", "last
|
||||
month", "last year", etc.
|
||||
- Amounts are stored as raw numbers in their native currency (see `currency`
|
||||
field on transactions/accounts). Tools that return `total_crc` are already
|
||||
converted; tools that return per-transaction amounts are NOT.
|
||||
|
||||
How to answer:
|
||||
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
|
||||
- Call multiple tools in parallel when the question spans domains
|
||||
(e.g. net worth + recent transactions).
|
||||
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
|
||||
USD (two decimals), € for EUR (two decimals).
|
||||
- When showing lists, prefer markdown tables over prose.
|
||||
- If a tool returns no data, say so explicitly — do not fill in zeros.
|
||||
- You are read-only in this version. If asked to create, edit or delete
|
||||
anything, explain that write actions aren't available yet and offer to
|
||||
summarize or export the change instead.
|
||||
|
||||
Language: match the user. The app is bilingual (Spanish/English); respond in
|
||||
whichever language they used.
|
||||
|
||||
Generative UI — render tools:
|
||||
- When showing spending totals, cycle summaries, or category breakdowns →
|
||||
call render_spending_summary. Source data: get_cycle_summary (by_source,
|
||||
grand_total_crc) + get_analytics_by_category (by_category).
|
||||
- When showing transaction lists or other structured data →
|
||||
call render_a2ui in a SEPARATE tool-call step, only after all data-fetching
|
||||
calls have returned. NEVER call render_a2ui in the same batch as any other
|
||||
tool.
|
||||
- Do NOT use markdown tables for data a render tool can display.
|
||||
- CRITICAL RULE: When you call a render tool (render_spending_summary or
|
||||
render_a2ui), that tool call MUST be the ONLY content in your message.
|
||||
Do NOT include any text content alongside the tool call — no introduction,
|
||||
no list, no explanation, nothing. The rendered card IS the complete
|
||||
response. Any text you write in the same message as a render call will
|
||||
appear as a duplicate below the card, which is wrong.
|
||||
"""
|
||||
|
||||
|
||||
def build_agent() -> Agent:
|
||||
client = OpenAIChatCompletionClient(
|
||||
api_key=settings.OPENAI_API_KEY,
|
||||
model=settings.AGENT_MODEL,
|
||||
)
|
||||
return Agent(
|
||||
name="wealthysmart",
|
||||
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()),
|
||||
client=client,
|
||||
tools=TOOLS,
|
||||
)
|
||||
467
backend/app/agent/tools.py
Normal file
467
backend/app/agent/tools.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""
|
||||
Read-only tools exposed to the MAF ChatAgent. Each tool is a thin wrapper
|
||||
around existing SQLModel queries / service helpers — they do NOT duplicate
|
||||
business logic. The active DB session is resolved via a ContextVar so tool
|
||||
signatures stay clean for the LLM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from pydantic import Field
|
||||
from sqlalchemy import case
|
||||
from sqlmodel import Session, col, func, select
|
||||
|
||||
from app.models.models import (
|
||||
Account,
|
||||
BalanceOverride,
|
||||
Category,
|
||||
MunicipalReceipt,
|
||||
PensionSnapshot,
|
||||
RecurringItem,
|
||||
Transaction,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
WaterMeterReading,
|
||||
)
|
||||
from app.services.budget_projection import (
|
||||
compute_monthly_projection,
|
||||
compute_yearly_projection_with_cumulative,
|
||||
get_cycle_range,
|
||||
)
|
||||
from app.services.exchange_rate import (
|
||||
get_converted_amount_expr,
|
||||
get_current_rate,
|
||||
)
|
||||
|
||||
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
|
||||
|
||||
|
||||
def set_session(session: Session) -> contextvars.Token:
|
||||
return _session_ctx.set(session)
|
||||
|
||||
|
||||
def reset_session(token: contextvars.Token) -> None:
|
||||
_session_ctx.reset(token)
|
||||
|
||||
|
||||
def _s() -> Session:
|
||||
return _session_ctx.get()
|
||||
|
||||
|
||||
# ─── Tools ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_accounts() -> list[dict]:
|
||||
"""List every account with current balance, currency, bank and type
|
||||
(BANK, PENSION, CRYPTO, SAVINGS, LIABILITY). Use this for net-worth and
|
||||
balance questions."""
|
||||
rows = _s().exec(select(Account).order_by(Account.account_type, Account.label)).all()
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"bank": a.bank.value,
|
||||
"label": a.label,
|
||||
"currency": a.currency.value,
|
||||
"balance": a.balance,
|
||||
"account_type": a.account_type.value,
|
||||
"next_payment": a.next_payment,
|
||||
}
|
||||
for a in rows
|
||||
]
|
||||
|
||||
|
||||
def get_net_worth() -> dict:
|
||||
"""Return total assets, liabilities and net worth in CRC (primary currency).
|
||||
USD/EUR balances are converted at the latest exchange rate."""
|
||||
accounts = _s().exec(select(Account)).all()
|
||||
rate = get_current_rate(_s())
|
||||
sell = rate.sell_rate if rate else 600.0
|
||||
assets_crc = 0.0
|
||||
liabilities_crc = 0.0
|
||||
for a in accounts:
|
||||
amt = a.balance
|
||||
if a.currency.value == "USD":
|
||||
amt = a.balance * sell
|
||||
elif a.currency.value == "EUR":
|
||||
amt = a.balance * sell * 1.08 # rough; real conversion is endpoint-side
|
||||
if a.account_type.value == "LIABILITY":
|
||||
liabilities_crc += amt
|
||||
else:
|
||||
assets_crc += amt
|
||||
return {
|
||||
"assets_crc": round(assets_crc, 2),
|
||||
"liabilities_crc": round(liabilities_crc, 2),
|
||||
"net_crc": round(assets_crc - liabilities_crc, 2),
|
||||
}
|
||||
|
||||
|
||||
def get_recent_transactions(
|
||||
limit: Annotated[int, Field(ge=1, le=100, description="How many rows to return")] = 20,
|
||||
source: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Filter by source: CREDIT_CARD, CASH, or TRANSFER"),
|
||||
] = None,
|
||||
category_id: Annotated[Optional[int], Field(description="Filter by category id")] = None,
|
||||
search: Annotated[
|
||||
Optional[str], Field(description="Substring match against merchant name")
|
||||
] = None,
|
||||
start_date: Annotated[
|
||||
Optional[str], Field(description="ISO date lower bound, inclusive")
|
||||
] = None,
|
||||
end_date: Annotated[
|
||||
Optional[str], Field(description="ISO date upper bound, exclusive")
|
||||
] = None,
|
||||
) -> list[dict]:
|
||||
"""Recent transactions, newest first. Use filters to narrow down. For
|
||||
billing-cycle scoped totals prefer get_cycle_summary."""
|
||||
q = select(Transaction).where(
|
||||
col(Transaction.transaction_type).notin_(
|
||||
[TransactionType.SALARY, TransactionType.DEPOSITO]
|
||||
)
|
||||
)
|
||||
if source:
|
||||
q = q.where(Transaction.source == TransactionSource(source))
|
||||
if category_id is not None:
|
||||
q = q.where(Transaction.category_id == category_id)
|
||||
if search:
|
||||
q = q.where(col(Transaction.merchant).ilike(f"%{search}%"))
|
||||
if start_date:
|
||||
q = q.where(Transaction.date >= datetime.fromisoformat(start_date))
|
||||
if end_date:
|
||||
q = q.where(Transaction.date < datetime.fromisoformat(end_date))
|
||||
q = q.order_by(col(Transaction.date).desc()).limit(limit)
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"date": t.date.isoformat(),
|
||||
"merchant": t.merchant,
|
||||
"amount": t.amount,
|
||||
"currency": t.currency.value,
|
||||
"source": t.source.value,
|
||||
"transaction_type": t.transaction_type.value,
|
||||
"bank": t.bank.value,
|
||||
"category_id": t.category_id,
|
||||
}
|
||||
for t in _s().exec(q).all()
|
||||
]
|
||||
|
||||
|
||||
def get_cycle_summary(
|
||||
cycle_year: Annotated[int, Field(description="Billing cycle year, e.g. 2026")],
|
||||
cycle_month: Annotated[
|
||||
int,
|
||||
Field(ge=1, le=12, description="Billing cycle month (cycle runs 18th→18th)"),
|
||||
],
|
||||
) -> dict:
|
||||
"""Totals for a credit-card billing cycle (18th of month → 18th of next).
|
||||
Returns spend by source, count, and spend by category."""
|
||||
session = _s()
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
|
||||
totals = session.exec(
|
||||
select(
|
||||
Transaction.source,
|
||||
func.count(),
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
)
|
||||
.where(
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
)
|
||||
.group_by(Transaction.source)
|
||||
).all()
|
||||
|
||||
by_category = session.exec(
|
||||
select(
|
||||
Category.name,
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.count(),
|
||||
)
|
||||
.join(Category, Category.id == Transaction.category_id, isouter=True)
|
||||
.where(
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
)
|
||||
.group_by(Category.name)
|
||||
.order_by(func.sum(amount_crc).desc())
|
||||
).all()
|
||||
|
||||
return {
|
||||
"cycle_year": cycle_year,
|
||||
"cycle_month": cycle_month,
|
||||
"range": [start.isoformat(), end.isoformat()],
|
||||
"by_source": [
|
||||
{"source": s.value, "count": c, "total_crc": float(t)}
|
||||
for s, c, t in totals
|
||||
],
|
||||
"by_category": [
|
||||
{"category": n or "Uncategorized", "total_crc": float(t), "count": c}
|
||||
for n, t, c in by_category
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_budget_projection(
|
||||
year: Annotated[int, Field(description="Year to project")],
|
||||
month: Annotated[
|
||||
Optional[int],
|
||||
Field(ge=1, le=12, description="If given, return only that month's detail"),
|
||||
] = None,
|
||||
) -> dict:
|
||||
"""Budget projection. If month is omitted, returns the yearly rollup; if
|
||||
given, returns the monthly detail with income items, expense items and
|
||||
actuals by source."""
|
||||
session = _s()
|
||||
if month is None:
|
||||
months_data = compute_yearly_projection_with_cumulative(session, year)
|
||||
return {
|
||||
"year": year,
|
||||
"months": months_data,
|
||||
"annual_income": sum(m["projected_income"] for m in months_data),
|
||||
"annual_expenses": sum(m["gran_total_egresos"] for m in months_data),
|
||||
"annual_net": sum(m["net_balance"] for m in months_data),
|
||||
}
|
||||
return compute_monthly_projection(session, year, month)
|
||||
|
||||
|
||||
def list_recurring_items() -> list[dict]:
|
||||
"""All recurring items (income and expense, SAVINGS excluded) used by the
|
||||
budget projection. Useful to explain what's driving a month's projection."""
|
||||
rows = _s().exec(
|
||||
select(RecurringItem)
|
||||
.where(RecurringItem.is_active == True) # noqa: E712
|
||||
.order_by(RecurringItem.item_type, RecurringItem.name)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"amount": r.amount,
|
||||
"currency": r.currency.value,
|
||||
"item_type": r.item_type.value,
|
||||
"frequency": r.frequency.value,
|
||||
"day_of_month": r.day_of_month,
|
||||
"category_id": r.category_id,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_pension_snapshots(
|
||||
fund: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Filter by fund bank code (FCL, ROP, VOL, etc.)"),
|
||||
] = None,
|
||||
latest_only: Annotated[
|
||||
bool,
|
||||
Field(description="If true, return only the latest snapshot per fund"),
|
||||
] = True,
|
||||
) -> list[dict]:
|
||||
"""Pension fund snapshots. Each snapshot covers a period with balances,
|
||||
contributions, returns, fees and the ending balance (saldo_final)."""
|
||||
q = select(PensionSnapshot).order_by(col(PensionSnapshot.period_end).desc())
|
||||
if fund:
|
||||
q = q.where(PensionSnapshot.fund == fund)
|
||||
rows = _s().exec(q).all()
|
||||
if latest_only:
|
||||
seen: dict[str, PensionSnapshot] = {}
|
||||
for r in rows:
|
||||
if r.fund.value not in seen:
|
||||
seen[r.fund.value] = r
|
||||
rows = list(seen.values())
|
||||
return [
|
||||
{
|
||||
"fund": r.fund.value,
|
||||
"period_start": r.period_start.isoformat(),
|
||||
"period_end": r.period_end.isoformat(),
|
||||
"saldo_anterior": r.saldo_anterior,
|
||||
"aportes": r.aportes,
|
||||
"rendimientos": r.rendimientos,
|
||||
"retiros": r.retiros,
|
||||
"comision": r.comision,
|
||||
"saldo_final": r.saldo_final,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_salary_summary() -> dict:
|
||||
"""Summary of salary deposits (count, total in CRC, latest date)."""
|
||||
session = _s()
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
row = session.exec(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.max(Transaction.date),
|
||||
).where(Transaction.transaction_type == TransactionType.SALARY)
|
||||
).first()
|
||||
count = row[0] if row else 0
|
||||
total = float(row[1]) if row else 0.0
|
||||
latest = row[2].isoformat() if row and row[2] else None
|
||||
return {"count": count, "total_crc": total, "latest_date": latest}
|
||||
|
||||
|
||||
def get_municipal_receipts(
|
||||
limit: Annotated[int, Field(ge=1, le=50)] = 12,
|
||||
account: Annotated[
|
||||
Optional[str], Field(description="Municipal account/contract id")
|
||||
] = None,
|
||||
) -> list[dict]:
|
||||
"""Recent municipal receipts (water + related services) with totals and
|
||||
water consumption in m³."""
|
||||
q = select(MunicipalReceipt).order_by(col(MunicipalReceipt.receipt_date).desc())
|
||||
if account:
|
||||
q = q.where(MunicipalReceipt.account == account)
|
||||
q = q.limit(limit)
|
||||
rows = _s().exec(q).all()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
readings = _s().exec(
|
||||
select(WaterMeterReading).where(WaterMeterReading.receipt_id == r.id)
|
||||
).all()
|
||||
out.append(
|
||||
{
|
||||
"id": r.id,
|
||||
"receipt_date": r.receipt_date.isoformat(),
|
||||
"period": r.period,
|
||||
"account": r.account,
|
||||
"finca": r.finca,
|
||||
"subtotal": r.subtotal,
|
||||
"interests": r.interests,
|
||||
"iva": r.iva,
|
||||
"total": r.total,
|
||||
"water_consumption_m3": sum(w.consumption_m3 for w in readings),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def get_analytics_by_category(
|
||||
cycle_year: Annotated[Optional[int], Field(description="Scope to a billing cycle")] = None,
|
||||
cycle_month: Annotated[Optional[int], Field(ge=1, le=12)] = None,
|
||||
) -> list[dict]:
|
||||
"""Spending breakdown by category in CRC (optionally scoped to a billing
|
||||
cycle). Percentages sum to 100."""
|
||||
session = _s()
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
q = (
|
||||
select(
|
||||
Transaction.category_id,
|
||||
func.sum(amount_crc).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == TransactionType.COMPRA)
|
||||
.group_by(Transaction.category_id)
|
||||
)
|
||||
if cycle_year and cycle_month:
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
q = q.where(Transaction.date >= start, Transaction.date < end)
|
||||
rows = session.exec(q).all()
|
||||
grand = sum(float(r[1]) for r in rows) or 1.0
|
||||
out = []
|
||||
for cat_id, total, count in rows:
|
||||
name = "Uncategorized"
|
||||
if cat_id:
|
||||
cat = session.get(Category, cat_id)
|
||||
if cat:
|
||||
name = cat.name
|
||||
out.append(
|
||||
{
|
||||
"category_id": cat_id,
|
||||
"category": name,
|
||||
"total_crc": float(total),
|
||||
"count": count,
|
||||
"percentage": round(float(total) / grand * 100, 1),
|
||||
}
|
||||
)
|
||||
out.sort(key=lambda x: x["total_crc"], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def get_monthly_trend(
|
||||
months: Annotated[int, Field(ge=1, le=24, description="How many months back")] = 6,
|
||||
) -> list[dict]:
|
||||
"""Spending trend by billing cycle for the last N months."""
|
||||
session = _s()
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
now = datetime.now()
|
||||
results: list[dict] = []
|
||||
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(amount_crc), 0),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((Transaction.currency == "USD", Transaction.amount), else_=0)
|
||||
),
|
||||
0,
|
||||
),
|
||||
).where(
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
)
|
||||
).first()
|
||||
results.append(
|
||||
{
|
||||
"year": y,
|
||||
"month": m,
|
||||
"total_crc": float(row[1]) if row else 0.0,
|
||||
"total_usd_raw": float(row[2]) if row else 0.0,
|
||||
"count": row[0] if row else 0,
|
||||
}
|
||||
)
|
||||
if m == 1:
|
||||
y, m = y - 1, 12
|
||||
else:
|
||||
m -= 1
|
||||
return list(reversed(results))
|
||||
|
||||
|
||||
def get_exchange_rate() -> dict:
|
||||
"""Latest USD/CRC exchange rate (buy and sell). All multi-currency data
|
||||
in the app is normalized to CRC using these rates."""
|
||||
rate = get_current_rate(_s())
|
||||
if not rate:
|
||||
return {"buy_rate": None, "sell_rate": None, "date": None}
|
||||
return {
|
||||
"buy_rate": rate.buy_rate,
|
||||
"sell_rate": rate.sell_rate,
|
||||
"date": rate.date.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def list_categories() -> list[dict]:
|
||||
"""All transaction categories (id, name, icon). Use when the user asks
|
||||
about a category and you need the id to filter by."""
|
||||
rows = _s().exec(select(Category).order_by(Category.name)).all()
|
||||
return [{"id": c.id, "name": c.name, "icon": c.icon} for c in rows]
|
||||
|
||||
|
||||
# Registered with the agent in agent.py
|
||||
TOOLS = [
|
||||
get_accounts,
|
||||
get_net_worth,
|
||||
get_recent_transactions,
|
||||
get_cycle_summary,
|
||||
get_budget_projection,
|
||||
list_recurring_items,
|
||||
get_pension_snapshots,
|
||||
get_salary_summary,
|
||||
get_municipal_receipts,
|
||||
get_analytics_by_category,
|
||||
get_monthly_trend,
|
||||
get_exchange_rate,
|
||||
list_categories,
|
||||
]
|
||||
@@ -3,12 +3,14 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import case
|
||||
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
|
||||
from app.services.budget_projection import get_cycle_range
|
||||
from app.services.exchange_rate import get_converted_amount_expr
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
@@ -43,10 +45,12 @@ def spending_by_category(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
query = (
|
||||
select(
|
||||
Transaction.category_id,
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
func.sum(amount_crc).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == "COMPRA")
|
||||
@@ -87,7 +91,12 @@ def monthly_trend(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Monthly spending totals using billing cycle boundaries (18th-18th)."""
|
||||
"""Monthly spending totals using billing cycle boundaries (18th-18th).
|
||||
|
||||
total_crc includes all currencies converted to CRC at current rates.
|
||||
total_usd is the raw USD amount (unconverted) for display purposes.
|
||||
"""
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
now = datetime.now()
|
||||
results = []
|
||||
month_names = [
|
||||
@@ -102,18 +111,10 @@ def monthly_trend(
|
||||
row = session.exec(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.currency == "CRC", Transaction.amount),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
case(
|
||||
(Transaction.currency == "USD", Transaction.amount),
|
||||
else_=0,
|
||||
)
|
||||
@@ -162,10 +163,12 @@ def daily_spending(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
query = (
|
||||
select(
|
||||
func.date(Transaction.date).label("day"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
func.sum(amount_crc).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == "COMPRA")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi import Depends
|
||||
|
||||
from app.auth import create_access_token
|
||||
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
)
|
||||
token = create_access_token(form_data.username)
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||
return {"username": username}
|
||||
|
||||
@@ -39,7 +39,9 @@ def list_recurring_items(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
query = select(RecurringItem)
|
||||
query = select(RecurringItem).where(
|
||||
RecurringItem.item_type != RecurringItemType.SAVINGS
|
||||
)
|
||||
if item_type:
|
||||
query = query.where(RecurringItem.item_type == item_type)
|
||||
if is_active is not None:
|
||||
@@ -101,7 +103,6 @@ class MonthlyProjectionResponse(BaseModel):
|
||||
year: int
|
||||
projected_income: float
|
||||
projected_fixed_expenses: float
|
||||
projected_savings: float
|
||||
actual_credit_card: float
|
||||
actual_cash: float
|
||||
actual_transfers: float
|
||||
@@ -118,7 +119,6 @@ class YearlyProjectionResponse(BaseModel):
|
||||
months: list[MonthlyProjectionResponse]
|
||||
annual_income: float
|
||||
annual_expenses: float
|
||||
annual_savings: float
|
||||
annual_net: float
|
||||
|
||||
|
||||
@@ -138,7 +138,6 @@ def get_yearly_projection(
|
||||
months = []
|
||||
annual_income = 0.0
|
||||
annual_expenses = 0.0
|
||||
annual_savings = 0.0
|
||||
annual_net = 0.0
|
||||
|
||||
for data in months_data:
|
||||
@@ -147,7 +146,6 @@ def get_yearly_projection(
|
||||
year=data["year"],
|
||||
projected_income=data["projected_income"],
|
||||
projected_fixed_expenses=data["projected_fixed_expenses"],
|
||||
projected_savings=data["projected_savings"],
|
||||
actual_credit_card=data["actual_credit_card"],
|
||||
actual_cash=data["actual_cash"],
|
||||
actual_transfers=data["actual_transfers"],
|
||||
@@ -161,7 +159,6 @@ def get_yearly_projection(
|
||||
months.append(monthly)
|
||||
annual_income += data["projected_income"]
|
||||
annual_expenses += data["gran_total_egresos"]
|
||||
annual_savings += data["projected_savings"]
|
||||
annual_net += data["net_balance"]
|
||||
|
||||
return YearlyProjectionResponse(
|
||||
@@ -169,7 +166,6 @@ def get_yearly_projection(
|
||||
months=months,
|
||||
annual_income=annual_income,
|
||||
annual_expenses=annual_expenses,
|
||||
annual_savings=annual_savings,
|
||||
annual_net=annual_net,
|
||||
)
|
||||
|
||||
@@ -194,19 +190,23 @@ class ActualsBySource(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class CCCategorySpending(BaseModel):
|
||||
category_name: str
|
||||
amount: float
|
||||
|
||||
|
||||
class MonthlyDetailResponse(BaseModel):
|
||||
year: int
|
||||
month: int
|
||||
income_items: list[RecurringItemDetail]
|
||||
expense_items: list[RecurringItemDetail]
|
||||
savings_items: list[RecurringItemDetail]
|
||||
actuals_by_source: list[ActualsBySource]
|
||||
total_projected_income: float
|
||||
total_projected_expenses: float
|
||||
total_projected_savings: float
|
||||
uncovered_actual: float
|
||||
gran_total_egresos: float
|
||||
net_balance: float
|
||||
cc_by_category: list[CCCategorySpending]
|
||||
|
||||
|
||||
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
|
||||
@@ -222,14 +222,13 @@ def get_monthly_detail(
|
||||
month=data["month"],
|
||||
income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
|
||||
expense_items=[RecurringItemDetail(**i) for i in data["expense_items"]],
|
||||
savings_items=[RecurringItemDetail(**i) for i in data["savings_items"]],
|
||||
actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]],
|
||||
total_projected_income=data["projected_income"],
|
||||
total_projected_expenses=data["projected_fixed_expenses"],
|
||||
total_projected_savings=data["projected_savings"],
|
||||
uncovered_actual=data["uncovered_actual"],
|
||||
gran_total_egresos=data["gran_total_egresos"],
|
||||
net_balance=data["net_balance"],
|
||||
cc_by_category=[CCCategorySpending(**c) for c in data["cc_by_category"]],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ from sqlmodel import Session, col, func, select
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import Transaction, TransactionRead, TransactionType
|
||||
from app.services.exchange_rate import get_converted_amount_expr
|
||||
|
||||
router = APIRouter(prefix="/salarios", tags=["salarios"])
|
||||
|
||||
SALARIO_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
|
||||
|
||||
|
||||
class SalariosSummary(BaseModel):
|
||||
count: int
|
||||
@@ -27,7 +30,7 @@ def list_salarios(
|
||||
):
|
||||
query = (
|
||||
select(Transaction)
|
||||
.where(Transaction.transaction_type == TransactionType.DEPOSITO)
|
||||
.where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
|
||||
.order_by(col(Transaction.date).desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -40,12 +43,13 @@ def salarios_summary(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
result = session.exec(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(Transaction.amount), 0),
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.max(Transaction.date),
|
||||
).where(Transaction.transaction_type == TransactionType.DEPOSITO)
|
||||
).where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
|
||||
).first()
|
||||
return SalariosSummary(
|
||||
count=result[0] if result else 0,
|
||||
|
||||
83
backend/app/api/v1/endpoints/savings_accrual.py
Normal file
83
backend/app/api/v1/endpoints/savings_accrual.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import (
|
||||
SavingsAccrual,
|
||||
SavingsAccrualCreate,
|
||||
SavingsAccrualRead,
|
||||
SavingsAccrualUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/savings-accrual", tags=["savings-accrual"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SavingsAccrualRead])
|
||||
def list_accruals(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
query = select(SavingsAccrual).order_by(
|
||||
col(SavingsAccrual.year).desc(), col(SavingsAccrual.month).desc()
|
||||
)
|
||||
return session.exec(query).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=SavingsAccrualRead, status_code=201)
|
||||
def create_accrual(
|
||||
data: SavingsAccrualCreate,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
existing = session.exec(
|
||||
select(SavingsAccrual).where(
|
||||
SavingsAccrual.year == data.year,
|
||||
SavingsAccrual.month == data.month,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})",
|
||||
)
|
||||
accrual = SavingsAccrual.model_validate(data)
|
||||
accrual.applied_at = datetime.utcnow()
|
||||
session.add(accrual)
|
||||
session.commit()
|
||||
session.refresh(accrual)
|
||||
return accrual
|
||||
|
||||
|
||||
@router.patch("/{accrual_id}", response_model=SavingsAccrualRead)
|
||||
def update_accrual(
|
||||
accrual_id: int,
|
||||
data: SavingsAccrualUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
accrual = session.get(SavingsAccrual, accrual_id)
|
||||
if not accrual:
|
||||
raise HTTPException(status_code=404, detail="Accrual not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(accrual, key, value)
|
||||
session.add(accrual)
|
||||
session.commit()
|
||||
session.refresh(accrual)
|
||||
return accrual
|
||||
|
||||
|
||||
@router.delete("/{accrual_id}", status_code=204)
|
||||
def delete_accrual(
|
||||
accrual_id: int = Path(...),
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
accrual = session.get(SavingsAccrual, accrual_id)
|
||||
if not accrual:
|
||||
raise HTTPException(status_code=404, detail="Accrual not found")
|
||||
session.delete(accrual)
|
||||
session.commit()
|
||||
@@ -19,19 +19,12 @@ from app.models.models import (
|
||||
TransactionUpdate,
|
||||
)
|
||||
|
||||
from app.services.budget_projection import get_cycle_range, get_previous_cycle
|
||||
from app.services.exchange_rate import get_converted_amount_expr
|
||||
|
||||
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
|
||||
@@ -54,6 +47,7 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
|
||||
@router.get("/", response_model=list[TransactionRead])
|
||||
def list_transactions(
|
||||
source: Optional[TransactionSource] = None,
|
||||
exclude_source: Optional[TransactionSource] = None,
|
||||
search: Optional[str] = None,
|
||||
category_id: Optional[int] = None,
|
||||
cycle_year: Optional[int] = None,
|
||||
@@ -68,13 +62,32 @@ def list_transactions(
|
||||
query = select(Transaction)
|
||||
if source:
|
||||
query = query.where(Transaction.source == source)
|
||||
if exclude_source:
|
||||
query = query.where(Transaction.source != exclude_source)
|
||||
if category_id:
|
||||
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)
|
||||
prev_y, prev_m = get_previous_cycle(cycle_year, cycle_month)
|
||||
prev_start, prev_end = get_cycle_range(prev_y, prev_m)
|
||||
# Normal transactions in this cycle (not deferred) + deferred from previous cycle
|
||||
from sqlalchemy import or_, and_
|
||||
query = query.where(
|
||||
or_(
|
||||
and_(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
),
|
||||
and_(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
),
|
||||
)
|
||||
)
|
||||
elif start_date and end_date:
|
||||
query = query.where(
|
||||
Transaction.date >= datetime.fromisoformat(start_date),
|
||||
@@ -98,6 +111,7 @@ def list_billing_cycles(
|
||||
return []
|
||||
|
||||
min_date, max_date = result
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
cycles = []
|
||||
# Determine which cycle the min_date falls into
|
||||
@@ -117,7 +131,7 @@ def list_billing_cycles(
|
||||
|
||||
# Count transactions in this cycle
|
||||
count_result = session.exec(
|
||||
select(func.count(), func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= start, Transaction.date < end
|
||||
)
|
||||
).first()
|
||||
@@ -182,16 +196,23 @@ def create_transaction(
|
||||
session.refresh(tx)
|
||||
|
||||
# Send push notification
|
||||
symbol = "₡" if tx.currency == Currency.CRC else tx.currency.value
|
||||
symbols = {Currency.CRC: "₡", Currency.USD: "$", Currency.EUR: "€"}
|
||||
symbol = symbols.get(tx.currency, tx.currency.value)
|
||||
amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}"
|
||||
is_deposit = tx.transaction_type == TransactionType.DEPOSITO
|
||||
is_income = tx.transaction_type in (TransactionType.DEPOSITO, TransactionType.SALARY)
|
||||
is_salary = tx.transaction_type == TransactionType.SALARY
|
||||
label = "salario" if is_salary else ("depósito" if is_income else tx.transaction_type.value.lower())
|
||||
send_push_to_all(
|
||||
session,
|
||||
title=f"{'🏦' if is_deposit else '💳'} {tx.merchant}",
|
||||
body=f"{amount_str} — {tx.bank.value} {'depósito' if is_deposit else tx.transaction_type.value.lower()}",
|
||||
url="/salarios" if is_deposit else "/budget",
|
||||
title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
|
||||
body=f"{amount_str} — {tx.bank.value} {label}",
|
||||
url="/salarios" if is_income else "/budget",
|
||||
)
|
||||
|
||||
if is_salary:
|
||||
from app.services.savings_accrual import maybe_apply_monthly_savings
|
||||
maybe_apply_monthly_savings(session, tx)
|
||||
|
||||
return tx
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.api.v1.endpoints import (
|
||||
notifications,
|
||||
pensions,
|
||||
salarios,
|
||||
savings_accrual,
|
||||
settings,
|
||||
tokens,
|
||||
transactions,
|
||||
@@ -32,3 +33,4 @@ api_router.include_router(notifications.router)
|
||||
api_router.include_router(salarios.router)
|
||||
api_router.include_router(pensions.router)
|
||||
api_router.include_router(municipal_receipts.router)
|
||||
api_router.include_router(savings_accrual.router)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlmodel import Session, select
|
||||
@@ -22,8 +24,8 @@ 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
|
||||
def _validate_token(token: str) -> str:
|
||||
"""Validate JWT and return subject, or raise 401."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
@@ -51,3 +53,26 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
||||
return f"api:{api_token.name}"
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
def get_current_user_cookie_or_bearer(
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
ws_token: Optional[str] = Cookie(default=None),
|
||||
) -> str:
|
||||
"""Accepts httpOnly cookie (SPA) or Bearer token (API clients / n8n)."""
|
||||
token: Optional[str] = None
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
elif ws_token:
|
||||
token = ws_token
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return _validate_token(token)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
ws_token: Optional[str] = Cookie(default=None),
|
||||
) -> str:
|
||||
"""SPA cookie or Bearer token. Single dependency for all v1 endpoints."""
|
||||
return get_current_user_cookie_or_bearer(authorization, ws_token)
|
||||
|
||||
@@ -12,6 +12,8 @@ class Settings(BaseSettings):
|
||||
VAPID_PRIVATE_KEY: str = ""
|
||||
VAPID_PUBLIC_KEY: str = ""
|
||||
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
||||
OPENAI_API_KEY: str = ""
|
||||
AGENT_MODEL: str = "gpt-5.4-mini"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
|
||||
from app.config import settings
|
||||
@@ -9,6 +10,72 @@ def init_db():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""Run idempotent schema migrations for columns added after initial create."""
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(text("ALTER TYPE currency ADD VALUE IF NOT EXISTS 'EUR'"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text("ALTER TYPE transactiontype ADD VALUE IF NOT EXISTS 'SALARY'")
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS savingsaccrual (
|
||||
id SERIAL PRIMARY KEY,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
memp_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
|
||||
mpat_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
|
||||
trigger_transaction_id INTEGER,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
notes TEXT,
|
||||
CONSTRAINT savingsaccrual_year_month_key UNIQUE (year, month)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO savingsaccrual (year, month, memp_amount, mpat_amount, notes)
|
||||
VALUES
|
||||
(2026, 2, 200000, 200000, 'Seeded: historical baseline'),
|
||||
(2026, 3, 200000, 200000, 'Seeded: historical baseline')
|
||||
ON CONFLICT (year, month) DO NOTHING
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
@@ -1,19 +1,77 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.agent import build_agent
|
||||
from app.agent.tools import reset_session, set_session
|
||||
from app.api.v1.router import api_router
|
||||
from app.auth import ALGORITHM, create_access_token
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.db import get_session, init_db, run_migrations
|
||||
from app.seed import seed_db
|
||||
from app.services.exchange_rate import refresh_rates_periodically
|
||||
|
||||
|
||||
AGENT_PATH = "/api/v1/agent/agui"
|
||||
|
||||
|
||||
def _pair_orphan_tool_calls(messages: list) -> list:
|
||||
"""Inject synthetic tool responses for any assistant tool_calls that have
|
||||
no matching tool message. OpenAI rejects histories where a tool_calls
|
||||
entry is not immediately followed by the corresponding tool response."""
|
||||
out: list = []
|
||||
pending: list[str] = []
|
||||
|
||||
def flush():
|
||||
for call_id in pending:
|
||||
out.append({"role": "tool", "tool_call_id": call_id, "content": ""})
|
||||
pending.clear()
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role == "tool":
|
||||
call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||
if call_id and call_id in pending:
|
||||
pending.remove(call_id)
|
||||
out.append(msg)
|
||||
continue
|
||||
if role == "assistant":
|
||||
flush()
|
||||
out.append(msg)
|
||||
for tc in msg.get("tool_calls") or msg.get("toolCalls") or []:
|
||||
tc_id = tc.get("id") if isinstance(tc, dict) else None
|
||||
if tc_id:
|
||||
pending.append(tc_id)
|
||||
continue
|
||||
flush()
|
||||
out.append(msg)
|
||||
|
||||
flush()
|
||||
return out
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
run_migrations()
|
||||
seed_db()
|
||||
yield
|
||||
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
rate_refresh_task.cancel()
|
||||
try:
|
||||
await rate_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
||||
@@ -26,9 +84,106 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def agent_auth_and_session(request: Request, call_next):
|
||||
"""For the AG-UI route, validate the JWT, repair message history, and
|
||||
bind a DB session to a ContextVar so agent tools can query without going
|
||||
through Depends."""
|
||||
if not request.url.path.startswith(AGENT_PATH):
|
||||
return await call_next(request)
|
||||
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
token: str | None = None
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
else:
|
||||
cookie_header = request.headers.get("cookie", "")
|
||||
m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header)
|
||||
if m:
|
||||
token = m.group(1)
|
||||
|
||||
if not token:
|
||||
return Response(status_code=401, content="Missing auth")
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
if not payload.get("sub"):
|
||||
return Response(status_code=401, content="Invalid token")
|
||||
except JWTError:
|
||||
return Response(status_code=401, content="Invalid token")
|
||||
|
||||
# Repair orphan tool_calls before the MAF agent sees the message history.
|
||||
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
||||
raw = await request.body()
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
if isinstance(body.get("messages"), list):
|
||||
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
||||
raw = json.dumps(body).encode()
|
||||
except Exception:
|
||||
pass
|
||||
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
||||
request._body = raw # type: ignore[attr-defined]
|
||||
|
||||
session_gen = get_session()
|
||||
session = next(session_gen)
|
||||
token_var = set_session(session)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_session(token_var)
|
||||
try:
|
||||
next(session_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
|
||||
# Register app routes
|
||||
app.include_router(api_router)
|
||||
|
||||
# Mount the AG-UI agent endpoint.
|
||||
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"app": "WealthySmart", "version": "0.1.0"}
|
||||
|
||||
|
||||
# ── Cookie-based auth endpoints (used by the Vite SPA) ──────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def cookie_login(body: LoginRequest, response: Response):
|
||||
if (
|
||||
body.username != settings.ADMIN_USERNAME
|
||||
or body.password != settings.ADMIN_PASSWORD
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token(body.username)
|
||||
response.set_cookie(
|
||||
key="ws_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
secure=False, # set True behind TLS in production via nginx
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout", status_code=204)
|
||||
def cookie_logout(response: Response):
|
||||
response.delete_cookie("ws_token")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
@@ -24,6 +24,7 @@ class TransactionType(str, enum.Enum):
|
||||
COMPRA = "COMPRA"
|
||||
DEVOLUCION = "DEVOLUCION"
|
||||
DEPOSITO = "DEPOSITO"
|
||||
SALARY = "SALARY"
|
||||
|
||||
|
||||
class TransactionSource(str, enum.Enum):
|
||||
@@ -35,6 +36,7 @@ class TransactionSource(str, enum.Enum):
|
||||
class Currency(str, enum.Enum):
|
||||
CRC = "CRC"
|
||||
USD = "USD"
|
||||
EUR = "EUR"
|
||||
BTC = "BTC"
|
||||
XMR = "XMR"
|
||||
|
||||
@@ -140,6 +142,7 @@ class TransactionBase(SQLModel):
|
||||
bank: Bank = Bank.BAC
|
||||
notes: Optional[str] = None
|
||||
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
||||
deferred_to_next_cycle: bool = Field(default=False)
|
||||
|
||||
|
||||
class Transaction(TransactionBase, table=True):
|
||||
@@ -168,6 +171,7 @@ class TransactionUpdate(SQLModel):
|
||||
source: Optional[TransactionSource] = None
|
||||
notes: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
deferred_to_next_cycle: Optional[bool] = None
|
||||
|
||||
|
||||
# --- Exchange Rate ---
|
||||
@@ -360,6 +364,39 @@ class BalanceOverrideRead(SQLModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# --- Savings Accrual ---
|
||||
|
||||
|
||||
class SavingsAccrualBase(SQLModel):
|
||||
year: int
|
||||
month: int
|
||||
memp_amount: float = 200000.0
|
||||
mpat_amount: float = 200000.0
|
||||
trigger_transaction_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SavingsAccrual(SavingsAccrualBase, table=True):
|
||||
__table_args__ = (UniqueConstraint("year", "month"),)
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
applied_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class SavingsAccrualCreate(SavingsAccrualBase):
|
||||
pass
|
||||
|
||||
|
||||
class SavingsAccrualRead(SavingsAccrualBase):
|
||||
id: int
|
||||
applied_at: datetime
|
||||
|
||||
|
||||
class SavingsAccrualUpdate(SQLModel):
|
||||
memp_amount: Optional[float] = None
|
||||
mpat_amount: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# --- Municipal Receipt ---
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import calendar
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Session, func, select
|
||||
from sqlmodel import Session, col, func, select
|
||||
|
||||
from app.models.models import (
|
||||
BalanceOverride,
|
||||
@@ -12,6 +12,7 @@ from app.models.models import (
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
)
|
||||
from app.services.exchange_rate import get_converted_amount_expr
|
||||
|
||||
MIN_YEAR = 2026
|
||||
MAX_YEAR = 2030
|
||||
@@ -19,6 +20,9 @@ MAX_YEAR = 2030
|
||||
FRESH_START_YEAR = 2026
|
||||
FRESH_START_MONTH = 3
|
||||
|
||||
# Income-like transaction types that should never be counted as expenses
|
||||
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
|
||||
|
||||
|
||||
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
|
||||
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
|
||||
@@ -71,87 +75,323 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
return start, end
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_previous_cycle(year: int, month: int) -> tuple[int, int]:
|
||||
"""Return (year, month) for the billing cycle preceding the given one."""
|
||||
if month == 1:
|
||||
return year - 1, 12
|
||||
return year, month - 1
|
||||
|
||||
|
||||
def compute_actuals_by_source(
|
||||
session: Session, year: int, month: int
|
||||
) -> dict[str, dict]:
|
||||
"""Query actual transaction totals for a calendar month, grouped by source."""
|
||||
start, end = get_month_range(year, month)
|
||||
"""Query actual transaction totals grouped by source.
|
||||
|
||||
Credit card uses billing cycle (18th-18th) with deferred logic.
|
||||
Cash/Transfer use calendar month (1st-1st).
|
||||
"""
|
||||
# CC billing cycle for budget month M is the cycle that *ends* around the 18th of M
|
||||
# i.e. cycle (M-1): from (M-1)/18 to M/18, paid with month M salary
|
||||
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
|
||||
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
|
||||
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
|
||||
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||
cal_start, cal_end = get_month_range(year, month)
|
||||
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
results = {}
|
||||
for source in TransactionSource:
|
||||
compra = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
)
|
||||
).one()
|
||||
devolucion = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
)
|
||||
).one()
|
||||
count = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
).one()
|
||||
if source == TransactionSource.CREDIT_CARD:
|
||||
start, end = cc_start, cc_end
|
||||
# Normal transactions in this cycle (not deferred)
|
||||
compra_normal = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
# Deferred from previous cycle
|
||||
compra_deferred = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
compra = float(compra_normal) + float(compra_deferred)
|
||||
|
||||
compra_val = float(compra)
|
||||
devolucion_val = float(devolucion)
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra_val,
|
||||
"total_devolucion": devolucion_val,
|
||||
"net": compra_val - devolucion_val,
|
||||
"count": count,
|
||||
}
|
||||
dev_normal = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
dev_deferred = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
devolucion = float(dev_normal) + float(dev_deferred)
|
||||
|
||||
count_normal = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
count_deferred = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
count = count_normal + count_deferred
|
||||
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra,
|
||||
"total_devolucion": devolucion,
|
||||
"net": compra - devolucion,
|
||||
"count": count,
|
||||
}
|
||||
else:
|
||||
# Cash / Transfer: calendar month, no deferred logic
|
||||
compra = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
)
|
||||
).one()
|
||||
devolucion = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
)
|
||||
).one()
|
||||
count = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
)
|
||||
).one()
|
||||
|
||||
compra_val = float(compra)
|
||||
devolucion_val = float(devolucion)
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra_val,
|
||||
"total_devolucion": devolucion_val,
|
||||
"net": compra_val - devolucion_val,
|
||||
"count": count,
|
||||
}
|
||||
return results
|
||||
|
||||
|
||||
def compute_actuals_by_category(
|
||||
session: Session, year: int, month: int
|
||||
) -> dict[int, float]:
|
||||
"""Return {category_id: net_amount} for actual transactions in a calendar month."""
|
||||
start, end = get_month_range(year, month)
|
||||
"""Return {category_id: net_amount} for actual transactions.
|
||||
|
||||
rows = session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
Credit card uses billing cycle (18th-18th) with deferred logic.
|
||||
Cash/Transfer use calendar month (1st-1st).
|
||||
"""
|
||||
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
|
||||
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
|
||||
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
|
||||
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||
cal_start, cal_end = get_month_range(year, month)
|
||||
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
totals: dict[int, float] = {}
|
||||
for cat_id, tx_type, amount in rows:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
totals[cat_id] = totals.get(cat_id, 0) + val
|
||||
|
||||
def _merge_rows(rows: list) -> None:
|
||||
for cat_id, tx_type, amount in rows:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
totals[cat_id] = totals.get(cat_id, 0) + val
|
||||
|
||||
# 1) CC normal in this cycle (not deferred)
|
||||
_merge_rows(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(amount_crc),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= cc_start,
|
||||
Transaction.date < cc_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
# 2) CC deferred from previous cycle
|
||||
_merge_rows(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(amount_crc),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
# 3) Non-CC: calendar month
|
||||
_merge_rows(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(amount_crc),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
return totals
|
||||
|
||||
|
||||
def compute_cc_by_category(
|
||||
session: Session, year: int, month: int
|
||||
) -> list[dict]:
|
||||
"""Return credit card spending by category for the billing cycle."""
|
||||
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
|
||||
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
|
||||
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
|
||||
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
totals: dict[int | None, float] = {}
|
||||
|
||||
def _merge(rows: list) -> None:
|
||||
for cat_id, tx_type, amount in rows:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
totals[cat_id] = totals.get(cat_id, 0) + val
|
||||
|
||||
# CC normal in this cycle
|
||||
_merge(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(amount_crc),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= cc_start,
|
||||
Transaction.date < cc_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
# CC deferred from previous cycle
|
||||
_merge(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(amount_crc),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
# Resolve category names
|
||||
from app.models.models import Category
|
||||
|
||||
result = []
|
||||
for cat_id, amount in totals.items():
|
||||
if amount <= 0:
|
||||
continue
|
||||
if cat_id is not None:
|
||||
cat = session.get(Category, cat_id)
|
||||
name = cat.name if cat else "Sin categoría"
|
||||
else:
|
||||
name = "Sin categoría"
|
||||
result.append({"category_name": name, "amount": round(amount, 2)})
|
||||
|
||||
return sorted(result, key=lambda x: x["amount"], reverse=True)
|
||||
|
||||
|
||||
def compute_monthly_projection(
|
||||
session: Session, year: int, month: int
|
||||
) -> dict:
|
||||
"""Compute full monthly projection with no-double-count logic."""
|
||||
items = session.exec(
|
||||
select(RecurringItem).where(RecurringItem.is_active == True) # noqa: E712
|
||||
select(RecurringItem).where(
|
||||
RecurringItem.is_active == True, # noqa: E712
|
||||
RecurringItem.item_type != RecurringItemType.SAVINGS,
|
||||
)
|
||||
).all()
|
||||
|
||||
actuals_by_source = compute_actuals_by_source(session, year, month)
|
||||
@@ -159,11 +399,9 @@ def compute_monthly_projection(
|
||||
|
||||
income_items = []
|
||||
expense_items = []
|
||||
savings_items = []
|
||||
|
||||
total_income = 0.0
|
||||
total_fixed_expenses = 0.0
|
||||
total_savings = 0.0
|
||||
|
||||
for item in items:
|
||||
effective = get_effective_amount(item, month, year)
|
||||
@@ -197,10 +435,6 @@ def compute_monthly_projection(
|
||||
total_fixed_expenses += effective
|
||||
expense_items.append(detail)
|
||||
|
||||
elif item.item_type == RecurringItemType.SAVINGS:
|
||||
savings_items.append(detail)
|
||||
total_savings += effective
|
||||
|
||||
# Sum actuals from sources for categories NOT covered by recurring items
|
||||
covered_category_ids = {
|
||||
item.category_id
|
||||
@@ -215,34 +449,75 @@ def compute_monthly_projection(
|
||||
if cat_id not in covered_category_ids:
|
||||
uncovered_actual += amount
|
||||
|
||||
# Also add transactions with no category
|
||||
start, end = get_month_range(year, month)
|
||||
uncategorized = session.exec(
|
||||
select(
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
for tx_type, amount in uncategorized:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
uncovered_actual += val
|
||||
# Also add transactions with no category (hybrid ranges + deferred)
|
||||
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
|
||||
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
|
||||
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
|
||||
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||
cal_start, cal_end = get_month_range(year, month)
|
||||
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
def _sum_uncategorized(rows: list) -> float:
|
||||
total = 0.0
|
||||
for tx_type, amount in rows:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
total += val
|
||||
return total
|
||||
|
||||
# CC uncategorized: this cycle (not deferred)
|
||||
uncovered_actual += _sum_uncategorized(
|
||||
session.exec(
|
||||
select(Transaction.transaction_type, func.sum(amount_crc))
|
||||
.where(
|
||||
Transaction.date >= cc_start,
|
||||
Transaction.date < cc_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
# CC uncategorized: deferred from previous cycle
|
||||
uncovered_actual += _sum_uncategorized(
|
||||
session.exec(
|
||||
select(Transaction.transaction_type, func.sum(amount_crc))
|
||||
.where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
# Non-CC uncategorized: calendar month
|
||||
uncovered_actual += _sum_uncategorized(
|
||||
session.exec(
|
||||
select(Transaction.transaction_type, func.sum(amount_crc))
|
||||
.where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
actual_credit_card = actuals_by_source.get("CREDIT_CARD", {}).get("net", 0)
|
||||
actual_cash = actuals_by_source.get("CASH", {}).get("net", 0)
|
||||
actual_transfers = actuals_by_source.get("TRANSFER", {}).get("net", 0)
|
||||
cc_by_category = compute_cc_by_category(session, year, month)
|
||||
|
||||
gran_total = total_fixed_expenses + uncovered_actual
|
||||
# Savings are NOT deducted — they are already deducted from gross salary
|
||||
# (the income amounts are net, post-savings)
|
||||
net_balance = total_income - gran_total
|
||||
|
||||
return {
|
||||
@@ -250,7 +525,6 @@ def compute_monthly_projection(
|
||||
"month": month,
|
||||
"projected_income": total_income,
|
||||
"projected_fixed_expenses": total_fixed_expenses,
|
||||
"projected_savings": total_savings,
|
||||
"actual_credit_card": actual_credit_card,
|
||||
"actual_cash": actual_cash,
|
||||
"actual_transfers": actual_transfers,
|
||||
@@ -259,8 +533,8 @@ def compute_monthly_projection(
|
||||
"net_balance": net_balance,
|
||||
"income_items": income_items,
|
||||
"expense_items": expense_items,
|
||||
"savings_items": savings_items,
|
||||
"actuals_by_source": list(actuals_by_source.values()),
|
||||
"cc_by_category": cc_by_category,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import case
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.models.models import ExchangeRate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Scheduled refresh interval — 4x/day
|
||||
REFRESH_INTERVAL_SECONDS = 6 * 3600
|
||||
|
||||
# BCCR indicators: 317 = buy, 318 = sell
|
||||
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
|
||||
|
||||
@@ -23,6 +32,14 @@ _cache: dict[str, tuple[ExchangeRate, datetime]] = {}
|
||||
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
|
||||
CACHE_TTL = timedelta(hours=1)
|
||||
|
||||
# Generic X/CRC mid-market rate cache (by currency code)
|
||||
_xcrc_cache: dict[str, tuple[float, datetime]] = {}
|
||||
_last_known_xcrc: dict[str, float] = {}
|
||||
|
||||
# CoinGecko ids for supported crypto codes
|
||||
_COINGECKO_IDS = {"BTC": "bitcoin", "XMR": "monero"}
|
||||
COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price"
|
||||
|
||||
|
||||
def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
||||
"""Fetch a single indicator from BCCR API."""
|
||||
@@ -167,6 +184,188 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_fiat_crc_mid(code: str) -> float | None:
|
||||
"""Derive {code}/CRC mid-market rate from ExchangeRate-API (USD-based).
|
||||
|
||||
X/CRC = CRC_per_USD / X_per_USD
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("result") == "success":
|
||||
crc = data["rates"].get("CRC")
|
||||
x = data["rates"].get(code)
|
||||
if crc and x:
|
||||
return float(crc) / float(x)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_crypto_crc(code: str) -> float | None:
|
||||
"""Fetch {code}/CRC spot from CoinGecko."""
|
||||
coin_id = _COINGECKO_IDS.get(code)
|
||||
if not coin_id:
|
||||
return None
|
||||
try:
|
||||
resp = httpx.get(
|
||||
COINGECKO_URL,
|
||||
params={"ids": coin_id, "vs_currencies": "crc"},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
price = data.get(coin_id, {}).get("crc")
|
||||
if price:
|
||||
return float(price)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_crc_rate(code: str) -> float | None:
|
||||
"""Get current {code}→CRC rate (cached 1 hour). Fiat via ExchangeRate-API, crypto via CoinGecko."""
|
||||
if code == "CRC":
|
||||
return 1.0
|
||||
|
||||
cached = _xcrc_cache.get(code)
|
||||
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
if code in _COINGECKO_IDS:
|
||||
rate = _fetch_crypto_crc(code)
|
||||
else:
|
||||
rate = _fetch_fiat_crc_mid(code)
|
||||
|
||||
if rate is not None:
|
||||
_xcrc_cache[code] = (rate, datetime.utcnow())
|
||||
_last_known_xcrc[code] = rate
|
||||
return rate
|
||||
|
||||
return _last_known_xcrc.get(code)
|
||||
|
||||
|
||||
def get_crc_multipliers(session: Session) -> dict[str, float]:
|
||||
"""Return {currency_code: CRC_multiplier} for every supported currency."""
|
||||
from app.models.models import Currency
|
||||
|
||||
multipliers: dict[str, float] = {"CRC": 1.0}
|
||||
|
||||
usd_rate = get_current_rate(session)
|
||||
if usd_rate:
|
||||
multipliers["USD"] = usd_rate.sell_rate
|
||||
|
||||
for code in (c.value for c in Currency):
|
||||
if code in multipliers:
|
||||
continue
|
||||
rate = get_crc_rate(code)
|
||||
if rate is not None:
|
||||
multipliers[code] = rate
|
||||
|
||||
return multipliers
|
||||
|
||||
|
||||
def get_converted_amount_expr(session: Session):
|
||||
"""Return a SQLAlchemy expression converting Transaction.amount to CRC.
|
||||
|
||||
Builds a CASE that multiplies by the per-currency CRC rate; CRC passes through.
|
||||
Missing rates fall back to 1.0 (treat as CRC) rather than 0.0 so a transient
|
||||
API outage does not silently zero out foreign-currency totals.
|
||||
"""
|
||||
from app.models.models import Transaction
|
||||
|
||||
multipliers = get_crc_multipliers(session)
|
||||
whens = [
|
||||
(Transaction.currency == code, Transaction.amount * mult)
|
||||
for code, mult in multipliers.items()
|
||||
if code != "CRC"
|
||||
]
|
||||
if not whens:
|
||||
return Transaction.amount
|
||||
return case(*whens, else_=Transaction.amount)
|
||||
|
||||
|
||||
def _refresh_usd_rate() -> bool:
|
||||
"""Force-fetch USD/CRC from APIs and persist to DB. Returns True on success."""
|
||||
fetched = _fetch_rate_from_apis()
|
||||
if fetched is None:
|
||||
return False
|
||||
buy, sell = fetched
|
||||
with Session(engine) as session:
|
||||
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
|
||||
session.add(rate)
|
||||
session.commit()
|
||||
session.refresh(rate)
|
||||
_remember(rate)
|
||||
return True
|
||||
|
||||
|
||||
def _refresh_other_rate(code: str) -> bool:
|
||||
"""Force-fetch {code}/CRC and update in-memory cache. Returns True on success."""
|
||||
if code in _COINGECKO_IDS:
|
||||
rate = _fetch_crypto_crc(code)
|
||||
else:
|
||||
rate = _fetch_fiat_crc_mid(code)
|
||||
if rate is None:
|
||||
return False
|
||||
_xcrc_cache[code] = (rate, datetime.utcnow())
|
||||
_last_known_xcrc[code] = rate
|
||||
return True
|
||||
|
||||
|
||||
def refresh_all_rates() -> dict[str, bool]:
|
||||
"""Force-refresh every supported currency.
|
||||
|
||||
Each currency is refreshed independently — one failure does not affect others.
|
||||
On success the DB (for USD) and in-memory caches are updated. On failure the
|
||||
previous value is retained via `_last_known_*` / stale-DB fallback, so callers
|
||||
always see the most recent working rate.
|
||||
"""
|
||||
from app.models.models import Currency
|
||||
|
||||
results: dict[str, bool] = {}
|
||||
|
||||
try:
|
||||
results["USD"] = _refresh_usd_rate()
|
||||
except Exception:
|
||||
logger.exception("USD rate refresh failed")
|
||||
results["USD"] = False
|
||||
|
||||
for currency in Currency:
|
||||
code = currency.value
|
||||
if code in ("CRC", "USD"):
|
||||
continue
|
||||
try:
|
||||
results[code] = _refresh_other_rate(code)
|
||||
except Exception:
|
||||
logger.exception("%s rate refresh failed", code)
|
||||
results[code] = False
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def refresh_rates_periodically(
|
||||
interval_seconds: int = REFRESH_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
"""Background loop that refreshes all currency rates every `interval_seconds`.
|
||||
|
||||
Never raises — failures are logged and the last-known rates are retained.
|
||||
Runs one refresh immediately on startup, then sleeps on the fixed interval.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
report = await asyncio.to_thread(refresh_all_rates)
|
||||
ok = sorted(k for k, v in report.items() if v)
|
||||
failed = sorted(k for k, v in report.items() if not v)
|
||||
logger.info(
|
||||
"Exchange rate refresh complete: ok=%s failed=%s", ok, failed
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Exchange rate refresh loop crashed")
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
|
||||
"""Get historical exchange rates."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
62
backend/app/services/savings_accrual.py
Normal file
62
backend/app/services/savings_accrual.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models.models import (
|
||||
Account,
|
||||
AccountType,
|
||||
Bank,
|
||||
SavingsAccrual,
|
||||
Transaction,
|
||||
)
|
||||
|
||||
MEMP_MONTHLY = 200000.0
|
||||
MPAT_MONTHLY = 200000.0
|
||||
|
||||
|
||||
def _get_savings_account(session: Session, bank: Bank) -> Account | None:
|
||||
return session.exec(
|
||||
select(Account).where(
|
||||
Account.account_type == AccountType.SAVINGS,
|
||||
Account.bank == bank,
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
def maybe_apply_monthly_savings(session: Session, tx: Transaction) -> SavingsAccrual | None:
|
||||
"""Apply monthly savings contribution if this is the first salary of the month.
|
||||
|
||||
Idempotent: if a SavingsAccrual row already exists for (year, month), do nothing.
|
||||
Bumps MEMP and MPAT savings account balances and records the accrual.
|
||||
"""
|
||||
year = tx.date.year
|
||||
month = tx.date.month
|
||||
|
||||
existing = session.exec(
|
||||
select(SavingsAccrual).where(
|
||||
SavingsAccrual.year == year,
|
||||
SavingsAccrual.month == month,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
return None
|
||||
|
||||
memp = _get_savings_account(session, Bank.MEMP)
|
||||
mpat = _get_savings_account(session, Bank.MPAT)
|
||||
if memp is None or mpat is None:
|
||||
return None
|
||||
|
||||
memp.balance += MEMP_MONTHLY
|
||||
mpat.balance += MPAT_MONTHLY
|
||||
session.add(memp)
|
||||
session.add(mpat)
|
||||
|
||||
accrual = SavingsAccrual(
|
||||
year=year,
|
||||
month=month,
|
||||
memp_amount=MEMP_MONTHLY,
|
||||
mpat_amount=MPAT_MONTHLY,
|
||||
trigger_transaction_id=tx.id,
|
||||
)
|
||||
session.add(accrual)
|
||||
session.commit()
|
||||
session.refresh(accrual)
|
||||
return accrual
|
||||
@@ -11,3 +11,6 @@ httpx
|
||||
pywebpush
|
||||
py-vapid
|
||||
python-dateutil
|
||||
agent-framework==1.2.1
|
||||
agent-framework-ag-ui==1.0.0b260428
|
||||
agent-framework-openai==1.2.1
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
@@ -47,22 +49,32 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
dockerfile: Dockerfile
|
||||
target: runner
|
||||
args:
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
container_name: wealthysmart-frontend-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
BACKEND_URL: http://wealthysmart-backend-prod:8000
|
||||
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui
|
||||
JWT_SECRET: ${SECRET_KEY}
|
||||
COOKIE_DOMAIN: wealth.cescalante.dev
|
||||
COOKIE_SECURE: "true"
|
||||
VIRTUAL_HOST: wealth.cescalante.dev
|
||||
VIRTUAL_PORT: "3000"
|
||||
LETSENCRYPT_HOST: wealth.cescalante.dev
|
||||
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
|
||||
expose:
|
||||
- "80"
|
||||
- "3000"
|
||||
networks:
|
||||
- wealthysmart-network
|
||||
- nginx-prod-network
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||
ports:
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
@@ -32,17 +34,52 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
develop:
|
||||
watch:
|
||||
- path: ./backend/app
|
||||
action: sync
|
||||
target: /app/app
|
||||
- path: ./backend/requirements.txt
|
||||
action: rebuild
|
||||
- path: ./backend/Dockerfile
|
||||
action: rebuild
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
container_name: wealthysmart-frontend-dev
|
||||
ports:
|
||||
- "5175:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- "5175:3000"
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||
depends_on:
|
||||
- backend
|
||||
develop:
|
||||
watch:
|
||||
- path: ./frontend/src
|
||||
action: sync
|
||||
target: /app/src
|
||||
- path: ./frontend/public
|
||||
action: sync
|
||||
target: /app/public
|
||||
- path: ./frontend/server.ts
|
||||
action: sync
|
||||
target: /app/server.ts
|
||||
- path: ./frontend/vite.config.ts
|
||||
action: sync+restart
|
||||
target: /app/vite.config.ts
|
||||
- path: ./frontend/tsconfig.json
|
||||
action: sync+restart
|
||||
target: /app/tsconfig.json
|
||||
- path: ./frontend/package.json
|
||||
action: rebuild
|
||||
- path: ./frontend/pnpm-lock.yaml
|
||||
action: rebuild
|
||||
- path: ./frontend/Dockerfile
|
||||
action: rebuild
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
97
docs/WealthySmart_ BAC Pensions Statements parser.json
Normal file
97
docs/WealthySmart_ BAC Pensions Statements parser.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "WealthySmart: BAC Pensions Statements parser",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"pollTimes": {
|
||||
"item": [
|
||||
{
|
||||
"hour": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"simple": false,
|
||||
"filters": {
|
||||
"q": "Estado de cuenta Pensiones BAC CREDOMATIC -{tarjeta} "
|
||||
},
|
||||
"options": {
|
||||
"downloadAttachments": true
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.gmailTrigger",
|
||||
"typeVersion": 1.3,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"id": "bc94143f-9980-4b94-9cd7-8e206dc1232e",
|
||||
"name": "Gmail Trigger",
|
||||
"credentials": {
|
||||
"gmailOAuth2": {
|
||||
"id": "LkAGMVgAqsiCkMFX",
|
||||
"name": "Gmail account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://wealth.cescalante.dev/api/v1/pensions/upload",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpBearerAuth",
|
||||
"sendBody": true,
|
||||
"contentType": "multipart-form-data",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"parameterType": "formBinaryData",
|
||||
"name": "files",
|
||||
"inputDataFieldName": "attachment_0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.4,
|
||||
"position": [
|
||||
208,
|
||||
0
|
||||
],
|
||||
"id": "b73c0182-8d56-48b3-b37e-c4dd9361a062",
|
||||
"name": "HTTP Request",
|
||||
"credentials": {
|
||||
"httpBearerAuth": {
|
||||
"id": "d4Le4M5bCuhEb2NN",
|
||||
"name": "BAC Parser - wealth.cescalante.dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"Gmail Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "HTTP Request",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": true,
|
||||
"settings": {
|
||||
"executionOrder": "v1",
|
||||
"binaryMode": "separate"
|
||||
},
|
||||
"versionId": "f0bbb681-1cf3-49f4-b06e-de0218b7ff3e",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "f446bf52a953467f756f2c188ff6b09473528d6316ecb3b9568cdbfbaa3258f3"
|
||||
},
|
||||
"id": "e88c3UhBeo9WCbcy",
|
||||
"tags": []
|
||||
}
|
||||
53
docs/a2ui-theming-findings.md
Normal file
53
docs/a2ui-theming-findings.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# A2UI Theming Findings
|
||||
|
||||
Captured 2026-04-28 while researching how to beautify the table the agent renders via `render_a2ui` without overriding component internals.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- A2UI v0.9 docs prescribe theming through a small set of **documented top-level CSS variables** declared on `:where(:root)` (zero specificity, so host CSS wins without `!important`).
|
||||
- Default theme uses `light-dark()` so tokens self-toggle. Recommended dark-mode coupling: add the documented `a2ui-dark` class on `<html>` whenever Tailwind's `.dark` is on; that flips `color-scheme: dark` and resolves every `light-dark()` to its dark branch.
|
||||
- **In our app, this approach has near-zero visible effect.** We render through `@copilotkit/a2ui-renderer` (React variant), not `@a2ui/lit`. Inspecting the compiled React renderer (`node_modules/.pnpm/@copilotkit+a2ui-renderer@1.56.4/.../catalog/basic/components/*.mjs`), every component uses **hardcoded inline styles**; the only CSS variable consumed across the whole basic catalog is `--a2ui-primary-color`.
|
||||
- This matches open issues `google/A2UI#977` and `#1285` ("React renderer parity is incomplete").
|
||||
|
||||
## Documented token surface (Lit / Angular)
|
||||
|
||||
From `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`:
|
||||
|
||||
- Color palette: `--a2ui-color-background`, `--a2ui-color-on-background`, `--a2ui-color-surface`, `--a2ui-color-on-surface`, `--a2ui-color-primary`, `--a2ui-color-primary-light/-dark/-hover`, `--a2ui-color-on-primary`, `--a2ui-color-secondary`, `--a2ui-color-secondary-light/-dark/-hover`, `--a2ui-color-on-secondary`, `--a2ui-color-input`, `--a2ui-color-on-input`, `--a2ui-color-border`.
|
||||
- Borders: `--a2ui-border`, `--a2ui-border-width`, `--a2ui-border-radius`.
|
||||
- Typography: `--a2ui-font-family-title`, `--a2ui-font-family-monospace`, `--a2ui-font-size`, `--a2ui-font-scale`, `--a2ui-font-size-xs..2xl`, `--a2ui-line-height-headings`, `--a2ui-line-height-body`.
|
||||
- Spacing: `--a2ui-grid-base`, `--a2ui-spacing-xs..xl`.
|
||||
|
||||
Per-component tokens like `--a2ui-card-background` exist but the renderer README and the official theming guide explicitly frame them as catalog-internal, not stable spec. Avoid relying on them.
|
||||
|
||||
## Dark-mode coupling
|
||||
|
||||
A2UI default uses `light-dark(...)` and supports forced modes via `a2ui-light` / `a2ui-dark` classes (see the `:where(.a2ui-dark)` block in `default.ts`). Best practice for a host that uses Tailwind `.dark` on `<html>`: toggle the matching `a2ui-dark` class in lockstep — one effect-driven `useEffect`, or add it directly to the root if the app is dark-only. No need to redeclare every token under `.dark`; the default rules already pick up the right branch through `light-dark()`.
|
||||
|
||||
## Footguns in v0.9
|
||||
|
||||
- The agent-supplied `theme` payload (e.g. `createSurface.theme.primaryColor`) is **not yet wired** in the basic catalog. Tracked in `google/A2UI#979` (Lit) and `#977` (React). Don't expect agent-side theming to recolor anything in v0.9; only the host CSS does.
|
||||
- `hintedStyles` (h1–h6 mappings) require all keys when overridden — see `google/A2UI#602`.
|
||||
- Tracking issues for the broader theming surface: `google/A2UI#1083`, `#1118`.
|
||||
|
||||
## Why this won't move the table in our app
|
||||
|
||||
`@copilotkit/a2ui-renderer@1.56.4` (React) is what `<CopilotKit a2ui={{}}>` plugs in. Its components apply colors and borders as inline `style` props (`backgroundColor: "#fff"`, `border: "1px solid #ccc"`, etc.). Inline styles can only be overridden with `!important` in author CSS, which contradicts the "don't take its freedom" goal. So the documented `--a2ui-*` token contract is currently a **Lit-only surface** for this catalog.
|
||||
|
||||
## Practical options for this codebase
|
||||
|
||||
1. **Quick win**: set `--a2ui-primary-color: var(--primary)` on `:root`. Real but small effect (primary buttons, links).
|
||||
2. **Prompt-side polish**: instruct the agent to compose richer A2UI structures — wrap the table in a `Card`, use `usageHint: "h3"` for the section title, insert a `Divider`, group columns with `Row` + `Column` weights — so the renderer's existing inline styles produce a cleaner result. No code change.
|
||||
3. **Switch renderer**: replace `@copilotkit/a2ui-renderer` with `@a2ui/lit` (or wait for `@a2ui/react` parity). Then every documented `--a2ui-*` token actually applies. Non-trivial migration.
|
||||
4. **Custom catalog overrides**: register replacement React components for `Row`/`List`/`Card` via `createA2UICatalog`; full control, most invasive, accepts ownership of those components forever.
|
||||
|
||||
## Source links
|
||||
|
||||
- `tech_docs/A2UI/docs/guides/theming.md`
|
||||
- `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`
|
||||
- `tech_docs/A2UI/renderers/lit/README.md` §"CSS-based Basic Catalog Theming"
|
||||
- `https://github.com/google/A2UI/issues/977` (React renderer theming gap)
|
||||
- `https://github.com/google/A2UI/issues/1285` (React renderer parity)
|
||||
- `https://github.com/google/A2UI/issues/1118` (theming surface roadmap)
|
||||
- `https://github.com/google/A2UI/issues/979` (Lit `theme` payload not wired)
|
||||
- `https://github.com/google/A2UI/pull/1079` (v0.9 CSS-variable rollout)
|
||||
15
frontend/.dockerignore
Normal file
15
frontend/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
coverage
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
.env*
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,7 +1,39 @@
|
||||
FROM node:20-slim
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
|
||||
# Dev: Vite HMR on port 3000 + Hono CK server on port 3001
|
||||
FROM node:22-alpine AS dev
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
CMD ["pnpm", "run", "dev", "--", "--host"]
|
||||
ENV NODE_ENV=development
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "-c", "corepack enable && pnpm dev"]
|
||||
|
||||
# Build Vite SPA
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Cap node heap so a build on a small VPS can't OOM-kill neighbours.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=1536
|
||||
RUN corepack enable && pnpm build
|
||||
|
||||
# Production: Hono serves dist/ + /api/copilotkit on port 3000
|
||||
FROM node:22-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY server.ts package.json ./
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||
CMD ["./node_modules/.bin/tsx", "server.ts"]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-slim AS build
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Serve
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -12,8 +12,6 @@
|
||||
<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>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API to backend (same docker network)
|
||||
location /api/ {
|
||||
proxy_pass http://wealthysmart-backend-prod:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# No cache for service worker
|
||||
location /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Cache immutable assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,46 @@
|
||||
{
|
||||
"name": "wealthysmart-frontend",
|
||||
"private": true,
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "tsx server.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@ag-ui/client": "0.0.52",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@copilotkit/react-core": "1.56.4",
|
||||
"@copilotkit/react-ui": "1.56.4",
|
||||
"@copilotkit/runtime": "1.56.4",
|
||||
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans": "^5.2.10",
|
||||
"@hono/node-server": "^1.14.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^2.15.4",
|
||||
"shadcn": "^4.1.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"hono": "^4.12.15",
|
||||
"lucide-react": "^1.12.0",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"recharts": "^3.8.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tsx": "^4.19.4",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
8440
frontend/pnpm-lock.yaml
generated
8440
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +1,3 @@
|
||||
onlyBuiltDependencies: '["@swc/core", "esbuild"]'
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#10b981"/>
|
||||
<text x="16" y="23" text-anchor="middle" font-size="20" font-weight="bold" fill="#0f172a" font-family="system-ui">W</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 248 B |
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,98 +1,4 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Only handle http(s) requests — skip chrome-extension:// etc.
|
||||
if (!url.protocol.startsWith('http')) 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)));
|
||||
});
|
||||
|
||||
// --- Push Notifications ---
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch {
|
||||
// Fallback for plain-text pushes (e.g. browser test pushes)
|
||||
data = { title: 'WealthySmart', body: event.data.text() };
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url: data.url || '/' },
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'transaction',
|
||||
renotify: true,
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(data.title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || '/';
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", async () => {
|
||||
await self.registration.unregister();
|
||||
});
|
||||
|
||||
416
frontend/server.ts
Normal file
416
frontend/server.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
|
||||
import { Observable } from "rxjs";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const AGENT_URL =
|
||||
process.env.AGENT_URL ?? "http://backend:8000/api/v1/agent/agui";
|
||||
const BACKEND_URL =
|
||||
process.env.BACKEND_URL ?? "http://localhost:8001";
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
|
||||
|
||||
// ── pairOrphanToolCalls ──────────────────────────────────────────────────────
|
||||
// CopilotKit's browser-side store sometimes drops the tool-role message
|
||||
// between turns, producing assistant(toolCalls=[X]) → assistant(text) which
|
||||
// OpenAI rejects. Inject a synthetic empty tool response for each orphan id.
|
||||
|
||||
interface AGUIMessage {
|
||||
id?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
toolCalls?: Array<{ id?: string }>;
|
||||
tool_calls?: Array<{ id?: string }>;
|
||||
toolCallId?: string;
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
function pairOrphanToolCalls(messages: AGUIMessage[]): AGUIMessage[] {
|
||||
const out: AGUIMessage[] = [];
|
||||
let pending: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
for (const callId of pending) {
|
||||
out.push({ id: `synth-${Math.random().toString(36).slice(2)}`, role: "tool", toolCallId: callId, content: "" });
|
||||
}
|
||||
pending = [];
|
||||
};
|
||||
|
||||
for (const msg of messages) {
|
||||
const role = String(msg?.role ?? "");
|
||||
if (role === "tool") {
|
||||
const callId = msg.toolCallId ?? msg.tool_call_id;
|
||||
if (callId) pending = pending.filter((p) => p !== callId);
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
if (role === "assistant") {
|
||||
flush();
|
||||
const toolCalls = msg.toolCalls ?? msg.tool_calls ?? [];
|
||||
out.push(msg);
|
||||
for (const tc of toolCalls) {
|
||||
if (tc?.id) pending.push(String(tc.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
out.push(msg);
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── DeduplicateToolCallMiddleware ─────────────────────────────────────────────
|
||||
// MAF re-emits TOOL_CALL_START for declaration-only calls when they are invoked
|
||||
// in parallel with other tools, causing verifyEvents to throw an AGUIError.
|
||||
// This middleware tracks completed tool calls and silently drops any duplicate
|
||||
// START/ARGS/END events for a call ID that has already been closed.
|
||||
//
|
||||
// NOTE: runNextWithState emits { event, messages, state } wrappers; always
|
||||
// extract `.event` and forward the raw BaseEvent — never the wrapper.
|
||||
|
||||
// ── DebugLogMiddleware ──────────────────────────────────────────────────────
|
||||
// Temporary: logs every event type flowing out to diagnose double-response bug.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class DebugLogMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event;
|
||||
const type: string = event?.type ?? "";
|
||||
const extra = event?.toolCallName ? ` [${event.toolCallName}]`
|
||||
: event?.activityType ? ` [${event.activityType}]`
|
||||
: event?.messageId ? ` [msg:${event.messageId.slice(0, 8)}]`
|
||||
: "";
|
||||
console.log(`[DBG] ${type}${extra}`);
|
||||
if (type === EventType.MESSAGES_SNAPSHOT) {
|
||||
const msgs = (event as any)?.messages ?? [];
|
||||
console.log(`[DBG] SNAPSHOT msgs: ${msgs.map((m: any) => `${m.role}:${(m.id ?? "").slice(0,8)}:${typeof m.content === "string" ? m.content.length : "?"}c`).join(" | ")}`);
|
||||
}
|
||||
observer.next(event);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class DeduplicateToolCallMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
const open = new Set<string>(); // tool call IDs currently in progress
|
||||
const closed = new Set<string>(); // tool call IDs that already received END
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event; // raw BaseEvent
|
||||
const type: string = event?.type ?? "";
|
||||
const id: string | undefined = event?.toolCallId;
|
||||
|
||||
if (type === EventType.TOOL_CALL_START) {
|
||||
if (!id || closed.has(id) || open.has(id)) return; // duplicate
|
||||
open.add(id);
|
||||
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
|
||||
if (id && closed.has(id)) return; // already completed, drop
|
||||
if (type === EventType.TOOL_CALL_END && id) {
|
||||
open.delete(id);
|
||||
closed.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
observer.next(event); // emit raw BaseEvent
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── StripModelArtifactsMiddleware ────────────────────────────────────────────
|
||||
// Some OpenAI models occasionally leak training-data special tokens such as
|
||||
// `<|ipynb_marker|>` into completion text. Filter them out of streamed deltas
|
||||
// before they reach the chat UI.
|
||||
|
||||
const ARTIFACT_RE = /<\|[^|>]+\|>/g;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class StripModelArtifactsMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event;
|
||||
if (
|
||||
event?.type === EventType.TEXT_MESSAGE_CONTENT &&
|
||||
typeof event?.delta === "string" &&
|
||||
ARTIFACT_RE.test(event.delta)
|
||||
) {
|
||||
const cleaned = event.delta.replace(ARTIFACT_RE, "");
|
||||
if (cleaned.length === 0) return;
|
||||
observer.next({ ...event, delta: cleaned });
|
||||
return;
|
||||
}
|
||||
observer.next(event);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
|
||||
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
|
||||
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
|
||||
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
|
||||
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
|
||||
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
|
||||
// by id then APPENDS unknown ids, so the browser ends up with two assistant
|
||||
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
|
||||
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
|
||||
// the assistant-with-toolCalls message in state past the run). The right fix
|
||||
// is to drop just the orphan text-only assistant message that has no streamed
|
||||
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
const streamedTextIds = new Set<string>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event;
|
||||
const type: string = event?.type ?? "";
|
||||
|
||||
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
|
||||
streamedTextIds.add(String(event.messageId));
|
||||
}
|
||||
|
||||
if (type === EventType.MESSAGES_SNAPSHOT) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
||||
const filtered = msgs.filter((m) => {
|
||||
if (m?.role !== "assistant") return true;
|
||||
const id = String(m?.id ?? "");
|
||||
const hasText = typeof m?.content === "string" && m.content.length > 0;
|
||||
const hasToolCalls =
|
||||
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
|
||||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
|
||||
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) {
|
||||
return false; // drop orphan text-only assistant duplicate
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (filtered.length !== msgs.length) {
|
||||
observer.next({ ...event, messages: filtered });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
observer.next(event);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── SuppressRenderToolTextMiddleware ──────────────────────────────────────────
|
||||
// When the LLM emits text content in the same response as a render tool call
|
||||
// (render_a2ui or render_spending_summary), the text appears as a duplicate
|
||||
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
|
||||
// them if any render tool call is detected in the same turn.
|
||||
|
||||
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
let renderToolSeen = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const textBuffer: any[] = [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event; // raw BaseEvent
|
||||
const type: string = event?.type ?? "";
|
||||
|
||||
if (type === EventType.TOOL_CALL_START) {
|
||||
const toolName: string = event?.toolCallName ?? "";
|
||||
if (RENDER_TOOLS.has(toolName)) {
|
||||
renderToolSeen = true;
|
||||
textBuffer.length = 0; // discard text buffered before we saw the tool call
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
type === EventType.TEXT_MESSAGE_START ||
|
||||
type === EventType.TEXT_MESSAGE_CONTENT ||
|
||||
type === EventType.TEXT_MESSAGE_END
|
||||
) {
|
||||
if (!renderToolSeen) textBuffer.push(event); // buffer raw event
|
||||
return; // always hold — flush at turn end
|
||||
}
|
||||
|
||||
if (type === EventType.RUN_FINISHED || type === EventType.RUN_ERROR) {
|
||||
if (!renderToolSeen) {
|
||||
for (const e of textBuffer) observer.next(e); // flush raw events
|
||||
}
|
||||
textBuffer.length = 0;
|
||||
observer.next(event); // emit raw event
|
||||
return;
|
||||
}
|
||||
|
||||
observer.next(event); // emit raw event
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => {
|
||||
if (!renderToolSeen) {
|
||||
for (const e of textBuffer) observer.next(e);
|
||||
}
|
||||
observer.complete();
|
||||
},
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hono app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.all("/api/copilotkit/*", async (c) => {
|
||||
const cookieHeader = c.req.header("cookie") ?? "";
|
||||
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
|
||||
const token = match?.[1];
|
||||
const agentHeaders: Record<string, string> = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
|
||||
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new StripModelArtifactsMiddleware() as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new ReconcileSnapshotMiddleware() as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new DeduplicateToolCallMiddleware() as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new DebugLogMiddleware() as any);
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { wealthysmart: agent },
|
||||
a2ui: { injectA2UITool: true },
|
||||
beforeRequestMiddleware: async ({ request: outbound }) => {
|
||||
if (outbound.method !== "POST") return;
|
||||
const ct = outbound.headers.get("content-type") ?? "";
|
||||
if (!ct.includes("application/json")) return;
|
||||
try {
|
||||
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
|
||||
if (!Array.isArray(body.messages)) return;
|
||||
const paired = pairOrphanToolCalls(body.messages);
|
||||
if (paired.length === body.messages.length) return;
|
||||
return new Request(outbound.url, {
|
||||
method: outbound.method,
|
||||
headers: outbound.headers,
|
||||
body: JSON.stringify({ ...body, messages: paired }),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
|
||||
});
|
||||
|
||||
app.get("/api/health", (c) => c.json({ ok: true }));
|
||||
|
||||
// Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly,
|
||||
// but in prod the browser talks to this Hono server, which must forward
|
||||
// `/api/v1/*` and `/api/auth/*` to the FastAPI container — otherwise the SPA
|
||||
// fallback below swallows them and returns index.html.
|
||||
const proxyToBackend = async (c: import("hono").Context) => {
|
||||
const url = new URL(c.req.url);
|
||||
const target = `${BACKEND_URL}${url.pathname}${url.search}`;
|
||||
const method = c.req.method;
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.delete("host");
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
};
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
init.body = c.req.raw.body;
|
||||
// @ts-expect-error undici requires duplex for streamed bodies
|
||||
init.duplex = "half";
|
||||
}
|
||||
return fetch(target, init);
|
||||
};
|
||||
|
||||
app.all("/api/v1/*", proxyToBackend);
|
||||
app.all("/api/auth/*", proxyToBackend);
|
||||
|
||||
// In production, serve the Vite build output.
|
||||
if (isProd) {
|
||||
app.use("/*", serveStatic({ root: "./dist" }));
|
||||
// SPA fallback: any non-asset path returns index.html
|
||||
app.get("/*", serveStatic({ path: "./dist/index.html" }));
|
||||
}
|
||||
|
||||
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
||||
console.log(`CopilotKit server on port ${info.port} [${isProd ? "prod" : "dev"}]`);
|
||||
});
|
||||
@@ -1,29 +1,34 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import { ThemeProvider } from './ThemeContext';
|
||||
import { PrivacyProvider } from './PrivacyContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Budget from './pages/Budget';
|
||||
import Analytics from './pages/Analytics';
|
||||
import Salarios from './pages/Salarios';
|
||||
import Pensions from './pages/Pensions';
|
||||
import ServiciosMunicipales from './pages/ServiciosMunicipales';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { AuthProvider, useAuth } from "./AuthContext";
|
||||
import { ThemeProvider } from "./contexts/theme-context";
|
||||
import { PrivacyProvider } from "./contexts/privacy-context";
|
||||
import Layout from "./components/Layout";
|
||||
import LoginPage from "./pages/Login";
|
||||
import Asistente from "./pages/Asistente";
|
||||
import Analytics from "./pages/Analytics";
|
||||
import Budget from "./pages/Budget";
|
||||
import Salarios from "./pages/Salarios";
|
||||
import Pensions from "./pages/Pensions";
|
||||
import Proyecciones from "./pages/Proyecciones";
|
||||
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
if (isLoading) return null;
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
||||
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
@@ -32,13 +37,14 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route index element={<Navigate to="/asistente" replace />} />
|
||||
<Route path="/asistente" element={<Asistente />} />
|
||||
<Route path="/budget" element={<Budget />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/proyecciones" element={<Proyecciones />} />
|
||||
<Route path="/salarios" element={<Salarios />} />
|
||||
<Route path="/pensions" element={<Pensions />} />
|
||||
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||
{/* Redirect old routes */}
|
||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||
</Route>
|
||||
@@ -52,7 +58,9 @@ export default function App() {
|
||||
<ThemeProvider>
|
||||
<PrivacyProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
|
||||
<AppRoutes />
|
||||
</CopilotKit>
|
||||
</AuthProvider>
|
||||
</PrivacyProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
|
||||
import { logout as apiLogout } from "@/lib/api";
|
||||
|
||||
interface AuthCtx {
|
||||
isAuthenticated: boolean;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
logout: () => Promise<void>;
|
||||
setAuthenticated: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthCtx>({
|
||||
isAuthenticated: false,
|
||||
logout: () => {},
|
||||
isLoading: true,
|
||||
logout: async () => {},
|
||||
setAuthenticated: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token'));
|
||||
const [isAuthenticated, setAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setAuthenticated(!!localStorage.getItem('token'));
|
||||
window.addEventListener('storage', check);
|
||||
return () => window.removeEventListener('storage', check);
|
||||
// Probe auth state by hitting a protected endpoint.
|
||||
// If the ws_token cookie is valid, the server returns 200; else 401.
|
||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||
.then((r) => setAuthenticated(r.ok))
|
||||
.catch(() => setAuthenticated(false))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
const logout = async () => {
|
||||
await apiLogout();
|
||||
setAuthenticated(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
|
||||
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
const PrivacyContext = createContext<{
|
||||
privacyMode: boolean;
|
||||
togglePrivacy: () => void;
|
||||
}>({ privacyMode: false, togglePrivacy: () => {} });
|
||||
|
||||
export function PrivacyProvider({ children }: { children: React.ReactNode }) {
|
||||
const [privacyMode, setPrivacyMode] = useState<boolean>(() => {
|
||||
return localStorage.getItem('privacyMode') === 'true';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('privacy', privacyMode);
|
||||
localStorage.setItem('privacyMode', String(privacyMode));
|
||||
}, [privacyMode]);
|
||||
|
||||
const togglePrivacy = () => setPrivacyMode((p) => !p);
|
||||
|
||||
return (
|
||||
<PrivacyContext.Provider value={{ privacyMode, togglePrivacy }}>
|
||||
{children}
|
||||
</PrivacyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const usePrivacy = () => useContext(PrivacyContext);
|
||||
@@ -1,31 +0,0 @@
|
||||
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);
|
||||
39
frontend/src/components/AgentHomeClient.tsx
Normal file
39
frontend/src/components/AgentHomeClient.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
|
||||
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
|
||||
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
|
||||
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
|
||||
];
|
||||
|
||||
export default function AgentHomeClient() {
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-105px)]">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight font-heading flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
Asistente
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pregúntale a WealthySmart sobre tus finanzas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
||||
<CopilotChat
|
||||
className="h-full"
|
||||
labels={{
|
||||
title: "WealthySmart",
|
||||
initial: "¿Qué quieres saber sobre tus finanzas?",
|
||||
placeholder: "Escribe tu pregunta…",
|
||||
}}
|
||||
suggestions={SUGGESTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import api from '../api';
|
||||
import api from '@/lib/api';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import type { SectionSettings } from '../api';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from '@/components/ui/accordion';
|
||||
import { getColorClasses } from '@/lib/colors';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
settings: SectionSettings;
|
||||
total?: number;
|
||||
totalCurrency?: string;
|
||||
onToggleExpanded: (expanded: boolean) => void;
|
||||
onOpenConfig: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardSection({
|
||||
sectionId,
|
||||
settings,
|
||||
total,
|
||||
totalCurrency,
|
||||
onToggleExpanded,
|
||||
onOpenConfig,
|
||||
children,
|
||||
}: Props) {
|
||||
const colors = getColorClasses(settings.color);
|
||||
|
||||
return (
|
||||
<Card className={cn('relative overflow-hidden border-l-4', colors.borderLeft)}>
|
||||
{/* Settings icon — outside accordion trigger to avoid button-in-button */}
|
||||
<button
|
||||
onClick={onOpenConfig}
|
||||
className="absolute top-2.5 right-3 z-10 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
title="Section settings"
|
||||
aria-label="Section settings"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<Accordion
|
||||
value={settings.expanded ? [sectionId] : []}
|
||||
onValueChange={(value: string[]) => onToggleExpanded(value.includes(sectionId))}
|
||||
>
|
||||
<AccordionItem value={sectionId} className="border-none">
|
||||
<AccordionTrigger
|
||||
className="px-4 py-3 hover:no-underline cursor-pointer"
|
||||
aria-label={`Expand ${settings.label}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full pr-8">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{settings.label}
|
||||
</span>
|
||||
{total != null && totalCurrency && (
|
||||
<span data-sensitive className="text-sm font-bold font-mono text-foreground">
|
||||
{formatAmount(total, totalCurrency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="divide-y divide-border mx-4 mb-4 rounded-lg overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Sparkles,
|
||||
Calculator,
|
||||
BarChart3,
|
||||
Landmark,
|
||||
PiggyBank,
|
||||
Droplets,
|
||||
LogOut,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
Menu,
|
||||
Sun,
|
||||
@@ -14,24 +16,20 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
import { usePrivacy } from '../PrivacyContext';
|
||||
import { subscribeToPush } from '../pushNotifications';
|
||||
import { Button } from '@/components/ui/button';
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "@/contexts/theme-context";
|
||||
import { usePrivacy } from "@/contexts/privacy-context";
|
||||
import { useAuth } from "@/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetClose,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Navigation Structure ────────────────────────────────────────────────────
|
||||
} from "@/components/ui/sheet";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavSection {
|
||||
label: string;
|
||||
@@ -40,31 +38,32 @@ interface NavSection {
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
label: 'General',
|
||||
label: "General",
|
||||
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
|
||||
},
|
||||
{
|
||||
label: "Finanzas",
|
||||
items: [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
||||
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
||||
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
||||
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
||||
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Finanzas',
|
||||
label: "Servicios",
|
||||
items: [
|
||||
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
|
||||
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
|
||||
{ to: '/pensions', icon: PiggyBank, label: 'Pensiones' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Servicios',
|
||||
items: [
|
||||
{ to: '/servicios-municipales', icon: Droplets, label: 'Municipalidad' },
|
||||
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Shared Nav Renderer ─────────────────────────────────────────────────────
|
||||
|
||||
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const isActive = (to: string) =>
|
||||
pathname === to || pathname.startsWith(`${to}/`);
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-0.5 px-3">
|
||||
{navSections.map((section) => (
|
||||
@@ -73,23 +72,20 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
{section.label}
|
||||
</p>
|
||||
{section.items.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
isActive(to)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -97,27 +93,20 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Layout ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Layout() {
|
||||
const { logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
subscribeToPush();
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* ── Top bar ───────────────────────────────────────────────────── */}
|
||||
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
|
||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -134,7 +123,7 @@ export default function Layout() {
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight hidden sm:inline font-heading">
|
||||
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
|
||||
Wealthy<span className="text-primary">Smart</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -144,7 +133,7 @@ export default function Layout() {
|
||||
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -161,7 +150,6 @@ export default function Layout() {
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
{/* ── Desktop sidebar ───────────────────────────────────────── */}
|
||||
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
|
||||
<div className="flex-1">
|
||||
<SidebarNav />
|
||||
@@ -178,7 +166,6 @@ export default function Layout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Mobile nav sheet ──────────────────────────────────────── */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="p-0 w-64">
|
||||
<SheetHeader className="p-4">
|
||||
@@ -186,7 +173,7 @@ export default function Layout() {
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="font-heading">
|
||||
<span style={{ fontFamily: "var(--font-heading)" }}>
|
||||
Wealthy<span className="text-primary">Smart</span>
|
||||
</span>
|
||||
</SheetTitle>
|
||||
@@ -200,7 +187,10 @@ export default function Layout() {
|
||||
<Separator className="mb-2" />
|
||||
<SheetClose render={<span />}>
|
||||
<button
|
||||
onClick={() => { setMobileOpen(false); handleLogout(); }}
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
void handleLogout();
|
||||
}}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
@@ -212,7 +202,6 @@ export default function Layout() {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ── Main content ──────────────────────────────────────────── */}
|
||||
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Outlet />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import api, { type ImportResult } from '../api';
|
||||
import api, { type ImportResult } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import { type PensionUploadResult, submitPensionManualEntries } from '../api';
|
||||
import { type PensionUploadResult, submitPensionManualEntries } from '@/lib/api';
|
||||
import { parsePensionPaste, type PensionParsedEntry } from '@/lib/parsePensionPaste';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { SectionSettings } from '../api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { COLOR_OPTIONS, getColorClasses } from '@/lib/colors';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
settings: SectionSettings;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (sectionId: string, updated: Partial<SectionSettings>) => void;
|
||||
}
|
||||
|
||||
function ColorSwatch({ color }: { color: string }) {
|
||||
const classes = getColorClasses(color);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={cn('w-3 h-3 rounded-full', classes.bg, classes.ring, 'ring-1')} />
|
||||
{color}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SectionConfigDialog({ sectionId, settings, open, onOpenChange, onSave }: Props) {
|
||||
const [label, setLabel] = useState(settings.label);
|
||||
const [color, setColor] = useState(settings.color);
|
||||
const [cardColor, setCardColor] = useState(settings.cardColor);
|
||||
const [visible, setVisible] = useState(settings.visible);
|
||||
const [order, setOrder] = useState(String(settings.order));
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(sectionId, {
|
||||
label,
|
||||
color,
|
||||
cardColor,
|
||||
visible,
|
||||
order: parseInt(order) || 0,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Section</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Section Color</Label>
|
||||
<Select value={color} onValueChange={setColor}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_OPTIONS.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
<ColorSwatch color={c} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Card Color</Label>
|
||||
<Select value={cardColor} onValueChange={setCardColor}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_OPTIONS.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
<ColorSwatch color={c} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor={`visible-${sectionId}`}>Visible</Label>
|
||||
<input
|
||||
id={`visible-${sectionId}`}
|
||||
type="checkbox"
|
||||
checked={visible}
|
||||
onChange={(e) => setVisible(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ArrowLeftRight,
|
||||
ArrowRightFromLine,
|
||||
Banknote,
|
||||
} from 'lucide-react';
|
||||
|
||||
import api, { type Transaction } from '../api';
|
||||
import api, { type Transaction } from '@/lib/api';
|
||||
import TransactionModal from './TransactionModal';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -30,7 +32,9 @@ export interface TransactionListProps {
|
||||
emptyIcon?: React.ReactNode;
|
||||
emptyMessage?: string;
|
||||
showCategory?: boolean;
|
||||
showSourceIcon?: boolean;
|
||||
addLabel?: string;
|
||||
onToggleDeferred?: (tx: Transaction) => void;
|
||||
}
|
||||
|
||||
export default function TransactionList({
|
||||
@@ -43,7 +47,9 @@ export default function TransactionList({
|
||||
emptyIcon,
|
||||
emptyMessage = 'No transactions found',
|
||||
showCategory = true,
|
||||
showSourceIcon = false,
|
||||
addLabel = 'Add Transaction',
|
||||
onToggleDeferred,
|
||||
}: TransactionListProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||
@@ -68,8 +74,8 @@ export default function TransactionList({
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => getTransactionColumns({ showCategory, onEdit: handleEdit, onDelete: (id) => setDeleteId(id) }),
|
||||
[showCategory],
|
||||
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
|
||||
[showCategory, showSourceIcon, onToggleDeferred],
|
||||
);
|
||||
|
||||
const empty = transactions.length === 0 && !loading;
|
||||
@@ -119,7 +125,16 @@ export default function TransactionList({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||
{showSourceIcon && (
|
||||
tx.source === 'CASH'
|
||||
? <Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
: tx.source === 'TRANSFER'
|
||||
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
: null
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
{showCategory && tx.category && (
|
||||
@@ -138,6 +153,18 @@ export default function TransactionList({
|
||||
{formatAmount(tx.amount, tx.currency)}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{onToggleDeferred && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
|
||||
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
|
||||
onClick={() => onToggleDeferred(tx)}
|
||||
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
|
||||
>
|
||||
<ArrowRightFromLine className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api, { type Category, type Transaction } from '../api';
|
||||
import api, { type Category, type Transaction } from '@/lib/api';
|
||||
import { formatLocalDatetime } from '@/lib/format';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -151,6 +151,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
<SelectContent>
|
||||
<SelectItem value="CRC">CRC (₡)</SelectItem>
|
||||
<SelectItem value="USD">USD ($)</SelectItem>
|
||||
<SelectItem value="EUR">EUR (€)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,150 +1,491 @@
|
||||
import { type MonthlyDetail as MonthlyDetailType } from '@/api';
|
||||
import { useState } from 'react';
|
||||
import { PieChart, Pie, Cell } from 'recharts';
|
||||
|
||||
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
PiggyBank,
|
||||
CreditCard,
|
||||
Banknote,
|
||||
ArrowLeftRight,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||
];
|
||||
type PaletteMode = 'chatgpt' | 'gemini';
|
||||
|
||||
const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }> = {
|
||||
CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard },
|
||||
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
|
||||
chatgpt: {
|
||||
// Pure green scale, darkest → lightest (assigned by rank)
|
||||
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
|
||||
// Pure amber scale, darkest → lightest (assigned by rank)
|
||||
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
|
||||
// Warm-to-cool alternating for CC categories
|
||||
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
|
||||
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
|
||||
},
|
||||
gemini: {
|
||||
// Qualitative greens: dark green, mint, pale green, forest
|
||||
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
|
||||
// Terracotta, slate blue, sage, sand — diverse hues
|
||||
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
|
||||
// Pastel/muted diverse for CC categories
|
||||
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
|
||||
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
|
||||
},
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
|
||||
CASH: { label: 'Efectivo', icon: Banknote },
|
||||
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
|
||||
};
|
||||
|
||||
interface MonthlyDetailProps {
|
||||
detail: MonthlyDetailType;
|
||||
detail: MonthlyDetailType | null;
|
||||
loading?: boolean;
|
||||
onNavigateToTransactions?: () => void;
|
||||
}
|
||||
|
||||
export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
|
||||
if (loading) {
|
||||
function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof TrendingUp; title: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<TitleIcon className="w-4 h-4" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-[200px] w-full flex items-center justify-center">
|
||||
<Skeleton className="h-[160px] w-[160px] rounded-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Skeleton className="w-2 h-2 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
<div className="flex items-center justify-between w-full mt-2">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
|
||||
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
|
||||
|
||||
if (loading || !detail) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="h-48" />
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
|
||||
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Tarjeta de Crédito
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4">
|
||||
<div className="h-[200px] w-full md:w-1/2 flex items-center justify-center">
|
||||
<Skeleton className="h-[160px] w-[160px] rounded-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Skeleton className="w-2 h-2 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
<div className="flex items-center justify-between w-full mt-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<Banknote className="w-4 h-4" />
|
||||
Efectivo o Transferencias
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="border-2 border-muted/40">
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
|
||||
|
||||
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
|
||||
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
|
||||
|
||||
// For ChatGPT mode: assign colors by rank (largest = darkest)
|
||||
// For Gemini mode: assign colors by position (qualitative)
|
||||
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
|
||||
if (paletteMode === 'chatgpt') {
|
||||
const sorted = [...data].sort((a, b) => b.value - a.value);
|
||||
const map = new Map<string, string>();
|
||||
sorted.forEach((item, i) => {
|
||||
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
// Gemini: positional
|
||||
const map = new Map<string, string>();
|
||||
data.forEach((item, i) => {
|
||||
map.set(item.name, colors[i % colors.length]);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
const incomeColorMap = buildColorMap(incomeData, incomeColors);
|
||||
const expenseColorMap = buildColorMap(expenseData, expenseColors);
|
||||
|
||||
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
|
||||
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const expenseConfig = expenseData.reduce<ChartConfig>((acc, item) => {
|
||||
acc[item.name] = { label: item.name, color: expenseColorMap.get(item.name)! };
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// CC spending by category
|
||||
const ccData = (detail.cc_by_category ?? []).map((item) => ({
|
||||
name: item.category_name,
|
||||
value: item.amount,
|
||||
}));
|
||||
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
|
||||
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
|
||||
return acc;
|
||||
}, {});
|
||||
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
// Filter actuals to only cash and transfer (no credit card)
|
||||
const cashTransferActuals = detail.actuals_by_source.filter(
|
||||
(src) => src.source !== 'CREDIT_CARD' && src.count > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Detalle: {MONTH_NAMES[detail.month]} {detail.year}
|
||||
</h3>
|
||||
{/* Palette Toggle */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
|
||||
<Button
|
||||
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => setPaletteMode('chatgpt')}
|
||||
>
|
||||
ChatGPT
|
||||
</Button>
|
||||
<Button
|
||||
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => setPaletteMode('gemini')}
|
||||
>
|
||||
Gemini
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{/* Income Card */}
|
||||
{/* Pie Charts */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Income Pie */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-primary" />
|
||||
Ingresos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{detail.income_items.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between text-sm">
|
||||
<span className="truncate mr-2">{item.name}</span>
|
||||
<span data-sensitive className="font-mono text-primary whitespace-nowrap">
|
||||
{formatAmount(item.amount, 'CRC')}
|
||||
</span>
|
||||
<CardContent>
|
||||
{incomeData.length > 0 ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<ChartContainer config={incomeConfig} className="h-[200px] w-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={incomeData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
strokeWidth={2}
|
||||
stroke="var(--card)"
|
||||
>
|
||||
{incomeData.map((item, i) => (
|
||||
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
nameKey="name"
|
||||
formatter={(value, name) => (
|
||||
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
|
||||
{incomeData.map((item, i) => (
|
||||
<div key={item.name} className="flex items-center gap-1.5 text-xs">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: incomeColorMap.get(item.name) }}
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
|
||||
<span>Total</span>
|
||||
<span data-sensitive className="font-mono text-primary">
|
||||
{formatAmount(detail.total_projected_income, 'CRC')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between font-semibold text-sm">
|
||||
<span>Total</span>
|
||||
<span data-sensitive className="font-mono text-primary">
|
||||
{formatAmount(detail.total_projected_income, 'CRC')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Sin ingresos</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expenses Card */}
|
||||
{/* Expenses Pie */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-destructive" />
|
||||
Egresos Fijos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{detail.expense_items.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1 truncate mr-2">
|
||||
<span className="truncate">{item.name}</span>
|
||||
{item.used_actual && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 shrink-0">
|
||||
real
|
||||
</Badge>
|
||||
)}
|
||||
<CardContent>
|
||||
{expenseData.length > 0 ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<ChartContainer config={expenseConfig} className="h-[200px] w-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={expenseData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
strokeWidth={2}
|
||||
stroke="var(--card)"
|
||||
>
|
||||
{expenseData.map((item, i) => (
|
||||
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name) => (
|
||||
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
|
||||
{expenseData.map((item, i) => (
|
||||
<div key={item.name} className="flex items-center gap-1.5 text-xs">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: expenseColorMap.get(item.name) }}
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-sensitive className="text-right whitespace-nowrap">
|
||||
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
|
||||
{item.used_actual && item.projected_amount != null && (
|
||||
<span className="block text-[10px] text-muted-foreground font-mono line-through">
|
||||
{formatAmount(item.projected_amount, 'CRC')}
|
||||
</span>
|
||||
)}
|
||||
<Separator className="mt-3" />
|
||||
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
|
||||
<span>Total Fijos</span>
|
||||
<span data-sensitive className="font-mono">
|
||||
{formatAmount(detail.total_projected_expenses, 'CRC')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{detail.expense_items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Sin egresos fijos</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Sin egresos fijos</p>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between font-semibold text-sm">
|
||||
<span>Total Fijos</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Credit Card by Category */}
|
||||
{ccData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Tarjeta de Crédito
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4">
|
||||
<ChartContainer config={ccConfig} className="h-[200px] w-full md:w-1/2">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={ccData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
strokeWidth={2}
|
||||
stroke="var(--card)"
|
||||
>
|
||||
{ccData.map((_, i) => (
|
||||
<Cell key={i} fill={ccColors[i % ccColors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
nameKey="name"
|
||||
formatter={(value, name) => (
|
||||
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
|
||||
{ccData.map((item, i) => (
|
||||
<div key={item.name} className="flex items-center gap-1.5 text-xs">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: ccColors[i % ccColors.length] }}
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
|
||||
<span>Total Tarjeta</span>
|
||||
<span data-sensitive className="font-mono">
|
||||
{formatAmount(detail.total_projected_expenses, 'CRC')}
|
||||
{formatAmount(ccTotal, 'CRC')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actuals Card */}
|
||||
{/* Actuals + Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Cash & Transfer Actuals Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Transacciones Reales
|
||||
<Banknote className="w-4 h-4" />
|
||||
Efectivo o Transferencias
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{detail.actuals_by_source.map((src) => {
|
||||
{cashTransferActuals.map((src) => {
|
||||
const meta = SOURCE_LABELS[src.source];
|
||||
if (!meta || src.count === 0) return null;
|
||||
if (!meta) return null;
|
||||
const Icon = meta.icon;
|
||||
const isClickable = onNavigateToTransactions != null;
|
||||
return (
|
||||
<div key={src.source} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5',
|
||||
isClickable && 'cursor-pointer hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
||||
)}
|
||||
onClick={isClickable ? onNavigateToTransactions : undefined}
|
||||
disabled={!isClickable}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>{meta.label}</span>
|
||||
<span className="text-xs text-muted-foreground">({src.count})</span>
|
||||
</div>
|
||||
</button>
|
||||
<span data-sensitive className="font-mono whitespace-nowrap">
|
||||
{formatAmount(src.net, 'CRC')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{cashTransferActuals.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Sin transacciones</p>
|
||||
)}
|
||||
{detail.uncovered_actual > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
@@ -159,36 +500,6 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Savings + Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Savings */}
|
||||
{detail.savings_items.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<PiggyBank className="w-4 h-4" />
|
||||
Ahorro
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{detail.savings_items.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between text-sm">
|
||||
<span>{item.name}</span>
|
||||
<span data-sensitive className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
|
||||
</div>
|
||||
))}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between font-semibold text-sm">
|
||||
<span>Total Ahorro</span>
|
||||
<span data-sensitive className="font-mono">
|
||||
{formatAmount(detail.total_projected_savings, 'CRC')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<Card className={cn(
|
||||
@@ -208,12 +519,6 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
|
||||
-{formatAmount(detail.gran_total_egresos, 'CRC')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Ahorro</span>
|
||||
<span data-sensitive className="font-mono font-medium">
|
||||
-{formatAmount(detail.total_projected_savings, 'CRC')}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">Balance Neto</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type RecurringItemUpdate,
|
||||
type RecurringItemType,
|
||||
type RecurringFrequency,
|
||||
} from '@/api';
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -29,7 +29,6 @@ import { Plus, Trash2 } from 'lucide-react';
|
||||
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
|
||||
{ value: 'INCOME', label: 'Ingreso' },
|
||||
{ value: 'EXPENSE', label: 'Egreso' },
|
||||
{ value: 'SAVINGS', label: 'Ahorro' },
|
||||
];
|
||||
|
||||
const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type RecurringItem,
|
||||
type RecurringItemCreate,
|
||||
type RecurringItemUpdate,
|
||||
} from '@/api';
|
||||
} from '@/lib/api';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -17,7 +17,6 @@ import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||
INCOME: { label: 'Ingreso', variant: 'default' },
|
||||
EXPENSE: { label: 'Egreso', variant: 'secondary' },
|
||||
SAVINGS: { label: 'Ahorro', variant: 'outline' },
|
||||
};
|
||||
|
||||
const FREQ_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
||||
import { type MonthlyProjection } from '@/api';
|
||||
import { type MonthlyProjection } from '@/lib/api';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -89,7 +89,6 @@ export default function YearlyOverview({
|
||||
<TableHead className="text-right">Egresos Fijos</TableHead>
|
||||
<TableHead className="text-right">Otros Gastos</TableHead>
|
||||
<TableHead className="text-right">Gran Total</TableHead>
|
||||
<TableHead className="text-right">Ahorro</TableHead>
|
||||
<TableHead className="text-right">Acum. Anterior</TableHead>
|
||||
<TableHead className="text-right">Neto Mes</TableHead>
|
||||
<TableHead className="text-right">Balance Acum.</TableHead>
|
||||
@@ -132,9 +131,6 @@ export default function YearlyOverview({
|
||||
<TableCell data-sensitive className="text-right font-mono text-sm font-medium">
|
||||
{formatAmount(m.gran_total_egresos, 'CRC')}
|
||||
</TableCell>
|
||||
<TableCell data-sensitive className="text-right font-mono text-sm text-muted-foreground">
|
||||
{formatAmount(m.projected_savings, 'CRC')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
'text-right font-mono text-sm',
|
||||
|
||||
456
frontend/src/components/chat/ChatCards.tsx
Normal file
456
frontend/src/components/chat/ChatCards.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import { TrendingDown, TrendingUp, Wallet, ArrowRightLeft } from "lucide-react";
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtCRC(n: number | undefined | null) {
|
||||
if (n == null) return "₡0";
|
||||
return `₡${Math.round(n).toLocaleString("es-CR")}`;
|
||||
}
|
||||
|
||||
function fmtCurrency(amount: number | undefined | null, currency: string | undefined | null) {
|
||||
if (amount == null) return "₡0";
|
||||
if (currency === "USD") return `$${amount.toFixed(2)}`;
|
||||
if (currency === "EUR") return `€${amount.toFixed(2)}`;
|
||||
return fmtCRC(amount);
|
||||
}
|
||||
|
||||
function sourceLabel(source: string | undefined | null) {
|
||||
if (!source) return "Otro";
|
||||
const map: Record<string, string> = {
|
||||
CREDIT_CARD: "Tarjeta",
|
||||
CASH: "Efectivo",
|
||||
TRANSFER: "Transferencia",
|
||||
SINPE: "SINPE",
|
||||
OTHER: "Otro",
|
||||
};
|
||||
return map[source] ?? source.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
// ── Spinner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin inline-block" />
|
||||
);
|
||||
}
|
||||
|
||||
// ── SpendingSummaryCard ───────────────────────────────────────────────────────
|
||||
|
||||
export interface SpendingBySource {
|
||||
source: string;
|
||||
total_crc: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SpendingByCategory {
|
||||
category: string;
|
||||
amount_crc: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SpendingSummaryArgs {
|
||||
title?: string;
|
||||
period?: string;
|
||||
total_crc?: number;
|
||||
by_source?: SpendingBySource[];
|
||||
by_category?: SpendingByCategory[];
|
||||
}
|
||||
|
||||
export function SpendingSummaryCard({
|
||||
args,
|
||||
status,
|
||||
}: {
|
||||
args: SpendingSummaryArgs;
|
||||
status: string;
|
||||
}) {
|
||||
const { title, period, total_crc, by_source = [], by_category = [] } = args;
|
||||
const max = Math.max(...by_category.map((c) => c.amount_crc), 1);
|
||||
|
||||
const PALETTE = [
|
||||
"bg-primary",
|
||||
"bg-chart-1",
|
||||
"bg-chart-2",
|
||||
"bg-chart-3",
|
||||
"bg-chart-4",
|
||||
"bg-chart-5",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||
{title ?? "Resumen de gastos"}
|
||||
</p>
|
||||
{period && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{period}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-2xl font-bold tabular-nums">{fmtCRC(total_crc)}</p>
|
||||
<p className="text-[11px] text-muted-foreground">total gastado</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By source */}
|
||||
{by_source.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{by_source.filter((s) => s?.source != null).map((s) => (
|
||||
<div
|
||||
key={s.source}
|
||||
className="rounded-lg bg-secondary/40 border border-border/50 px-3 py-2"
|
||||
>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{sourceLabel(s.source)}
|
||||
</p>
|
||||
<p className="font-semibold text-sm tabular-nums">
|
||||
{fmtCRC(s.total_crc)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{s.count} mov.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* By category */}
|
||||
{by_category.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-2">
|
||||
Por categoría
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{by_category.filter((c) => c?.category != null).slice(0, 7).map((c, i) => (
|
||||
<div key={c.category}>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-card-foreground">{c.category}</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{fmtCRC(c.amount_crc)}
|
||||
<span className="text-[10px] ml-1">({c.count})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${PALETTE[i % PALETTE.length]}`}
|
||||
style={{
|
||||
width: `${Math.round(((c.amount_crc ?? 0) / max) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "inProgress" && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Spinner /> Obteniendo datos…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TransactionListCard ───────────────────────────────────────────────────────
|
||||
|
||||
export interface TransactionRow {
|
||||
date: string;
|
||||
merchant: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface TransactionListArgs {
|
||||
title?: string;
|
||||
transactions?: TransactionRow[];
|
||||
}
|
||||
|
||||
const SOURCE_ICON: Record<string, React.ReactNode> = {
|
||||
CREDIT_CARD: <Wallet className="w-3 h-3" />,
|
||||
TRANSFER: <ArrowRightLeft className="w-3 h-3" />,
|
||||
};
|
||||
|
||||
export function TransactionListCard({
|
||||
args,
|
||||
status,
|
||||
}: {
|
||||
args: TransactionListArgs;
|
||||
status: string;
|
||||
}) {
|
||||
const { title, transactions = [] } = args;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 my-2 shadow-sm">
|
||||
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-3">
|
||||
{title ?? "Transacciones"}
|
||||
</p>
|
||||
|
||||
{transactions.length === 0 && status !== "inProgress" && (
|
||||
<p className="text-sm text-muted-foreground">Sin transacciones.</p>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-border/50">
|
||||
{transactions.map((t, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-2 gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-6 h-6 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
{SOURCE_ICON[t.source] ?? <Wallet className="w-3 h-3" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{t.merchant}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t.date}
|
||||
{t.category && ` · ${t.category}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold tabular-nums shrink-0 text-destructive">
|
||||
{fmtCurrency(t.amount, t.currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{status === "inProgress" && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-2">
|
||||
<Spinner /> Cargando…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NetWorthCard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AccountRow {
|
||||
bank: string;
|
||||
label: string;
|
||||
balance_crc: number;
|
||||
account_type: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface NetWorthArgs {
|
||||
total_assets_crc?: number;
|
||||
total_liabilities_crc?: number;
|
||||
net_worth_crc?: number;
|
||||
accounts?: AccountRow[];
|
||||
}
|
||||
|
||||
export function NetWorthCard({
|
||||
args,
|
||||
status,
|
||||
}: {
|
||||
args: NetWorthArgs;
|
||||
status: string;
|
||||
}) {
|
||||
const {
|
||||
total_assets_crc = 0,
|
||||
total_liabilities_crc = 0,
|
||||
net_worth_crc = 0,
|
||||
accounts = [],
|
||||
} = args;
|
||||
|
||||
const isPositive = net_worth_crc >= 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
||||
{/* Net worth headline */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||
Patrimonio neto
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{isPositive ? (
|
||||
<TrendingUp className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4 text-destructive" />
|
||||
)}
|
||||
<span
|
||||
className={`text-2xl font-bold tabular-nums ${isPositive ? "text-green-500" : "text-destructive"}`}
|
||||
>
|
||||
{fmtCRC(net_worth_crc)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Activos </span>
|
||||
<span className="font-semibold text-green-500">
|
||||
{fmtCRC(total_assets_crc)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">Pasivos </span>
|
||||
<span className="font-semibold text-destructive">
|
||||
{fmtCRC(total_liabilities_crc)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset bar */}
|
||||
{total_assets_crc + total_liabilities_crc > 0 && (
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all"
|
||||
style={{
|
||||
width: `${Math.round(
|
||||
(total_assets_crc /
|
||||
(total_assets_crc + total_liabilities_crc)) *
|
||||
100,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="h-full bg-destructive flex-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts */}
|
||||
{accounts.length > 0 && (
|
||||
<div className="divide-y divide-border/50">
|
||||
{accounts.map((a, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{a.label || a.bank}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{a.bank} · {a.account_type} · {a.currency}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm font-semibold tabular-nums ${a.balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
|
||||
>
|
||||
{fmtCRC(a.balance_crc)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "inProgress" && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Spinner /> Calculando…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BudgetMonthCard ───────────────────────────────────────────────────────────
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
|
||||
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre",
|
||||
];
|
||||
|
||||
export interface BudgetMonthArgs {
|
||||
year?: number;
|
||||
month?: number;
|
||||
projected_income_crc?: number;
|
||||
projected_expenses_crc?: number;
|
||||
actual_total_crc?: number;
|
||||
net_balance_crc?: number;
|
||||
}
|
||||
|
||||
export function BudgetMonthCard({
|
||||
args,
|
||||
status,
|
||||
}: {
|
||||
args: BudgetMonthArgs;
|
||||
status: string;
|
||||
}) {
|
||||
const {
|
||||
year,
|
||||
month,
|
||||
projected_income_crc = 0,
|
||||
projected_expenses_crc = 0,
|
||||
actual_total_crc = 0,
|
||||
net_balance_crc = 0,
|
||||
} = args;
|
||||
|
||||
const usedPct =
|
||||
projected_expenses_crc > 0
|
||||
? Math.min(Math.round((actual_total_crc / projected_expenses_crc) * 100), 100)
|
||||
: 0;
|
||||
|
||||
const isOver = actual_total_crc > projected_expenses_crc;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||
Presupuesto
|
||||
</p>
|
||||
{month != null && year != null && (
|
||||
<p className="text-sm font-semibold mt-0.5">
|
||||
{MONTH_NAMES[month]} {year}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-right text-2xl font-bold tabular-nums ${net_balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
|
||||
>
|
||||
{fmtCRC(net_balance_crc)}
|
||||
<p className="text-[11px] font-normal text-muted-foreground">
|
||||
balance neto
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Ingresos proyectados</span>
|
||||
<span className="font-medium tabular-nums text-green-500">
|
||||
{fmtCRC(projected_income_crc)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Gastos proyectados</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{fmtCRC(projected_expenses_crc)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-border/50 pt-1.5">
|
||||
<span className="text-muted-foreground">Gastado real</span>
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${isOver ? "text-destructive" : ""}`}
|
||||
>
|
||||
{fmtCRC(actual_total_crc)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{projected_expenses_crc > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground mb-1">
|
||||
<span>Ejecución presupuestaria</span>
|
||||
<span className={isOver ? "text-destructive font-semibold" : ""}>
|
||||
{usedPct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${isOver ? "bg-destructive" : "bg-primary"}`}
|
||||
style={{ width: `${usedPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "inProgress" && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Spinner /> Cargando…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
|
||||
import { type Transaction } from '@/api';
|
||||
import { type Transaction } from '@/lib/api';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,14 +10,18 @@ import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'
|
||||
|
||||
interface TransactionColumnOptions {
|
||||
showCategory: boolean;
|
||||
showSourceIcon?: boolean;
|
||||
onEdit: (tx: Transaction) => void;
|
||||
onDelete: (txId: number) => void;
|
||||
onToggleDeferred?: (tx: Transaction) => void;
|
||||
}
|
||||
|
||||
export function getTransactionColumns({
|
||||
showCategory,
|
||||
showSourceIcon,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleDeferred,
|
||||
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
||||
const columns: ColumnDef<Transaction, unknown>[] = [
|
||||
{
|
||||
@@ -55,6 +59,17 @@ export function getTransactionColumns({
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate">{tx.merchant}</span>
|
||||
{showSourceIcon && tx.source === 'CASH' && (
|
||||
<Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
|
||||
)}
|
||||
{showSourceIcon && tx.source === 'TRANSFER' && (
|
||||
<ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
|
||||
)}
|
||||
{tx.deferred_to_next_cycle && (
|
||||
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-amber-600 border-amber-300">
|
||||
Diferida
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -109,6 +124,18 @@ export function getTransactionColumns({
|
||||
const tx = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{onToggleDeferred && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
|
||||
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
|
||||
onClick={() => onToggleDeferred(tx)}
|
||||
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
|
||||
>
|
||||
<ArrowRightFromLine className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
14
frontend/src/components/ui/skeleton.tsx
Normal file
14
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted/60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ function Tabs({
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:flex-row data-[orientation=vertical]:items-start",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,7 +22,7 @@ function Tabs({
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -56,10 +56,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
31
frontend/src/contexts/privacy-context.tsx
Normal file
31
frontend/src/contexts/privacy-context.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
const PrivacyContext = createContext<{
|
||||
privacyMode: boolean;
|
||||
togglePrivacy: () => void;
|
||||
}>({ privacyMode: false, togglePrivacy: () => {} });
|
||||
|
||||
export function PrivacyProvider({ children }: { children: ReactNode }) {
|
||||
const [privacyMode, setPrivacyMode] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPrivacyMode(localStorage.getItem("privacyMode") === "true");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("privacy", privacyMode);
|
||||
localStorage.setItem("privacyMode", String(privacyMode));
|
||||
}, [privacyMode]);
|
||||
|
||||
const togglePrivacy = () => setPrivacyMode((p) => !p);
|
||||
|
||||
return (
|
||||
<PrivacyContext.Provider value={{ privacyMode, togglePrivacy }}>
|
||||
{children}
|
||||
</PrivacyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const usePrivacy = () => useContext(PrivacyContext);
|
||||
40
frontend/src/contexts/theme-context.tsx
Normal file
40
frontend/src/contexts/theme-context.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}>({ theme: "dark", toggleTheme: () => {} });
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
// Initialize once on mount (localStorage + prefers-color-scheme).
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("theme") as Theme | null;
|
||||
const initial: Theme = saved
|
||||
? saved
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
setTheme(initial);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
deleteRecurringItem as apiDeleteItem,
|
||||
upsertBalanceOverride,
|
||||
deleteBalanceOverride,
|
||||
} from '@/api';
|
||||
} from '@/lib/api';
|
||||
|
||||
export function useBudget(initialYear: number) {
|
||||
const [year, setYear] = useState(initialYear);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
getSettings,
|
||||
updateSettings,
|
||||
type UserSettingsData,
|
||||
type SectionSettings,
|
||||
} from '../api';
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettingsData = {
|
||||
dashboard: {
|
||||
sections: {
|
||||
crc_accounts: { label: 'CRC Accounts', color: 'primary', cardColor: 'primary', visible: true, order: 0, expanded: false },
|
||||
usd_accounts: { label: 'USD Accounts', color: 'chart-1', cardColor: 'chart-1', visible: true, order: 1, expanded: false },
|
||||
pension: { label: 'Pension', color: 'chart-2', cardColor: 'chart-2', visible: true, order: 2, expanded: false },
|
||||
savings: { label: 'Savings', color: 'chart-3', cardColor: 'chart-3', visible: true, order: 3, expanded: false },
|
||||
liabilities: { label: 'Liabilities', color: 'destructive', cardColor: 'destructive', visible: true, order: 4, expanded: false },
|
||||
crypto: { label: 'Crypto', color: 'chart-4', cardColor: 'chart-4', visible: true, order: 5, expanded: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState<UserSettingsData>(DEFAULT_SETTINGS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((r) => setSettings(r.data.data))
|
||||
.catch(() => {}) // use defaults on error
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const patchSection = useCallback(
|
||||
async (sectionId: string, partial: Partial<SectionSettings>) => {
|
||||
setSettings((prev) => {
|
||||
const updated = {
|
||||
...prev,
|
||||
dashboard: {
|
||||
...prev.dashboard,
|
||||
sections: {
|
||||
...prev.dashboard.sections,
|
||||
[sectionId]: { ...prev.dashboard.sections[sectionId], ...partial },
|
||||
},
|
||||
},
|
||||
};
|
||||
// Fire-and-forget save
|
||||
updateSettings(updated).catch(console.error);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { settings, loading, patchSection };
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/noto-sans";
|
||||
@import "@fontsource-variable/ibm-plex-sans";
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@copilotkit/react-core/v2/styles.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-sans: "Noto Sans Variable", sans-serif;
|
||||
--font-heading: "IBM Plex Sans Variable", sans-serif;
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -25,11 +28,11 @@
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.905 0.182 98.111);
|
||||
--chart-2: oklch(0.795 0.184 86.047);
|
||||
--chart-3: oklch(0.681 0.162 75.834);
|
||||
--chart-4: oklch(0.554 0.135 66.442);
|
||||
--chart-5: oklch(0.476 0.114 61.907);
|
||||
--chart-1: oklch(0.55 0.16 145);
|
||||
--chart-2: oklch(0.62 0.19 25);
|
||||
--chart-3: oklch(0.58 0.14 250);
|
||||
--chart-4: oklch(0.68 0.15 80);
|
||||
--chart-5: oklch(0.52 0.13 320);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
@@ -39,6 +42,8 @@
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
|
||||
--copilot-kit-primary-color: var(--primary);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -60,11 +65,11 @@
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.905 0.182 98.111);
|
||||
--chart-2: oklch(0.795 0.184 86.047);
|
||||
--chart-3: oklch(0.681 0.162 75.834);
|
||||
--chart-4: oklch(0.554 0.135 66.442);
|
||||
--chart-5: oklch(0.476 0.114 61.907);
|
||||
--chart-1: oklch(0.60 0.16 145);
|
||||
--chart-2: oklch(0.67 0.19 25);
|
||||
--chart-3: oklch(0.63 0.14 250);
|
||||
--chart-4: oklch(0.73 0.15 80);
|
||||
--chart-5: oklch(0.57 0.13 320);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.704 0.14 182.503);
|
||||
@@ -73,11 +78,39 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
|
||||
--copilot-kit-primary-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Wire CopilotKit v2 CSS variables to WealthySmart's dark palette.
|
||||
The v2 CSS sets --background/--muted/etc directly on [data-copilotkit]
|
||||
elements (unlayered), overriding inherited values from .dark on <html>.
|
||||
Using html.dark [data-copilotkit] (specificity 0,2,1) beats the v2's
|
||||
own .dark [data-copilotkit] (specificity 0,2,0) and restores dark mode. */
|
||||
html.dark [data-copilotkit] {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.437 0.078 188.216);
|
||||
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Noto Sans Variable', sans-serif;
|
||||
--font-heading: 'IBM Plex Sans Variable', sans-serif;
|
||||
--font-sans: var(--font-sans);
|
||||
--font-heading: var(--font-heading);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -121,13 +154,11 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
|
||||
/* Privacy mode: blur sensitive financial data */
|
||||
|
||||
@@ -1,37 +1,113 @@
|
||||
import axios from 'axios';
|
||||
const BASE_URL = "/api/v1";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
|
||||
});
|
||||
class ApiError extends Error {
|
||||
response: { status: number; data: unknown };
|
||||
constructor(status: number, data: unknown) {
|
||||
super(`Request failed with status ${status}`);
|
||||
this.response = { status, data };
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
interface RequestConfig {
|
||||
params?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
async function request<T>(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
config?: RequestConfig,
|
||||
): Promise<{ data: T }> {
|
||||
let fullUrl = `${BASE_URL}${url}`;
|
||||
|
||||
if (config?.params) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(config.params)) {
|
||||
if (v !== undefined) search.set(k, String(v));
|
||||
}
|
||||
return Promise.reject(err);
|
||||
const qs = search.toString();
|
||||
if (qs) fullUrl += `?${qs}`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
let fetchBody: BodyInit | undefined;
|
||||
if (body instanceof FormData || body instanceof URLSearchParams) {
|
||||
fetchBody = body;
|
||||
} else if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
fetchBody = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const res = await fetch(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: fetchBody,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||||
if (typeof window !== "undefined") window.location.replace("/login");
|
||||
throw new ApiError(401, null);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {}
|
||||
throw new ApiError(res.status, data);
|
||||
}
|
||||
|
||||
if (res.status === 204) return { data: null as T };
|
||||
|
||||
const data = await res.json();
|
||||
return { data };
|
||||
}
|
||||
|
||||
const api = {
|
||||
get<T = unknown>(url: string, config?: RequestConfig) {
|
||||
return request<T>("GET", url, undefined, config);
|
||||
},
|
||||
);
|
||||
post<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
|
||||
return request<T>("POST", url, body, config);
|
||||
},
|
||||
patch<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
|
||||
return request<T>("PATCH", url, body, config);
|
||||
},
|
||||
put<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
|
||||
return request<T>("PUT", url, body, config);
|
||||
},
|
||||
delete<T = unknown>(url: string, config?: RequestConfig) {
|
||||
return request<T>("DELETE", url, undefined, config);
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const form = new URLSearchParams();
|
||||
form.append('username', username);
|
||||
form.append('password', password);
|
||||
const { data } = await api.post('/auth/login', form);
|
||||
localStorage.setItem('token', data.access_token);
|
||||
return data;
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {}
|
||||
throw new ApiError(res.status, data);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
bank: string;
|
||||
@@ -56,34 +132,6 @@ export interface ImportResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// --- User Settings ---
|
||||
|
||||
export interface SectionSettings {
|
||||
label: string;
|
||||
color: string;
|
||||
cardColor: string;
|
||||
visible: boolean;
|
||||
order: number;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardSettings {
|
||||
sections: Record<string, SectionSettings>;
|
||||
}
|
||||
|
||||
export interface UserSettingsData {
|
||||
dashboard: DashboardSettings;
|
||||
}
|
||||
|
||||
export interface UserSettingsResponse {
|
||||
key: string;
|
||||
data: UserSettingsData;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const getSettings = () => api.get<UserSettingsResponse>('/settings/');
|
||||
export const updateSettings = (data: UserSettingsData) => api.patch<UserSettingsResponse>('/settings/', { data });
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
amount: number;
|
||||
@@ -101,13 +149,19 @@ export interface Transaction {
|
||||
notes: string | null;
|
||||
category_id: number | null;
|
||||
category: Category | null;
|
||||
deferred_to_next_cycle: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- Budget / Recurring Items ---
|
||||
|
||||
export type RecurringItemType = 'INCOME' | 'EXPENSE' | 'SAVINGS';
|
||||
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY';
|
||||
export type RecurringItemType = "INCOME" | "EXPENSE";
|
||||
export type RecurringFrequency =
|
||||
| "WEEKLY"
|
||||
| "MONTHLY"
|
||||
| "QUARTERLY"
|
||||
| "BIANNUAL"
|
||||
| "YEARLY";
|
||||
|
||||
export interface RecurringItem {
|
||||
id: number;
|
||||
@@ -179,7 +233,6 @@ export interface MonthlyProjection {
|
||||
year: number;
|
||||
projected_income: number;
|
||||
projected_fixed_expenses: number;
|
||||
projected_savings: number;
|
||||
actual_credit_card: number;
|
||||
actual_cash: number;
|
||||
actual_transfers: number;
|
||||
@@ -196,7 +249,6 @@ export interface YearlyProjection {
|
||||
months: MonthlyProjection[];
|
||||
annual_income: number;
|
||||
annual_expenses: number;
|
||||
annual_savings: number;
|
||||
annual_net: number;
|
||||
}
|
||||
|
||||
@@ -205,21 +257,60 @@ export interface MonthlyDetail {
|
||||
month: number;
|
||||
income_items: RecurringItemDetail[];
|
||||
expense_items: RecurringItemDetail[];
|
||||
savings_items: RecurringItemDetail[];
|
||||
actuals_by_source: ActualsBySource[];
|
||||
total_projected_income: number;
|
||||
total_projected_expenses: number;
|
||||
total_projected_savings: number;
|
||||
uncovered_actual: number;
|
||||
gran_total_egresos: number;
|
||||
net_balance: number;
|
||||
cc_by_category: { category_name: string; amount: number }[];
|
||||
}
|
||||
|
||||
// Budget API functions
|
||||
export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) =>
|
||||
api.get<RecurringItem[]>('/budget/recurring', { params });
|
||||
// --- Savings Accrual ---
|
||||
|
||||
export interface SavingsAccrual {
|
||||
id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
memp_amount: number;
|
||||
mpat_amount: number;
|
||||
trigger_transaction_id: number | null;
|
||||
applied_at: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface SavingsAccrualCreate {
|
||||
year: number;
|
||||
month: number;
|
||||
memp_amount?: number;
|
||||
mpat_amount?: number;
|
||||
trigger_transaction_id?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface SavingsAccrualUpdate {
|
||||
memp_amount?: number;
|
||||
mpat_amount?: number;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export const getSavingsAccruals = () =>
|
||||
api.get<SavingsAccrual[]>("/savings-accrual/");
|
||||
export const createSavingsAccrual = (data: SavingsAccrualCreate) =>
|
||||
api.post<SavingsAccrual>("/savings-accrual/", data);
|
||||
export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) =>
|
||||
api.patch<SavingsAccrual>(`/savings-accrual/${id}`, data);
|
||||
export const deleteSavingsAccrual = (id: number) =>
|
||||
api.delete(`/savings-accrual/${id}`);
|
||||
|
||||
// --- Budget ---
|
||||
|
||||
export const getRecurringItems = (params?: {
|
||||
item_type?: string;
|
||||
is_active?: boolean;
|
||||
}) => api.get<RecurringItem[]>("/budget/recurring", { params });
|
||||
export const createRecurringItem = (data: RecurringItemCreate) =>
|
||||
api.post<RecurringItem>('/budget/recurring', data);
|
||||
api.post<RecurringItem>("/budget/recurring", data);
|
||||
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
|
||||
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
|
||||
export const deleteRecurringItem = (id: number) =>
|
||||
@@ -228,7 +319,11 @@ export const getYearlyProjection = (year: number) =>
|
||||
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
||||
export const getMonthlyDetail = (year: number, month: number) =>
|
||||
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
|
||||
export const upsertBalanceOverride = (year: number, month: number, override_balance: number) =>
|
||||
export const upsertBalanceOverride = (
|
||||
year: number,
|
||||
month: number,
|
||||
override_balance: number,
|
||||
) =>
|
||||
api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
|
||||
export const deleteBalanceOverride = (year: number, month: number) =>
|
||||
api.delete(`/budget/balance-override/${year}/${month}`);
|
||||
@@ -242,9 +337,9 @@ export interface SalariosSummary {
|
||||
}
|
||||
|
||||
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
|
||||
api.get<Transaction[]>('/salarios/', { params });
|
||||
api.get<Transaction[]>("/salarios/", { params });
|
||||
export const getSalariosSummary = () =>
|
||||
api.get<SalariosSummary>('/salarios/summary');
|
||||
api.get<SalariosSummary>("/salarios/summary");
|
||||
|
||||
// --- Pensions ---
|
||||
|
||||
@@ -292,18 +387,16 @@ export interface PensionManualEntry {
|
||||
|
||||
export const uploadPensionPDFs = (files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append('files', f));
|
||||
return api.post<PensionUploadResult>('/pensions/upload', form);
|
||||
files.forEach((f) => form.append("files", f));
|
||||
return api.post<PensionUploadResult>("/pensions/upload", form);
|
||||
};
|
||||
|
||||
export const getPensionSnapshots = () =>
|
||||
api.get<PensionSnapshot[]>('/pensions/snapshots');
|
||||
|
||||
api.get<PensionSnapshot[]>("/pensions/snapshots");
|
||||
export const getPensionFundSummary = () =>
|
||||
api.get<PensionSnapshot[]>('/pensions/fund-summary');
|
||||
|
||||
api.get<PensionSnapshot[]>("/pensions/fund-summary");
|
||||
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
|
||||
api.post<PensionUploadResult>('/pensions/manual', { entries });
|
||||
api.post<PensionUploadResult>("/pensions/manual", { entries });
|
||||
|
||||
// --- Municipal Receipts ---
|
||||
|
||||
@@ -360,17 +453,18 @@ export interface MunicipalReceiptUploadResult {
|
||||
|
||||
export const uploadMunicipalReceipt = (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return api.post<MunicipalReceiptUploadResult>('/municipal-receipts/upload', form);
|
||||
form.append("file", file);
|
||||
return api.post<MunicipalReceiptUploadResult>(
|
||||
"/municipal-receipts/upload",
|
||||
form,
|
||||
);
|
||||
};
|
||||
|
||||
export const getMunicipalReceipts = () =>
|
||||
api.get<MunicipalReceipt[]>('/municipal-receipts/');
|
||||
|
||||
api.get<MunicipalReceipt[]>("/municipal-receipts/");
|
||||
export const getMunicipalReceiptDetail = (id: number) =>
|
||||
api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`);
|
||||
|
||||
export const getWaterConsumption = (months?: number) =>
|
||||
api.get<WaterMeterReading[]>('/municipal-receipts/water-consumption', {
|
||||
api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", {
|
||||
params: months ? { months } : undefined,
|
||||
});
|
||||
@@ -5,6 +5,9 @@ export function formatAmount(amount: number, currency: string) {
|
||||
if (currency === 'USD') {
|
||||
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
if (currency === 'EUR') {
|
||||
return `€${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
} from 'recharts';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
import api from '../api';
|
||||
import BillingCycleSelector from '../components/BillingCycleSelector';
|
||||
import api from '@/lib/api';
|
||||
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
@@ -46,9 +46,8 @@ interface DailySpending {
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)',
|
||||
'oklch(0.7 0.15 30)', 'oklch(0.65 0.2 300)', 'oklch(0.6 0.15 150)',
|
||||
'oklch(0.75 0.12 60)', 'oklch(0.55 0.18 250)',
|
||||
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
|
||||
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
|
||||
];
|
||||
|
||||
function formatCRC(value: number) {
|
||||
@@ -132,7 +131,7 @@ export default function Analytics() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<ChartContainer config={pieChartConfig} className="h-[260px] w-full">
|
||||
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={byCategory}
|
||||
@@ -168,7 +167,7 @@ export default function Analytics() {
|
||||
style={{ background: COLORS[i % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{cat.category_name}</span>
|
||||
<span className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
|
||||
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -190,7 +189,7 @@ export default function Analytics() {
|
||||
No data
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={trendChartConfig} className="h-[300px] w-full">
|
||||
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
|
||||
<BarChart data={trend}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
@@ -229,7 +228,7 @@ export default function Analytics() {
|
||||
No data for this period
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={dailyChartConfig} className="h-[240px] w-full">
|
||||
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
|
||||
<LineChart data={daily}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
@@ -287,11 +286,11 @@ export default function Analytics() {
|
||||
style={{ background: COLORS[i % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-sm flex-1">{cat.category_name}</span>
|
||||
<span className="text-xs text-muted-foreground">{cat.count} txns</span>
|
||||
<span className="text-sm font-mono font-medium w-32 text-right">
|
||||
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
|
||||
<span data-sensitive className="text-sm font-mono font-medium w-32 text-right">
|
||||
{formatCRC(cat.total)}
|
||||
</span>
|
||||
<div className="w-24 bg-muted rounded-full h-1.5">
|
||||
<div data-sensitive className="w-24 bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="h-1.5 rounded-full"
|
||||
style={{
|
||||
|
||||
80
frontend/src/pages/Asistente.tsx
Normal file
80
frontend/src/pages/Asistente.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { CopilotChat, useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
import { useCopilotAction } from "@copilotkit/react-core";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
||||
|
||||
const STATIC_SUGGESTIONS = {
|
||||
available: "before-first-message" as const,
|
||||
suggestions: [
|
||||
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
|
||||
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
|
||||
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
|
||||
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function Asistente() {
|
||||
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
||||
|
||||
useCopilotAction({
|
||||
name: "render_spending_summary",
|
||||
description:
|
||||
"Render a visual spending summary card with source breakdown and category progress bars. " +
|
||||
"Call this for any cycle summary, spending totals, or category breakdown.",
|
||||
parameters: [
|
||||
{ name: "title", type: "string", description: "Card title (e.g. 'Ciclo actual')" },
|
||||
{ name: "period", type: "string", description: "Human-readable period (e.g. '18 mar → 18 abr 2026')" },
|
||||
{ name: "total_crc", type: "number", description: "Total spend in CRC" },
|
||||
{
|
||||
name: "by_source",
|
||||
type: "object[]",
|
||||
description: "Breakdown by payment source",
|
||||
attributes: [
|
||||
{ name: "source", type: "string" },
|
||||
{ name: "total_crc", type: "number" },
|
||||
{ name: "count", type: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "by_category",
|
||||
type: "object[]",
|
||||
required: false,
|
||||
description: "Top spending categories with CRC amounts",
|
||||
attributes: [
|
||||
{ name: "category", type: "string" },
|
||||
{ name: "amount_crc", type: "number" },
|
||||
{ name: "count", type: "number" },
|
||||
],
|
||||
},
|
||||
],
|
||||
handler: async () => "ok",
|
||||
render: (props) => (
|
||||
<SpendingSummaryCard args={props.args as SpendingSummaryArgs} status={props.status} />
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-105px)]">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
Asistente
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pregúntale a WealthySmart sobre tus finanzas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
||||
<CopilotChat
|
||||
className="h-full"
|
||||
labels={{
|
||||
modalHeaderTitle: "WealthySmart",
|
||||
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
|
||||
chatInputPlaceholder: "Escribe tu pregunta…",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Calculator, Loader2 } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react';
|
||||
|
||||
import api, { type Transaction } from '@/api';
|
||||
import api, { type Transaction } from '@/lib/api';
|
||||
import { useBudget } from '@/hooks/useBudget';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import YearlyOverview from '@/components/budget/YearlyOverview';
|
||||
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
||||
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
||||
import TransactionList from '@/components/TransactionList';
|
||||
@@ -28,51 +24,71 @@ export default function Budget() {
|
||||
setYear,
|
||||
selectedMonth,
|
||||
setSelectedMonth,
|
||||
projection,
|
||||
monthDetail,
|
||||
recurringItems,
|
||||
loading,
|
||||
monthLoading,
|
||||
addItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
saveBalanceOverride,
|
||||
clearBalanceOverride,
|
||||
refresh,
|
||||
} = useBudget(currentYear);
|
||||
|
||||
const [subTab, setSubTab] = useState<'detail' | 'transactions' | 'projections'>('detail');
|
||||
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
|
||||
|
||||
// Transaction list state for the selected month
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [txLoading, setTxLoading] = useState(false);
|
||||
const [txSearch, setTxSearch] = useState('');
|
||||
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH' | 'TRANSFER'>('CREDIT_CARD');
|
||||
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setTxLoading(true);
|
||||
try {
|
||||
// Use calendar month date range
|
||||
const startDate = `${year}-${String(selectedMonth).padStart(2, '0')}-01`;
|
||||
const endMonth = selectedMonth === 12 ? 1 : selectedMonth + 1;
|
||||
const endYear = selectedMonth === 12 ? year + 1 : year;
|
||||
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
|
||||
const params: Record<string, unknown> = {
|
||||
search: txSearch || undefined,
|
||||
limit: 200,
|
||||
};
|
||||
|
||||
const { data } = await api.get<Transaction[]>('/transactions/', {
|
||||
params: {
|
||||
source: txSource,
|
||||
search: txSearch || undefined,
|
||||
limit: 200,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
},
|
||||
});
|
||||
setTransactions(data);
|
||||
if (txSource === 'CREDIT_CARD') {
|
||||
params.source = 'CREDIT_CARD';
|
||||
// Credit card: billing cycle that ends around the 18th of selectedMonth
|
||||
const prevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
|
||||
const prevYear = selectedMonth === 1 ? year - 1 : year;
|
||||
params.cycle_year = prevYear;
|
||||
params.cycle_month = prevMonth;
|
||||
} else {
|
||||
// Cash + Transfer merged: calendar month, exclude credit card
|
||||
params.exclude_source = 'CREDIT_CARD';
|
||||
const startDate = `${year}-${String(selectedMonth).padStart(2, '0')}-01`;
|
||||
const endMonth = selectedMonth === 12 ? 1 : selectedMonth + 1;
|
||||
const endYear = selectedMonth === 12 ? year + 1 : year;
|
||||
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
|
||||
params.start_date = startDate;
|
||||
params.end_date = endDate;
|
||||
}
|
||||
|
||||
const { data } = await api.get<Transaction[]>('/transactions/', { params });
|
||||
const INCOME_TYPES = ['DEPOSITO', 'SALARY'];
|
||||
const filtered = data.filter((tx) => !INCOME_TYPES.includes(tx.transaction_type));
|
||||
setTransactions(filtered);
|
||||
} finally {
|
||||
setTxLoading(false);
|
||||
}
|
||||
}, [year, selectedMonth, txSource, txSearch]);
|
||||
|
||||
const handleToggleDeferred = useCallback(async (tx: Transaction) => {
|
||||
await api.patch(`/transactions/${tx.id}`, {
|
||||
deferred_to_next_cycle: !tx.deferred_to_next_cycle,
|
||||
});
|
||||
fetchTransactions();
|
||||
refresh();
|
||||
}, [fetchTransactions, refresh]);
|
||||
|
||||
const handleNavigateToTransactions = useCallback(() => {
|
||||
setTxSource('CASH_AND_TRANSFER');
|
||||
setSubTab('transactions');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransactions();
|
||||
}, [fetchTransactions]);
|
||||
@@ -107,16 +123,42 @@ export default function Budget() {
|
||||
value={subTab}
|
||||
onValueChange={(v) => setSubTab(v as typeof subTab)}
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="detail">
|
||||
Detalle: {MONTH_NAMES[selectedMonth]} {year}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transactions">Transacciones</TabsTrigger>
|
||||
<TabsTrigger value="projections">Proyecciones</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="detail">Detalle</TabsTrigger>
|
||||
<TabsTrigger value="transactions">Transacciones</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={selectedMonth <= 1}
|
||||
onClick={() => setSelectedMonth(selectedMonth - 1)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium w-28 text-center">
|
||||
{MONTH_NAMES[selectedMonth]} {year}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={selectedMonth >= 12}
|
||||
onClick={() => setSelectedMonth(selectedMonth + 1)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="detail" className="space-y-6 mt-4">
|
||||
{monthDetail && <MonthlyDetail detail={monthDetail} loading={monthLoading} />}
|
||||
<MonthlyDetail
|
||||
detail={monthDetail}
|
||||
loading={monthLoading || !monthDetail}
|
||||
onNavigateToTransactions={handleNavigateToTransactions}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transactions" className="space-y-3 mt-4">
|
||||
@@ -126,14 +168,13 @@ export default function Budget() {
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
|
||||
<TabsTrigger value="CASH">Efectivo</TabsTrigger>
|
||||
<TabsTrigger value="TRANSFER">Transferencias</TabsTrigger>
|
||||
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<TransactionList
|
||||
transactions={transactions}
|
||||
loading={txLoading}
|
||||
source={txSource}
|
||||
source={txSource === 'CREDIT_CARD' ? 'CREDIT_CARD' : 'CASH'}
|
||||
search={txSearch}
|
||||
onSearchChange={setTxSearch}
|
||||
onRefresh={() => {
|
||||
@@ -141,81 +182,11 @@ export default function Budget() {
|
||||
refresh();
|
||||
}}
|
||||
showCategory={txSource === 'CREDIT_CARD'}
|
||||
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : txSource === 'CASH' ? 'efectivo' : 'transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
||||
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
|
||||
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
||||
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="projections" className="space-y-6 mt-4">
|
||||
{projection && (
|
||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">Ingresos Anuales</p>
|
||||
<p data-sensitive className="text-lg font-bold font-mono text-primary">
|
||||
{formatAmount(projection.annual_income, 'CRC')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">Egresos Anuales</p>
|
||||
<p data-sensitive className="text-lg font-bold font-mono">
|
||||
{formatAmount(projection.annual_expenses, 'CRC')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">Ahorro Anual</p>
|
||||
<p data-sensitive className="text-lg font-bold font-mono">
|
||||
{formatAmount(projection.annual_savings, 'CRC')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">Balance Neto Anual</p>
|
||||
<p
|
||||
data-sensitive
|
||||
className={cn(
|
||||
'text-lg font-bold font-mono',
|
||||
projection.annual_net >= 0 ? 'text-primary' : 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{projection.annual_net >= 0 ? '+' : ''}
|
||||
{formatAmount(projection.annual_net, 'CRC')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projection ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<YearlyOverview
|
||||
months={projection.months}
|
||||
selectedMonth={selectedMonth}
|
||||
year={year}
|
||||
onSelectMonth={(m) => {
|
||||
setSelectedMonth(m);
|
||||
setSubTab('detail');
|
||||
}}
|
||||
onSaveOverride={async (month, value) => {
|
||||
await saveBalanceOverride(year, month, value);
|
||||
}}
|
||||
onClearOverride={async (month) => {
|
||||
await clearBalanceOverride(year, month);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowRight,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
RefreshCw,
|
||||
CreditCard,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
BellRing,
|
||||
Landmark,
|
||||
} from 'lucide-react';
|
||||
|
||||
import api, { type Account, type Transaction } from '../api';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { formatAmount, formatDate, formatLocalDatetime } from '@/lib/format';
|
||||
import DashboardSection from '@/components/DashboardSection';
|
||||
import SectionConfigDialog from '@/components/SectionConfigDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// --- Section definitions ---
|
||||
|
||||
interface SectionDef {
|
||||
filterFn: (a: Account) => boolean;
|
||||
totalCurrency: string; // empty string = no total
|
||||
}
|
||||
|
||||
const SECTION_DEFS: Record<string, SectionDef> = {
|
||||
crc_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'CRC', totalCurrency: 'CRC' },
|
||||
usd_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'USD', totalCurrency: 'USD' },
|
||||
pension: { filterFn: (a) => a.account_type === 'PENSION', totalCurrency: 'CRC' },
|
||||
savings: { filterFn: (a) => a.account_type === 'SAVINGS', totalCurrency: 'CRC' },
|
||||
liabilities: { filterFn: (a) => a.account_type === 'LIABILITY', totalCurrency: '' },
|
||||
crypto: { filterFn: (a) => a.account_type === 'CRYPTO', totalCurrency: '' },
|
||||
};
|
||||
|
||||
const BANK_ORDER = ['BAC', 'BCR', 'DAVIVIENDA'];
|
||||
|
||||
// --- AccountRow ---
|
||||
|
||||
interface AccountRowProps {
|
||||
account: Account;
|
||||
editingId: number | null;
|
||||
editValue: string;
|
||||
setEditValue: (v: string) => void;
|
||||
startEditing: (a: Account) => void;
|
||||
saveBalance: (id: number) => void;
|
||||
cancelEditing: () => void;
|
||||
}
|
||||
|
||||
function AccountRow({
|
||||
account,
|
||||
editingId,
|
||||
editValue,
|
||||
setEditValue,
|
||||
startEditing,
|
||||
saveBalance,
|
||||
cancelEditing,
|
||||
}: AccountRowProps) {
|
||||
const isLiability = account.account_type === 'LIABILITY';
|
||||
const isCrypto = account.account_type === 'CRYPTO';
|
||||
const label = isCrypto ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank);
|
||||
const isEditing = editingId === account.id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/30 transition-colors group">
|
||||
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||
|
||||
{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="text-sm font-bold font-mono tracking-tight h-auto py-1 w-40"
|
||||
/>
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => saveBalance(account.id)} title="Save" aria-label="Save balance">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={cancelEditing} title="Cancel" aria-label="Cancel editing">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span data-sensitive className={cn('text-lg font-bold font-mono tracking-tight', isLiability && 'text-destructive')}>
|
||||
{formatAmount(account.balance, account.currency)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => startEditing(account)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
title="Edit balance"
|
||||
aria-label="Edit balance"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{isLiability && account.next_payment != null && (
|
||||
<span data-sensitive className="text-xs font-mono text-destructive/60 ml-2">
|
||||
Next: {formatAmount(account.next_payment, account.currency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
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 [configSection, setConfigSection] = useState<string | null>(null);
|
||||
const [testingPush, setTestingPush] = useState(false);
|
||||
|
||||
const { settings, patchSection } = useSettings();
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [accRes, txRes] = await Promise.all([
|
||||
api.get('/accounts/'),
|
||||
api.get('/transactions/recent?limit=5'),
|
||||
]);
|
||||
setAccounts(accRes.data);
|
||||
setRecent(txRes.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
api.get('/exchange-rate/').then((r) => setExchangeRate(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
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 rowProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing };
|
||||
|
||||
// Sort sections by order, filter by visible
|
||||
const sortedSections = useMemo(() => {
|
||||
const sections = settings.dashboard.sections;
|
||||
return Object.entries(sections)
|
||||
.filter(([, s]) => s.visible)
|
||||
.sort(([, a], [, b]) => a.order - b.order);
|
||||
}, [settings]);
|
||||
|
||||
// Net worth calculation
|
||||
const netWorthBreakdown = useMemo(() => {
|
||||
if (accounts.length === 0) return null;
|
||||
let assets = 0;
|
||||
let liabilities = 0;
|
||||
for (const a of accounts) {
|
||||
const isLiability = a.account_type === 'LIABILITY';
|
||||
let crcValue = 0;
|
||||
if (a.currency === 'USD') {
|
||||
crcValue = Math.abs(a.balance) * (exchangeRate?.sell_rate ?? 0);
|
||||
} else if (a.currency === 'CRC') {
|
||||
crcValue = Math.abs(a.balance);
|
||||
}
|
||||
if (isLiability) {
|
||||
liabilities += crcValue;
|
||||
} else {
|
||||
assets += crcValue;
|
||||
}
|
||||
}
|
||||
return { assets, liabilities, net: assets - liabilities };
|
||||
}, [accounts, exchangeRate]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-heading">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Financial overview</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
|
||||
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Net Worth */}
|
||||
{netWorthBreakdown != null && (
|
||||
<Card>
|
||||
<CardContent className="px-4 py-3">
|
||||
<div className="flex items-center justify-between text-sm font-mono text-muted-foreground">
|
||||
<span>Net <span data-sensitive className="text-foreground font-bold">{netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')}</span></span>
|
||||
<div className="flex gap-4">
|
||||
<span>Assets <span data-sensitive className="text-foreground">{formatAmount(netWorthBreakdown.assets, 'CRC')}</span></span>
|
||||
<span>Liabilities <span data-sensitive className="text-foreground">{formatAmount(netWorthBreakdown.liabilities, 'CRC')}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Account sections */}
|
||||
{sortedSections.map(([sectionId, sectionSettings]) => {
|
||||
const def = SECTION_DEFS[sectionId];
|
||||
if (!def) return null;
|
||||
let accts = accounts.filter(def.filterFn);
|
||||
if (accts.length === 0) return null;
|
||||
|
||||
// Sort bank accounts by bank order
|
||||
if (sectionId === 'crc_accounts' || sectionId === 'usd_accounts') {
|
||||
accts = accts.sort((a, b) => BANK_ORDER.indexOf(a.bank) - BANK_ORDER.indexOf(b.bank));
|
||||
}
|
||||
|
||||
const total = accts.reduce((s, a) => s + a.balance, 0);
|
||||
|
||||
return (
|
||||
<DashboardSection
|
||||
key={sectionId}
|
||||
sectionId={sectionId}
|
||||
settings={sectionSettings}
|
||||
total={def.totalCurrency ? total : undefined}
|
||||
totalCurrency={def.totalCurrency || undefined}
|
||||
onToggleExpanded={(expanded) => patchSection(sectionId, { expanded })}
|
||||
onOpenConfig={() => setConfigSection(sectionId)}
|
||||
>
|
||||
{accts.map((a) => (
|
||||
<AccountRow key={a.id} account={a} {...rowProps} />
|
||||
))}
|
||||
</DashboardSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Exchange rate */}
|
||||
{exchangeRate && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">USD/CRC Exchange Rate</span>
|
||||
<div className="flex items-baseline gap-3 mt-1">
|
||||
<span data-sensitive className="text-lg font-bold font-mono">Buy: ₡{exchangeRate.buy_rate.toFixed(2)}</span>
|
||||
<span data-sensitive className="text-lg font-bold font-mono text-muted-foreground">Sell: ₡{exchangeRate.sell_rate.toFixed(2)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent transactions */}
|
||||
<Card>
|
||||
<CardHeader className="border-b flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">Recent Charges</CardTitle>
|
||||
</div>
|
||||
<Link
|
||||
to="/transactions"
|
||||
className="flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{recent.length === 0 && !loading ? (
|
||||
<div className="px-5 py-12 text-center text-muted-foreground text-sm">No transactions yet. Add your first one!</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{recent.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
||||
tx.transaction_type === 'COMPRA' ? 'bg-destructive/10 text-destructive' : 'bg-primary/10 text-primary'
|
||||
)}>
|
||||
{tx.transaction_type === 'DEPOSITO' ? <Landmark className="w-4 h-4" /> : 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-muted-foreground">
|
||||
{formatDate(tx.date)}
|
||||
{tx.category && <span className="ml-2 text-muted-foreground/60">{tx.category.name}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span data-sensitive className={cn(
|
||||
'font-mono text-sm font-medium shrink-0 ml-4',
|
||||
tx.transaction_type !== 'COMPRA' && 'text-primary'
|
||||
)}>
|
||||
{tx.transaction_type === 'COMPRA' ? '-' : '+'}{formatAmount(tx.amount, tx.currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test push notification */}
|
||||
<Card className="border-dashed border-yellow-500/50">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Test Push Notification</p>
|
||||
<p className="text-xs text-muted-foreground">Creates a mock transaction to trigger a push notification</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={testingPush}
|
||||
onClick={async () => {
|
||||
setTestingPush(true);
|
||||
try {
|
||||
const merchants = ['Walmart', 'AutoMercado', 'Uber Eats', 'Amazon', 'PriceSmart'];
|
||||
const amounts = [4500, 12350, 8900, 25000, 67800];
|
||||
const i = Math.floor(Math.random() * merchants.length);
|
||||
await api.post('/transactions/', {
|
||||
merchant: merchants[i],
|
||||
amount: amounts[i],
|
||||
currency: 'CRC',
|
||||
date: formatLocalDatetime(new Date()),
|
||||
bank: 'BAC',
|
||||
source: 'CREDIT_CARD',
|
||||
transaction_type: 'COMPRA',
|
||||
reference: `test-push-${Date.now()}`,
|
||||
notes: '[TEST] Push notification test — safe to delete',
|
||||
});
|
||||
fetchData();
|
||||
} catch (e) {
|
||||
console.error('Test push failed:', e);
|
||||
} finally {
|
||||
setTestingPush(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BellRing className="w-4 h-4 mr-2" />
|
||||
{testingPush ? 'Sending...' : 'Send test'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section config dialog */}
|
||||
{configSection && settings.dashboard.sections[configSection] && (
|
||||
<SectionConfigDialog
|
||||
sectionId={configSection}
|
||||
settings={settings.dashboard.sections[configSection]}
|
||||
open={!!configSection}
|
||||
onOpenChange={(open) => { if (!open) setConfigSection(null); }}
|
||||
onSave={(id, partial) => patchSection(id, partial)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Wallet, ArrowRight, AlertCircle } from "lucide-react";
|
||||
import { login } from "@/lib/api";
|
||||
import { useAuth } from "@/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import { login } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { subscribeToPush } from '../pushNotifications';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { setAuthenticated } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setError("");
|
||||
try {
|
||||
await login(username, password);
|
||||
setAuthenticated(true);
|
||||
subscribeToPush();
|
||||
navigate('/');
|
||||
navigate("/asistente", { replace: true });
|
||||
} catch {
|
||||
setError('Invalid credentials');
|
||||
setError("Invalid credentials");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -41,7 +38,7 @@ export default function Login() {
|
||||
<div className="w-10 h-10 rounded-lg bg-primary flex items-center justify-center">
|
||||
<Wallet className="w-6 h-6 text-primary-foreground" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-2xl font-bold tracking-tight font-heading">
|
||||
<span className="text-2xl font-bold tracking-tight" style={{ fontFamily: "var(--font-heading)" }}>
|
||||
Wealthy<span className="text-primary">Smart</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -84,7 +81,7 @@ export default function Login() {
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full h-10">
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
{!loading && <ArrowRight className="w-4 h-4" />}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
getPensionSnapshots,
|
||||
type PensionSnapshot,
|
||||
type PensionUploadResult,
|
||||
} from '@/api';
|
||||
} from '@/lib/api';
|
||||
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
|
||||
import { ClipboardPaste } from 'lucide-react';
|
||||
|
||||
|
||||
112
frontend/src/pages/Proyecciones.tsx
Normal file
112
frontend/src/pages/Proyecciones.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ChevronLeft, ChevronRight, Loader2, TrendingUp } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useBudget } from '@/hooks/useBudget';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import YearlyOverview from '@/components/budget/YearlyOverview';
|
||||
|
||||
const MIN_YEAR = 2026;
|
||||
const MAX_YEAR = 2030;
|
||||
|
||||
export default function Proyecciones() {
|
||||
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
|
||||
const {
|
||||
year,
|
||||
setYear,
|
||||
setSelectedMonth,
|
||||
projection,
|
||||
loading,
|
||||
saveBalanceOverride,
|
||||
clearBalanceOverride,
|
||||
} = useBudget(currentYear);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
|
||||
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annual summary cards */}
|
||||
{projection && (
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">Ingresos Anuales</p>
|
||||
<p data-sensitive className="text-lg font-bold font-mono text-primary">
|
||||
{formatAmount(projection.annual_income, 'CRC')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">Egresos Anuales</p>
|
||||
<p data-sensitive className="text-lg font-bold font-mono">
|
||||
{formatAmount(projection.annual_expenses, 'CRC')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">Balance Neto Anual</p>
|
||||
<p
|
||||
data-sensitive
|
||||
className={cn(
|
||||
'text-lg font-bold font-mono',
|
||||
projection.annual_net >= 0 ? 'text-primary' : 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{projection.annual_net >= 0 ? '+' : ''}
|
||||
{formatAmount(projection.annual_net, 'CRC')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yearly table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projection ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<YearlyOverview
|
||||
months={projection.months}
|
||||
selectedMonth={0}
|
||||
year={year}
|
||||
onSelectMonth={(m) => {
|
||||
setSelectedMonth(m);
|
||||
navigate('/budget');
|
||||
}}
|
||||
onSaveOverride={async (month, value) => {
|
||||
await saveBalanceOverride(year, month, value);
|
||||
}}
|
||||
onClearOverride={async (month) => {
|
||||
await clearBalanceOverride(year, month);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react';
|
||||
|
||||
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '../api';
|
||||
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
|
||||
import { formatAmount, formatDate } from '@/lib/format';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type MunicipalReceipt,
|
||||
type MunicipalReceiptUploadResult,
|
||||
type WaterMeterReading,
|
||||
} from '@/api';
|
||||
} from '@/lib/api';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, ClipboardPaste } from 'lucide-react';
|
||||
|
||||
import api, { type Transaction, type Category } from '../api';
|
||||
import PasteImportModal from '../components/PasteImportModal';
|
||||
import BillingCycleSelector from '../components/BillingCycleSelector';
|
||||
import TransactionList from '../components/TransactionList';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
function formatAmount(amount: number, currency: string) {
|
||||
const abs = Math.abs(amount);
|
||||
if (currency === 'USD') {
|
||||
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
export default function Transactions() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' };
|
||||
if (search) params.search = search;
|
||||
if (categoryFilter) params.category_id = categoryFilter;
|
||||
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, cycle]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/categories/').then((r) => setCategories(r.data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(fetchTransactions, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchTransactions]);
|
||||
|
||||
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 font-heading">Credit Card Transactions</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{transactions.length} transactions
|
||||
{totalCRC !== 0 && (
|
||||
<> · <span data-sensitive className="font-mono text-foreground">{formatAmount(totalCRC, 'CRC')}</span></>
|
||||
)}
|
||||
{totalUSD !== 0 && (
|
||||
<> · <span data-sensitive className="font-mono text-foreground">{formatAmount(totalUSD, 'USD')}</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<ClipboardPaste className="w-4 h-4" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing cycle */}
|
||||
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
||||
|
||||
{/* Category filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={categoryFilter || 'all'}
|
||||
onValueChange={(v) => setCategoryFilter(v === 'all' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<TransactionList
|
||||
transactions={transactions}
|
||||
loading={loading}
|
||||
source="CREDIT_CARD"
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
onRefresh={fetchTransactions}
|
||||
showCategory
|
||||
/>
|
||||
|
||||
{importOpen && (
|
||||
<PasteImportModal
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={fetchTransactions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { ArrowLeftRight } from 'lucide-react';
|
||||
|
||||
import api, { type Transaction } from '../api';
|
||||
import TransactionList from '../components/TransactionList';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
|
||||
type SourceTab = 'CASH' | 'TRANSFER';
|
||||
|
||||
export default function Transfers() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sourceTab, setSourceTab] = useState<SourceTab>('CASH');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { source: sourceTab, limit: '200' };
|
||||
if (search) params.search = search;
|
||||
const { data } = await api.get('/transactions/', { params });
|
||||
setTransactions(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, sourceTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(fetchTransactions, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchTransactions]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-heading">Cash & Transfers</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Track non-credit-card expenses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={sourceTab} onValueChange={(v) => setSourceTab(v as SourceTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="CASH">Cash</TabsTrigger>
|
||||
<TabsTrigger value="TRANSFER">Transfers</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={sourceTab} className="mt-5 space-y-5">
|
||||
<TransactionList
|
||||
transactions={transactions}
|
||||
loading={loading}
|
||||
source={sourceTab}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
onRefresh={fetchTransactions}
|
||||
showCategory={false}
|
||||
addLabel={sourceTab === 'CASH' ? 'Add Cash Expense' : 'Add Transfer'}
|
||||
emptyIcon={<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
|
||||
emptyMessage={`No ${sourceTab.toLowerCase()} transactions yet`}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -7,17 +7,15 @@
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "server.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8001',
|
||||
// CopilotKit runtime (Hono server, dev only)
|
||||
"/api/copilotkit": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
// All other API calls → Python backend
|
||||
"/api": {
|
||||
target: "http://localhost:8001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
99
scripts/sync-db.sh
Executable file
99
scripts/sync-db.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ────────────────────────────────────────────────
|
||||
PROD_SSH_ALIAS="old-vps"
|
||||
PROD_CONTAINER="wealthysmart-db-prod"
|
||||
PROD_DB="wealthysmart"
|
||||
PROD_USER="wealthy_user"
|
||||
|
||||
LOCAL_CONTAINER="wealthysmart-db-dev"
|
||||
LOCAL_DB="wealthysmart"
|
||||
LOCAL_USER="wealthy_user"
|
||||
LOCAL_PASS="wealthy_pass"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
DUMP_FILE="$(mktemp -t wealthysmart-dump-XXXXXX)"
|
||||
|
||||
# ── Cleanup on exit ─────────────────────────────────────────────
|
||||
cleanup() { rm -f "$DUMP_FILE"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Confirmation ─────────────────────────────────────────────────
|
||||
echo "=== WealthySmart Database Sync ==="
|
||||
echo ""
|
||||
echo "This will DESTROY your local dev database and replace it"
|
||||
echo "with a copy of production data."
|
||||
echo ""
|
||||
read -r -p "Continue? [y/N] " confirm
|
||||
if [[ "$confirm" != [yY] ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 1. Dump production ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[1/5] Dumping production database..."
|
||||
ssh "$PROD_SSH_ALIAS" \
|
||||
"docker exec $PROD_CONTAINER pg_dump \
|
||||
--format=custom \
|
||||
--no-owner \
|
||||
--no-acl \
|
||||
-U $PROD_USER \
|
||||
$PROD_DB" > "$DUMP_FILE"
|
||||
|
||||
if [[ ! -s "$DUMP_FILE" ]]; then
|
||||
echo "ERROR: Dump file is empty. SSH or pg_dump may have failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DUMP_SIZE=$(du -h "$DUMP_FILE" | cut -f1)
|
||||
echo " Done. Dump size: $DUMP_SIZE"
|
||||
|
||||
# ── 2. Ensure local DB container is running ─────────────────────
|
||||
echo "[2/5] Ensuring local dev database is running..."
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
if ! docker inspect --format='{{.State.Running}}' "$LOCAL_CONTAINER" 2>/dev/null | grep -q "true"; then
|
||||
echo " Starting db service..."
|
||||
docker compose up -d db
|
||||
fi
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$LOCAL_CONTAINER" 2>/dev/null | grep -q "healthy"; then
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 30 ]]; then
|
||||
echo "ERROR: Local DB container did not become healthy within 30s."
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " Local DB is running and healthy."
|
||||
|
||||
# ── 3. Drop and recreate local database ─────────────────────────
|
||||
echo "[3/5] Dropping and recreating local dev database..."
|
||||
docker exec "$LOCAL_CONTAINER" bash -c \
|
||||
"PGPASSWORD='$LOCAL_PASS' dropdb -U $LOCAL_USER --if-exists $LOCAL_DB && \
|
||||
PGPASSWORD='$LOCAL_PASS' createdb -U $LOCAL_USER $LOCAL_DB"
|
||||
echo " Done."
|
||||
|
||||
# ── 4. Restore ──────────────────────────────────────────────────
|
||||
echo "[4/5] Restoring dump into local dev database..."
|
||||
docker exec -i "$LOCAL_CONTAINER" pg_restore \
|
||||
--no-owner \
|
||||
--no-acl \
|
||||
--dbname="$LOCAL_DB" \
|
||||
-U "$LOCAL_USER" < "$DUMP_FILE"
|
||||
|
||||
# ── 5. Run pending migrations ───────────────────────────────────
|
||||
echo "[5/5] Running pending migrations..."
|
||||
docker exec "$LOCAL_CONTAINER" psql -U "$LOCAL_USER" -d "$LOCAL_DB" -c \
|
||||
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false;" \
|
||||
2>/dev/null || true
|
||||
echo " Done."
|
||||
|
||||
echo ""
|
||||
echo "=== Sync complete! ==="
|
||||
echo "Local dev database now mirrors production."
|
||||
Reference in New Issue
Block a user