mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 13:28:48 +02:00
Compare commits
48 Commits
2cd0d3b2e1
...
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 | ||
|
|
37e04273b9 | ||
|
|
c005956458 | ||
|
|
8f775e5531 | ||
|
|
739a32efd4 | ||
|
|
45166f9d20 | ||
|
|
aedf3aa3b0 | ||
|
|
cab4d86b5c | ||
|
|
22334c2129 | ||
|
|
0923337fff | ||
|
|
898b540b3f | ||
|
|
3c9656f416 | ||
|
|
e011a3adcc | ||
|
|
b68129a171 | ||
|
|
99d0c4ebd7 | ||
|
|
26a26b8ca2 | ||
|
|
fe8d0144eb | ||
|
|
eccfd53e0b | ||
|
|
1b90f0c70a | ||
|
|
bd1346f9da | ||
|
|
9cfa1c4eb1 | ||
|
|
8d76059ae8 |
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -21,6 +21,10 @@ jobs:
|
|||||||
ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }}
|
ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }}
|
||||||
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
||||||
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
|
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
|
ENVEOF
|
||||||
sed -i 's/^[[:space:]]*//' .env.prod
|
sed -i 's/^[[:space:]]*//' .env.prod
|
||||||
|
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -2,7 +2,15 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*.db
|
||||||
|
*.db.bak
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
docs/legacy_budget_analysis.md
|
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
|
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
|
## Deployment
|
||||||
|
|
||||||
- Deployed via Gitea Actions (self-hosted runner on VPS)
|
- Deployed via Gitea Actions (self-hosted runner on VPS)
|
||||||
- Push to `main` triggers: GitHub → webhook → Gitea mirror sync → Actions workflow → Docker build & deploy
|
- Push to `main` triggers: GitHub → webhook → Gitea mirror sync → Actions workflow → Docker build & deploy
|
||||||
- Domain: wealth.cescalante.dev
|
- Domain: wealth.cescalante.dev
|
||||||
- Reverse proxy: nginx-proxy + acme-companion (auto TLS)
|
- 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`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir --pre -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
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 fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import case
|
||||||
from sqlmodel import Session, func, select
|
from sqlmodel import Session, func, select
|
||||||
|
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.models import Category, Transaction
|
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"])
|
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||||
|
|
||||||
@@ -43,10 +45,12 @@ def spending_by_category(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
Transaction.category_id,
|
Transaction.category_id,
|
||||||
func.sum(Transaction.amount).label("total"),
|
func.sum(amount_crc).label("total"),
|
||||||
func.count().label("count"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == "COMPRA")
|
.where(Transaction.transaction_type == "COMPRA")
|
||||||
@@ -87,7 +91,12 @@ def monthly_trend(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_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()
|
now = datetime.now()
|
||||||
results = []
|
results = []
|
||||||
month_names = [
|
month_names = [
|
||||||
@@ -102,18 +111,10 @@ def monthly_trend(
|
|||||||
row = session.exec(
|
row = session.exec(
|
||||||
select(
|
select(
|
||||||
func.count(),
|
func.count(),
|
||||||
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
func.coalesce(
|
func.coalesce(
|
||||||
func.sum(
|
func.sum(
|
||||||
func.case(
|
case(
|
||||||
(Transaction.currency == "CRC", Transaction.amount),
|
|
||||||
else_=0,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
func.coalesce(
|
|
||||||
func.sum(
|
|
||||||
func.case(
|
|
||||||
(Transaction.currency == "USD", Transaction.amount),
|
(Transaction.currency == "USD", Transaction.amount),
|
||||||
else_=0,
|
else_=0,
|
||||||
)
|
)
|
||||||
@@ -162,10 +163,12 @@ def daily_spending(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
func.date(Transaction.date).label("day"),
|
func.date(Transaction.date).label("day"),
|
||||||
func.sum(Transaction.amount).label("total"),
|
func.sum(amount_crc).label("total"),
|
||||||
func.count().label("count"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == "COMPRA")
|
.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.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
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
|||||||
)
|
)
|
||||||
token = create_access_token(form_data.username)
|
token = create_access_token(form_data.username)
|
||||||
return {"access_token": token, "token_type": "bearer"}
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||||
|
return {"username": username}
|
||||||
|
|||||||
292
backend/app/api/v1/endpoints/budget.py
Normal file
292
backend/app/api/v1/endpoints/budget.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import (
|
||||||
|
BalanceOverride,
|
||||||
|
BalanceOverrideCreate,
|
||||||
|
BalanceOverrideRead,
|
||||||
|
RecurringItem,
|
||||||
|
RecurringItemCreate,
|
||||||
|
RecurringItemRead,
|
||||||
|
RecurringItemType,
|
||||||
|
RecurringItemUpdate,
|
||||||
|
)
|
||||||
|
from app.services.budget_projection import (
|
||||||
|
FRESH_START_MONTH,
|
||||||
|
FRESH_START_YEAR,
|
||||||
|
MAX_YEAR,
|
||||||
|
MIN_YEAR,
|
||||||
|
compute_monthly_projection,
|
||||||
|
compute_yearly_projection_with_cumulative,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/budget", tags=["budget"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Recurring Item CRUD ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recurring", response_model=list[RecurringItemRead])
|
||||||
|
def list_recurring_items(
|
||||||
|
item_type: Optional[RecurringItemType] = None,
|
||||||
|
is_active: Optional[bool] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
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:
|
||||||
|
query = query.where(RecurringItem.is_active == is_active)
|
||||||
|
query = query.order_by(RecurringItem.item_type, RecurringItem.name)
|
||||||
|
return session.exec(query).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recurring", response_model=RecurringItemRead, status_code=201)
|
||||||
|
def create_recurring_item(
|
||||||
|
data: RecurringItemCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = RecurringItem.model_validate(data)
|
||||||
|
session.add(item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/recurring/{item_id}", response_model=RecurringItemRead)
|
||||||
|
def update_recurring_item(
|
||||||
|
item_id: int,
|
||||||
|
data: RecurringItemUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = session.get(RecurringItem, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring item not found")
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
session.add(item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/recurring/{item_id}", status_code=204)
|
||||||
|
def delete_recurring_item(
|
||||||
|
item_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = session.get(RecurringItem, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring item not found")
|
||||||
|
session.delete(item)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Projection Endpoints ---
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyProjectionResponse(BaseModel):
|
||||||
|
month: int
|
||||||
|
year: int
|
||||||
|
projected_income: float
|
||||||
|
projected_fixed_expenses: float
|
||||||
|
actual_credit_card: float
|
||||||
|
actual_cash: float
|
||||||
|
actual_transfers: float
|
||||||
|
uncovered_actual: float
|
||||||
|
gran_total_egresos: float
|
||||||
|
net_balance: float
|
||||||
|
carryover_balance: float = 0.0
|
||||||
|
cumulative_balance: float = 0.0
|
||||||
|
balance_overridden: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class YearlyProjectionResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
months: list[MonthlyProjectionResponse]
|
||||||
|
annual_income: float
|
||||||
|
annual_expenses: float
|
||||||
|
annual_net: float
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projection/{year}", response_model=YearlyProjectionResponse)
|
||||||
|
def get_yearly_projection(
|
||||||
|
year: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if year < MIN_YEAR or year > MAX_YEAR:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Year must be between {MIN_YEAR} and {MAX_YEAR}",
|
||||||
|
)
|
||||||
|
|
||||||
|
months_data = compute_yearly_projection_with_cumulative(session, year)
|
||||||
|
months = []
|
||||||
|
annual_income = 0.0
|
||||||
|
annual_expenses = 0.0
|
||||||
|
annual_net = 0.0
|
||||||
|
|
||||||
|
for data in months_data:
|
||||||
|
monthly = MonthlyProjectionResponse(
|
||||||
|
month=data["month"],
|
||||||
|
year=data["year"],
|
||||||
|
projected_income=data["projected_income"],
|
||||||
|
projected_fixed_expenses=data["projected_fixed_expenses"],
|
||||||
|
actual_credit_card=data["actual_credit_card"],
|
||||||
|
actual_cash=data["actual_cash"],
|
||||||
|
actual_transfers=data["actual_transfers"],
|
||||||
|
uncovered_actual=data["uncovered_actual"],
|
||||||
|
gran_total_egresos=data["gran_total_egresos"],
|
||||||
|
net_balance=data["net_balance"],
|
||||||
|
carryover_balance=data["carryover_balance"],
|
||||||
|
cumulative_balance=data["cumulative_balance"],
|
||||||
|
balance_overridden=data["balance_overridden"],
|
||||||
|
)
|
||||||
|
months.append(monthly)
|
||||||
|
annual_income += data["projected_income"]
|
||||||
|
annual_expenses += data["gran_total_egresos"]
|
||||||
|
annual_net += data["net_balance"]
|
||||||
|
|
||||||
|
return YearlyProjectionResponse(
|
||||||
|
year=year,
|
||||||
|
months=months,
|
||||||
|
annual_income=annual_income,
|
||||||
|
annual_expenses=annual_expenses,
|
||||||
|
annual_net=annual_net,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemDetail(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
amount: float
|
||||||
|
projected_amount: float | None = None
|
||||||
|
used_actual: bool = False
|
||||||
|
item_type: str
|
||||||
|
frequency: str
|
||||||
|
category_name: str | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActualsBySource(BaseModel):
|
||||||
|
source: str
|
||||||
|
total_compra: float
|
||||||
|
total_devolucion: float
|
||||||
|
net: float
|
||||||
|
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]
|
||||||
|
actuals_by_source: list[ActualsBySource]
|
||||||
|
total_projected_income: float
|
||||||
|
total_projected_expenses: float
|
||||||
|
uncovered_actual: float
|
||||||
|
gran_total_egresos: float
|
||||||
|
net_balance: float
|
||||||
|
cc_by_category: list[CCCategorySpending]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
|
||||||
|
def get_monthly_detail(
|
||||||
|
year: int,
|
||||||
|
month: int = Path(ge=1, le=12),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
data = compute_monthly_projection(session, year, month)
|
||||||
|
return MonthlyDetailResponse(
|
||||||
|
year=data["year"],
|
||||||
|
month=data["month"],
|
||||||
|
income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
|
||||||
|
expense_items=[RecurringItemDetail(**i) for i in data["expense_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"],
|
||||||
|
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"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Balance Override CRUD ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/balance-override/{year}/{month}",
|
||||||
|
response_model=BalanceOverrideRead,
|
||||||
|
)
|
||||||
|
def upsert_balance_override(
|
||||||
|
year: int,
|
||||||
|
month: int = Path(ge=1, le=12),
|
||||||
|
data: BalanceOverrideCreate = ...,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if year < MIN_YEAR or year > MAX_YEAR:
|
||||||
|
raise HTTPException(400, f"Year must be between {MIN_YEAR} and {MAX_YEAR}")
|
||||||
|
if year == FRESH_START_YEAR and month < FRESH_START_MONTH:
|
||||||
|
raise HTTPException(400, f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}")
|
||||||
|
|
||||||
|
existing = session.exec(
|
||||||
|
select(BalanceOverride).where(
|
||||||
|
BalanceOverride.year == year, BalanceOverride.month == month
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.override_balance = data.override_balance
|
||||||
|
existing.updated_at = datetime.utcnow()
|
||||||
|
session.add(existing)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
override = BalanceOverride(
|
||||||
|
year=year, month=month, override_balance=data.override_balance
|
||||||
|
)
|
||||||
|
session.add(override)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(override)
|
||||||
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/balance-override/{year}/{month}", status_code=204)
|
||||||
|
def delete_balance_override(
|
||||||
|
year: int,
|
||||||
|
month: int = Path(ge=1, le=12),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
existing = session.exec(
|
||||||
|
select(BalanceOverride).where(
|
||||||
|
BalanceOverride.year == year, BalanceOverride.month == month
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "No override found for this month")
|
||||||
|
session.delete(existing)
|
||||||
|
session.commit()
|
||||||
285
backend/app/api/v1/endpoints/municipal_receipts.py
Normal file
285
backend/app/api/v1/endpoints/municipal_receipts.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, UploadFile
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import (
|
||||||
|
Category,
|
||||||
|
Currency,
|
||||||
|
MunicipalReceipt,
|
||||||
|
MunicipalReceiptRead,
|
||||||
|
Transaction,
|
||||||
|
TransactionSource,
|
||||||
|
TransactionType,
|
||||||
|
WaterMeterReading,
|
||||||
|
WaterMeterReadingRead,
|
||||||
|
)
|
||||||
|
from app.services.municipal_receipt_pdf import extract_municipal_receipt
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/municipal-receipts", tags=["municipal-receipts"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Response models ---
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalReceiptDetailRead(MunicipalReceiptRead):
|
||||||
|
water_readings: list[WaterMeterReadingRead] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalReceiptUploadResult(BaseModel):
|
||||||
|
imported: int
|
||||||
|
updated: int
|
||||||
|
errors: list[str]
|
||||||
|
receipt: Optional[MunicipalReceiptRead] = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_categorize(merchant: str, session: Session) -> Optional[int]:
|
||||||
|
categories = session.exec(select(Category)).all()
|
||||||
|
merchant_lower = merchant.lower()
|
||||||
|
for cat in categories:
|
||||||
|
if cat.auto_match_patterns:
|
||||||
|
patterns = [p.strip().lower() for p in cat.auto_match_patterns.split(",")]
|
||||||
|
if any(p in merchant_lower for p in patterns if p):
|
||||||
|
return cat.id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_receipt(
|
||||||
|
session: Session, data: dict, filename: str
|
||||||
|
) -> tuple[MunicipalReceipt, bool]:
|
||||||
|
"""Insert or update a municipal receipt. Returns (row, is_new)."""
|
||||||
|
r = data["receipt"]
|
||||||
|
totals = data["totals"]
|
||||||
|
receipt_date_str = r["date"]
|
||||||
|
# The receipt is issued in month N but covers month N-1
|
||||||
|
receipt_dt = datetime.strptime(receipt_date_str, "%Y-%m-%d").date()
|
||||||
|
billing_month = receipt_dt - relativedelta(months=1)
|
||||||
|
period = billing_month.strftime("%Y-%m")
|
||||||
|
|
||||||
|
existing = session.exec(
|
||||||
|
select(MunicipalReceipt).where(
|
||||||
|
MunicipalReceipt.account == r["account"],
|
||||||
|
MunicipalReceipt.period == period,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
charges = [
|
||||||
|
{"detail": c["detail"], "amount": c.get("amount", 0)}
|
||||||
|
for c in data.get("charges", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
fields = dict(
|
||||||
|
receipt_date=datetime.strptime(receipt_date_str, "%Y-%m-%d").date(),
|
||||||
|
due_date=datetime.strptime(r["due_date"], "%Y-%m-%d").date(),
|
||||||
|
period=period,
|
||||||
|
account=r["account"],
|
||||||
|
finca=r.get("finca", ""),
|
||||||
|
holder_name=r.get("account_holder", {}).get("name", ""),
|
||||||
|
holder_cedula=r.get("account_holder", {}).get("cedula", ""),
|
||||||
|
holder_address=r.get("account_holder", {}).get("address", ""),
|
||||||
|
subtotal=totals.get("subtotal", 0),
|
||||||
|
interests=totals.get("interests", 0),
|
||||||
|
iva=totals.get("iva", 0),
|
||||||
|
total=totals.get("total", 0),
|
||||||
|
raw_charges=charges,
|
||||||
|
source_filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
for k, v in fields.items():
|
||||||
|
setattr(existing, k, v)
|
||||||
|
session.add(existing)
|
||||||
|
# Delete old water readings for this receipt
|
||||||
|
old_readings = session.exec(
|
||||||
|
select(WaterMeterReading).where(
|
||||||
|
WaterMeterReading.receipt_id == existing.id
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for rd in old_readings:
|
||||||
|
session.delete(rd)
|
||||||
|
session.flush()
|
||||||
|
return existing, False
|
||||||
|
|
||||||
|
row = MunicipalReceipt(**fields)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row, True
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_water_readings(
|
||||||
|
session: Session, receipt: MunicipalReceipt, data: dict
|
||||||
|
) -> None:
|
||||||
|
"""Insert water meter readings (current + historical) for a receipt."""
|
||||||
|
# Current period readings
|
||||||
|
for wm in data.get("water_meters", []):
|
||||||
|
reading = WaterMeterReading(
|
||||||
|
receipt_id=receipt.id,
|
||||||
|
meter_id=str(wm["meter_id"]),
|
||||||
|
period=wm["period"],
|
||||||
|
reading_previous=wm.get("reading_previous", 0),
|
||||||
|
reading_current=wm.get("reading_current", 0),
|
||||||
|
consumption_m3=wm.get("consumption_m3", 0),
|
||||||
|
agua_potable=wm.get("agua_potable", 0),
|
||||||
|
serv_ambientales=wm.get("serv_ambientales", 0),
|
||||||
|
alcant_sanitario=wm.get("alcant_sanitario", 0),
|
||||||
|
iva=wm.get("iva", 0),
|
||||||
|
is_historical=False,
|
||||||
|
)
|
||||||
|
session.add(reading)
|
||||||
|
|
||||||
|
# Historical consumption entries
|
||||||
|
for hc in data.get("historical_consumption", []):
|
||||||
|
period = hc["period"]
|
||||||
|
meter_id = str(hc["meter_id"])
|
||||||
|
# Upsert: check if this historical entry already exists
|
||||||
|
existing = session.exec(
|
||||||
|
select(WaterMeterReading).where(
|
||||||
|
WaterMeterReading.meter_id == meter_id,
|
||||||
|
WaterMeterReading.period == period,
|
||||||
|
WaterMeterReading.is_historical == True, # noqa: E712
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.consumption_m3 = hc.get("consumption_m3", 0)
|
||||||
|
session.add(existing)
|
||||||
|
else:
|
||||||
|
session.add(
|
||||||
|
WaterMeterReading(
|
||||||
|
receipt_id=receipt.id,
|
||||||
|
meter_id=meter_id,
|
||||||
|
period=period,
|
||||||
|
consumption_m3=hc.get("consumption_m3", 0),
|
||||||
|
is_historical=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_transaction(
|
||||||
|
session: Session, receipt: MunicipalReceipt
|
||||||
|
) -> None:
|
||||||
|
"""Create a budget Transaction for this receipt if one doesn't exist."""
|
||||||
|
reference = f"municipal-{receipt.account}-{receipt.period}"
|
||||||
|
existing = session.exec(
|
||||||
|
select(Transaction).where(Transaction.reference == reference)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
# Update amount in case receipt was re-uploaded with corrections
|
||||||
|
existing.amount = receipt.total
|
||||||
|
session.add(existing)
|
||||||
|
return
|
||||||
|
|
||||||
|
category_id = _auto_categorize("municipalidad", session)
|
||||||
|
tx = Transaction(
|
||||||
|
amount=receipt.total,
|
||||||
|
currency=Currency.CRC,
|
||||||
|
merchant="Municipalidad de Belén",
|
||||||
|
date=datetime.combine(receipt.receipt_date, datetime.min.time()),
|
||||||
|
transaction_type=TransactionType.COMPRA,
|
||||||
|
source=TransactionSource.TRANSFER,
|
||||||
|
reference=reference,
|
||||||
|
category_id=category_id,
|
||||||
|
notes=f"Recibo municipal {receipt.period}",
|
||||||
|
)
|
||||||
|
session.add(tx)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Endpoints ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=MunicipalReceiptUploadResult)
|
||||||
|
async def upload_municipal_receipt(
|
||||||
|
file: UploadFile,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
filename = file.filename or "unknown.pdf"
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_bytes = await file.read()
|
||||||
|
data = extract_municipal_receipt(pdf_bytes, filename)
|
||||||
|
except ValueError as e:
|
||||||
|
return MunicipalReceiptUploadResult(imported=0, updated=0, errors=[str(e)])
|
||||||
|
except Exception as e:
|
||||||
|
return MunicipalReceiptUploadResult(
|
||||||
|
imported=0, updated=0, errors=[f"{filename}: {e}"]
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt, is_new = _upsert_receipt(session, data, filename)
|
||||||
|
_insert_water_readings(session, receipt, data)
|
||||||
|
_ensure_transaction(session, receipt)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(receipt)
|
||||||
|
|
||||||
|
return MunicipalReceiptUploadResult(
|
||||||
|
imported=1 if is_new else 0,
|
||||||
|
updated=0 if is_new else 1,
|
||||||
|
errors=errors,
|
||||||
|
receipt=MunicipalReceiptRead.model_validate(receipt),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[MunicipalReceiptRead])
|
||||||
|
def list_receipts(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = session.exec(
|
||||||
|
select(MunicipalReceipt).order_by(
|
||||||
|
MunicipalReceipt.receipt_date.desc() # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/water-consumption", response_model=list[WaterMeterReadingRead])
|
||||||
|
def get_water_consumption(
|
||||||
|
months: int = Query(default=24, ge=1, le=120),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = session.exec(
|
||||||
|
select(WaterMeterReading)
|
||||||
|
.where(WaterMeterReading.is_historical == False) # noqa: E712
|
||||||
|
.order_by(
|
||||||
|
WaterMeterReading.period.asc(), # type: ignore[union-attr]
|
||||||
|
WaterMeterReading.meter_id.asc(), # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
.limit(months * 3) # up to 3 meters per month
|
||||||
|
).all()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{receipt_id}", response_model=MunicipalReceiptDetailRead)
|
||||||
|
def get_receipt_detail(
|
||||||
|
receipt_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
receipt = session.get(MunicipalReceipt, receipt_id)
|
||||||
|
if not receipt:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Receipt not found")
|
||||||
|
|
||||||
|
readings = session.exec(
|
||||||
|
select(WaterMeterReading).where(
|
||||||
|
WaterMeterReading.receipt_id == receipt_id
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return MunicipalReceiptDetailRead(
|
||||||
|
**MunicipalReceiptRead.model_validate(receipt).model_dump(),
|
||||||
|
water_readings=[
|
||||||
|
WaterMeterReadingRead.model_validate(r) for r in readings
|
||||||
|
],
|
||||||
|
)
|
||||||
93
backend/app/api/v1/endpoints/notifications.py
Normal file
93
backend/app/api/v1/endpoints/notifications.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pywebpush import WebPushException, webpush
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import PushSubscription, PushSubscriptionCreate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vapid-public-key")
|
||||||
|
def get_vapid_public_key(_user: str = Depends(get_current_user)):
|
||||||
|
if not settings.VAPID_PUBLIC_KEY:
|
||||||
|
raise HTTPException(status_code=503, detail="Push notifications not configured")
|
||||||
|
return {"publicKey": settings.VAPID_PUBLIC_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe", status_code=201)
|
||||||
|
def subscribe(
|
||||||
|
data: PushSubscriptionCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
existing = session.exec(
|
||||||
|
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.p256dh = data.keys["p256dh"]
|
||||||
|
existing.auth = data.keys["auth"]
|
||||||
|
session.add(existing)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "updated"}
|
||||||
|
|
||||||
|
sub = PushSubscription(
|
||||||
|
endpoint=data.endpoint,
|
||||||
|
p256dh=data.keys["p256dh"],
|
||||||
|
auth=data.keys["auth"],
|
||||||
|
)
|
||||||
|
session.add(sub)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "subscribed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/unsubscribe")
|
||||||
|
def unsubscribe(
|
||||||
|
data: PushSubscriptionCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
existing = session.exec(
|
||||||
|
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
session.delete(existing)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "unsubscribed"}
|
||||||
|
|
||||||
|
|
||||||
|
def send_push_to_all(session: Session, title: str, body: str, url: str = "/"):
|
||||||
|
"""Send a push notification to all registered subscriptions."""
|
||||||
|
if not settings.VAPID_PRIVATE_KEY or not settings.VAPID_PUBLIC_KEY:
|
||||||
|
logger.debug("VAPID keys not configured, skipping push notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
subscriptions = session.exec(select(PushSubscription)).all()
|
||||||
|
payload = json.dumps({"title": title, "body": body, "url": url})
|
||||||
|
|
||||||
|
for sub in subscriptions:
|
||||||
|
subscription_info = {
|
||||||
|
"endpoint": sub.endpoint,
|
||||||
|
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=payload,
|
||||||
|
vapid_private_key=settings.VAPID_PRIVATE_KEY,
|
||||||
|
vapid_claims={"sub": settings.VAPID_CLAIM_EMAIL},
|
||||||
|
)
|
||||||
|
except WebPushException as e:
|
||||||
|
logger.warning("Push failed for %s: %s", sub.endpoint[:50], e)
|
||||||
|
if e.response and e.response.status_code in (404, 410):
|
||||||
|
session.delete(sub)
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected push error for %s", sub.endpoint[:50])
|
||||||
245
backend/app/api/v1/endpoints/pensions.py
Normal file
245
backend/app/api/v1/endpoints/pensions.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import Bank, PensionSnapshot, PensionSnapshotRead
|
||||||
|
from app.services.pension_pdf import parse_pension_pdf
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pensions", tags=["pensions"])
|
||||||
|
|
||||||
|
|
||||||
|
class PensionUploadResult(BaseModel):
|
||||||
|
imported: int
|
||||||
|
updated: int
|
||||||
|
duplicates: int
|
||||||
|
errors: list[str]
|
||||||
|
snapshots: list[PensionSnapshotRead]
|
||||||
|
|
||||||
|
|
||||||
|
class PensionManualEntry(BaseModel):
|
||||||
|
fund: str
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
|
saldo_anterior: float
|
||||||
|
aportes: float
|
||||||
|
rendimientos: float
|
||||||
|
retiros: float
|
||||||
|
traslados: float
|
||||||
|
comision: float
|
||||||
|
correccion: float = 0.0
|
||||||
|
bonificacion: float = 0.0
|
||||||
|
saldo_final: float
|
||||||
|
|
||||||
|
|
||||||
|
class PensionManualRequest(BaseModel):
|
||||||
|
entries: list[PensionManualEntry]
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_snapshot(
|
||||||
|
session: Session,
|
||||||
|
fund: str,
|
||||||
|
period_start: date,
|
||||||
|
period_end: date,
|
||||||
|
saldo_anterior: float,
|
||||||
|
aportes: float,
|
||||||
|
rendimientos: float,
|
||||||
|
retiros: float,
|
||||||
|
traslados: float,
|
||||||
|
comision: float,
|
||||||
|
correccion: float,
|
||||||
|
bonificacion: float,
|
||||||
|
saldo_final: float,
|
||||||
|
source_filename: str,
|
||||||
|
contract_number: str = "",
|
||||||
|
) -> tuple[PensionSnapshot, bool]:
|
||||||
|
"""Insert or update a pension snapshot. Returns (row, is_new)."""
|
||||||
|
existing = session.exec(
|
||||||
|
select(PensionSnapshot).where(
|
||||||
|
PensionSnapshot.fund == Bank(fund),
|
||||||
|
PensionSnapshot.period_start == period_start,
|
||||||
|
PensionSnapshot.period_end == period_end,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.saldo_anterior = saldo_anterior
|
||||||
|
existing.aportes = aportes
|
||||||
|
existing.rendimientos = rendimientos
|
||||||
|
existing.retiros = retiros
|
||||||
|
existing.traslados = traslados
|
||||||
|
existing.comision = comision
|
||||||
|
existing.correccion = correccion
|
||||||
|
existing.bonificacion = bonificacion
|
||||||
|
existing.saldo_final = saldo_final
|
||||||
|
existing.source_filename = source_filename
|
||||||
|
if contract_number:
|
||||||
|
existing.contract_number = contract_number
|
||||||
|
session.add(existing)
|
||||||
|
return existing, False
|
||||||
|
|
||||||
|
row = PensionSnapshot(
|
||||||
|
fund=Bank(fund),
|
||||||
|
contract_number=contract_number,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
saldo_anterior=saldo_anterior,
|
||||||
|
aportes=aportes,
|
||||||
|
rendimientos=rendimientos,
|
||||||
|
retiros=retiros,
|
||||||
|
traslados=traslados,
|
||||||
|
comision=comision,
|
||||||
|
correccion=correccion,
|
||||||
|
bonificacion=bonificacion,
|
||||||
|
saldo_final=saldo_final,
|
||||||
|
source_filename=source_filename,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
return row, True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=PensionUploadResult)
|
||||||
|
async def upload_pension_pdfs(
|
||||||
|
files: list[UploadFile],
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
imported = 0
|
||||||
|
updated = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
results: list[PensionSnapshot] = []
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
filename = file.filename or "unknown.pdf"
|
||||||
|
try:
|
||||||
|
pdf_bytes = await file.read()
|
||||||
|
fund_snapshots = parse_pension_pdf(pdf_bytes, filename)
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{filename}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for snap in fund_snapshots:
|
||||||
|
row, is_new = _upsert_snapshot(
|
||||||
|
session,
|
||||||
|
fund=snap.fund,
|
||||||
|
period_start=snap.period_start,
|
||||||
|
period_end=snap.period_end,
|
||||||
|
saldo_anterior=snap.saldo_anterior,
|
||||||
|
aportes=snap.aportes,
|
||||||
|
rendimientos=snap.rendimientos,
|
||||||
|
retiros=snap.retiros,
|
||||||
|
traslados=snap.traslados,
|
||||||
|
comision=snap.comision,
|
||||||
|
correccion=snap.correccion,
|
||||||
|
bonificacion=snap.bonificacion,
|
||||||
|
saldo_final=snap.saldo_final,
|
||||||
|
source_filename=filename,
|
||||||
|
contract_number=snap.contract_number,
|
||||||
|
)
|
||||||
|
results.append(row)
|
||||||
|
if is_new:
|
||||||
|
imported += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if imported > 0 or updated > 0:
|
||||||
|
session.commit()
|
||||||
|
for row in results:
|
||||||
|
session.refresh(row)
|
||||||
|
|
||||||
|
return PensionUploadResult(
|
||||||
|
imported=imported,
|
||||||
|
updated=updated,
|
||||||
|
duplicates=0,
|
||||||
|
errors=errors,
|
||||||
|
snapshots=[PensionSnapshotRead.model_validate(r) for r in results],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/manual", response_model=PensionUploadResult)
|
||||||
|
def submit_manual_entries(
|
||||||
|
body: PensionManualRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
imported = 0
|
||||||
|
updated = 0
|
||||||
|
results: list[PensionSnapshot] = []
|
||||||
|
|
||||||
|
for entry in body.entries:
|
||||||
|
row, is_new = _upsert_snapshot(
|
||||||
|
session,
|
||||||
|
fund=entry.fund,
|
||||||
|
period_start=entry.period_start,
|
||||||
|
period_end=entry.period_end,
|
||||||
|
saldo_anterior=entry.saldo_anterior,
|
||||||
|
aportes=entry.aportes,
|
||||||
|
rendimientos=entry.rendimientos,
|
||||||
|
retiros=entry.retiros,
|
||||||
|
traslados=entry.traslados,
|
||||||
|
comision=entry.comision,
|
||||||
|
correccion=entry.correccion,
|
||||||
|
bonificacion=entry.bonificacion,
|
||||||
|
saldo_final=entry.saldo_final,
|
||||||
|
source_filename="manual-entry",
|
||||||
|
)
|
||||||
|
results.append(row)
|
||||||
|
if is_new:
|
||||||
|
imported += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if imported > 0 or updated > 0:
|
||||||
|
session.commit()
|
||||||
|
for row in results:
|
||||||
|
session.refresh(row)
|
||||||
|
|
||||||
|
return PensionUploadResult(
|
||||||
|
imported=imported,
|
||||||
|
updated=updated,
|
||||||
|
duplicates=0,
|
||||||
|
errors=[],
|
||||||
|
snapshots=[PensionSnapshotRead.model_validate(r) for r in results],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/snapshots", response_model=list[PensionSnapshotRead])
|
||||||
|
def get_snapshots(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = session.exec(
|
||||||
|
select(PensionSnapshot).order_by(
|
||||||
|
PensionSnapshot.period_end.desc(), # type: ignore[union-attr]
|
||||||
|
PensionSnapshot.fund,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fund-summary", response_model=list[PensionSnapshotRead])
|
||||||
|
def get_fund_summary(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return the latest snapshot per fund (by most recent period_end)."""
|
||||||
|
all_rows = session.exec(
|
||||||
|
select(PensionSnapshot).order_by(
|
||||||
|
PensionSnapshot.period_end.desc(), # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
latest: list[PensionSnapshot] = []
|
||||||
|
for row in all_rows:
|
||||||
|
if row.fund.value not in seen:
|
||||||
|
seen.add(row.fund.value)
|
||||||
|
latest.append(row)
|
||||||
|
|
||||||
|
return latest
|
||||||
58
backend/app/api/v1/endpoints/salarios.py
Normal file
58
backend/app/api/v1/endpoints/salarios.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
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
|
||||||
|
total_amount: float
|
||||||
|
latest_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[TransactionRead])
|
||||||
|
def list_salarios(
|
||||||
|
limit: int = Query(default=50, le=500),
|
||||||
|
offset: int = 0,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
query = (
|
||||||
|
select(Transaction)
|
||||||
|
.where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
|
||||||
|
.order_by(col(Transaction.date).desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return session.exec(query).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=SalariosSummary)
|
||||||
|
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(amount_crc), 0),
|
||||||
|
func.max(Transaction.date),
|
||||||
|
).where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
|
||||||
|
).first()
|
||||||
|
return SalariosSummary(
|
||||||
|
count=result[0] if result else 0,
|
||||||
|
total_amount=float(result[1]) if result else 0.0,
|
||||||
|
latest_date=result[2] if result else None,
|
||||||
|
)
|
||||||
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()
|
||||||
@@ -7,28 +7,24 @@ from sqlmodel import Session, col, func, select
|
|||||||
|
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.api.v1.endpoints.notifications import send_push_to_all
|
||||||
from app.models.models import (
|
from app.models.models import (
|
||||||
Category,
|
Category,
|
||||||
|
Currency,
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionCreate,
|
TransactionCreate,
|
||||||
TransactionRead,
|
TransactionRead,
|
||||||
TransactionSource,
|
TransactionSource,
|
||||||
|
TransactionType,
|
||||||
TransactionUpdate,
|
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"])
|
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):
|
class BillingCycle(BaseModel):
|
||||||
year: int
|
year: int
|
||||||
month: int
|
month: int
|
||||||
@@ -51,10 +47,13 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
|
|||||||
@router.get("/", response_model=list[TransactionRead])
|
@router.get("/", response_model=list[TransactionRead])
|
||||||
def list_transactions(
|
def list_transactions(
|
||||||
source: Optional[TransactionSource] = None,
|
source: Optional[TransactionSource] = None,
|
||||||
|
exclude_source: Optional[TransactionSource] = None,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
category_id: Optional[int] = None,
|
category_id: Optional[int] = None,
|
||||||
cycle_year: Optional[int] = None,
|
cycle_year: Optional[int] = None,
|
||||||
cycle_month: Optional[int] = None,
|
cycle_month: Optional[int] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
limit: int = Query(default=50, le=500),
|
limit: int = Query(default=50, le=500),
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -63,13 +62,37 @@ def list_transactions(
|
|||||||
query = select(Transaction)
|
query = select(Transaction)
|
||||||
if source:
|
if source:
|
||||||
query = query.where(Transaction.source == source)
|
query = query.where(Transaction.source == source)
|
||||||
|
if exclude_source:
|
||||||
|
query = query.where(Transaction.source != exclude_source)
|
||||||
if category_id:
|
if category_id:
|
||||||
query = query.where(Transaction.category_id == category_id)
|
query = query.where(Transaction.category_id == category_id)
|
||||||
if search:
|
if search:
|
||||||
query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
|
query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
|
||||||
if cycle_year and cycle_month:
|
if cycle_year and cycle_month:
|
||||||
start, end = get_cycle_range(cycle_year, 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),
|
||||||
|
Transaction.date < datetime.fromisoformat(end_date),
|
||||||
|
)
|
||||||
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
|
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
|
||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
@@ -88,6 +111,7 @@ def list_billing_cycles(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
min_date, max_date = result
|
min_date, max_date = result
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
cycles = []
|
cycles = []
|
||||||
# Determine which cycle the min_date falls into
|
# Determine which cycle the min_date falls into
|
||||||
@@ -107,7 +131,7 @@ def list_billing_cycles(
|
|||||||
|
|
||||||
# Count transactions in this cycle
|
# Count transactions in this cycle
|
||||||
count_result = session.exec(
|
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
|
Transaction.date >= start, Transaction.date < end
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
@@ -170,6 +194,25 @@ def create_transaction(
|
|||||||
session.add(tx)
|
session.add(tx)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(tx)
|
session.refresh(tx)
|
||||||
|
|
||||||
|
# Send push notification
|
||||||
|
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_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_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
|
return tx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ from app.api.v1.endpoints import (
|
|||||||
accounts,
|
accounts,
|
||||||
analytics,
|
analytics,
|
||||||
auth,
|
auth,
|
||||||
|
budget,
|
||||||
categories,
|
categories,
|
||||||
exchange_rate,
|
exchange_rate,
|
||||||
import_transactions,
|
import_transactions,
|
||||||
|
municipal_receipts,
|
||||||
|
notifications,
|
||||||
|
pensions,
|
||||||
|
salarios,
|
||||||
|
savings_accrual,
|
||||||
settings,
|
settings,
|
||||||
tokens,
|
tokens,
|
||||||
transactions,
|
transactions,
|
||||||
@@ -22,3 +28,9 @@ api_router.include_router(exchange_rate.router)
|
|||||||
api_router.include_router(tokens.router)
|
api_router.include_router(tokens.router)
|
||||||
api_router.include_router(analytics.router)
|
api_router.include_router(analytics.router)
|
||||||
api_router.include_router(settings.router)
|
api_router.include_router(settings.router)
|
||||||
|
api_router.include_router(budget.router)
|
||||||
|
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 hashlib
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
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 fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -22,8 +24,8 @@ def hash_token(token: str) -> str:
|
|||||||
return hashlib.sha256(token.encode()).hexdigest()
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
def _validate_token(token: str) -> str:
|
||||||
# Try JWT first
|
"""Validate JWT and return subject, or raise 401."""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
@@ -51,3 +53,26 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
|||||||
return f"api:{api_token.name}"
|
return f"api:{api_token.name}"
|
||||||
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
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)
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ class Settings(BaseSettings):
|
|||||||
ADMIN_PASSWORD: str = "admin"
|
ADMIN_PASSWORD: str = "admin"
|
||||||
BCCR_API_EMAIL: str = ""
|
BCCR_API_EMAIL: str = ""
|
||||||
BCCR_API_TOKEN: str = ""
|
BCCR_API_TOKEN: str = ""
|
||||||
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from sqlalchemy import text
|
||||||
from sqlmodel import SQLModel, Session, create_engine
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -9,6 +10,72 @@ def init_db():
|
|||||||
SQLModel.metadata.create_all(engine)
|
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():
|
def get_session():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
@@ -1,19 +1,77 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
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 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.api.v1.router import api_router
|
||||||
|
from app.auth import ALGORITHM, create_access_token
|
||||||
from app.config import settings
|
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.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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
|
run_migrations()
|
||||||
seed_db()
|
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)
|
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
||||||
@@ -26,9 +84,106 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
# Mount the AG-UI agent endpoint.
|
||||||
|
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return {"app": "WealthySmart", "version": "0.1.0"}
|
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}
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import enum
|
import enum
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Column
|
from sqlalchemy import JSON, Column, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemType(str, enum.Enum):
|
||||||
|
INCOME = "INCOME"
|
||||||
|
EXPENSE = "EXPENSE"
|
||||||
|
SAVINGS = "SAVINGS"
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringFrequency(str, enum.Enum):
|
||||||
|
WEEKLY = "WEEKLY"
|
||||||
|
MONTHLY = "MONTHLY"
|
||||||
|
QUARTERLY = "QUARTERLY"
|
||||||
|
BIANNUAL = "BIANNUAL"
|
||||||
|
YEARLY = "YEARLY"
|
||||||
|
|
||||||
|
|
||||||
class TransactionType(str, enum.Enum):
|
class TransactionType(str, enum.Enum):
|
||||||
COMPRA = "COMPRA"
|
COMPRA = "COMPRA"
|
||||||
DEVOLUCION = "DEVOLUCION"
|
DEVOLUCION = "DEVOLUCION"
|
||||||
|
DEPOSITO = "DEPOSITO"
|
||||||
|
SALARY = "SALARY"
|
||||||
|
|
||||||
|
|
||||||
class TransactionSource(str, enum.Enum):
|
class TransactionSource(str, enum.Enum):
|
||||||
@@ -21,6 +36,7 @@ class TransactionSource(str, enum.Enum):
|
|||||||
class Currency(str, enum.Enum):
|
class Currency(str, enum.Enum):
|
||||||
CRC = "CRC"
|
CRC = "CRC"
|
||||||
USD = "USD"
|
USD = "USD"
|
||||||
|
EUR = "EUR"
|
||||||
BTC = "BTC"
|
BTC = "BTC"
|
||||||
XMR = "XMR"
|
XMR = "XMR"
|
||||||
|
|
||||||
@@ -126,6 +142,7 @@ class TransactionBase(SQLModel):
|
|||||||
bank: Bank = Bank.BAC
|
bank: Bank = Bank.BAC
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
||||||
|
deferred_to_next_cycle: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
class Transaction(TransactionBase, table=True):
|
class Transaction(TransactionBase, table=True):
|
||||||
@@ -154,6 +171,7 @@ class TransactionUpdate(SQLModel):
|
|||||||
source: Optional[TransactionSource] = None
|
source: Optional[TransactionSource] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
|
deferred_to_next_cycle: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
# --- Exchange Rate ---
|
# --- Exchange Rate ---
|
||||||
@@ -207,7 +225,7 @@ class UserSettings(SQLModel, table=True):
|
|||||||
key: str = Field(index=True, unique=True, default="default")
|
key: str = Field(index=True, unique=True, default="default")
|
||||||
data: dict = Field(
|
data: dict = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
sa_column=Column(JSONB, nullable=False, server_default="{}"),
|
sa_column=Column(JSON, nullable=False, server_default="{}"),
|
||||||
)
|
)
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
@@ -220,3 +238,232 @@ class UserSettingsRead(SQLModel):
|
|||||||
|
|
||||||
class UserSettingsUpdate(SQLModel):
|
class UserSettingsUpdate(SQLModel):
|
||||||
data: dict
|
data: dict
|
||||||
|
|
||||||
|
|
||||||
|
# --- Recurring Item ---
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemBase(SQLModel):
|
||||||
|
name: str
|
||||||
|
amount: float
|
||||||
|
currency: Currency = Currency.CRC
|
||||||
|
item_type: RecurringItemType
|
||||||
|
frequency: RecurringFrequency = RecurringFrequency.MONTHLY
|
||||||
|
day_of_month: Optional[int] = None
|
||||||
|
month_of_year: Optional[int] = None
|
||||||
|
override_amounts: Optional[dict] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(JSON, nullable=True),
|
||||||
|
)
|
||||||
|
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
||||||
|
is_active: bool = True
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItem(RecurringItemBase, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
category: Optional[Category] = Relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemCreate(RecurringItemBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemRead(RecurringItemBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
category: Optional[CategoryRead] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemUpdate(SQLModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
amount: Optional[float] = None
|
||||||
|
currency: Optional[Currency] = None
|
||||||
|
item_type: Optional[RecurringItemType] = None
|
||||||
|
frequency: Optional[RecurringFrequency] = None
|
||||||
|
day_of_month: Optional[int] = None
|
||||||
|
month_of_year: Optional[int] = None
|
||||||
|
override_amounts: Optional[dict] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Push Subscription ---
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscription(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
endpoint: str = Field(unique=True)
|
||||||
|
p256dh: str
|
||||||
|
auth: str
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionCreate(SQLModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: dict # {"p256dh": "...", "auth": "..."}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pension Snapshot ---
|
||||||
|
|
||||||
|
|
||||||
|
class PensionSnapshotBase(SQLModel):
|
||||||
|
fund: Bank
|
||||||
|
contract_number: str
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
|
saldo_anterior: float
|
||||||
|
aportes: float
|
||||||
|
rendimientos: float
|
||||||
|
retiros: float
|
||||||
|
traslados: float
|
||||||
|
comision: float
|
||||||
|
correccion: float
|
||||||
|
bonificacion: float
|
||||||
|
saldo_final: float
|
||||||
|
source_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class PensionSnapshot(PensionSnapshotBase, table=True):
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("fund", "period_start", "period_end"),
|
||||||
|
)
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PensionSnapshotRead(PensionSnapshotBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Balance Override ---
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceOverride(SQLModel, table=True):
|
||||||
|
__table_args__ = (UniqueConstraint("year", "month"),)
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
override_balance: float
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceOverrideCreate(SQLModel):
|
||||||
|
override_balance: float
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceOverrideRead(SQLModel):
|
||||||
|
id: int
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
override_balance: float
|
||||||
|
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 ---
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalReceiptBase(SQLModel):
|
||||||
|
receipt_date: date
|
||||||
|
due_date: date
|
||||||
|
period: str # "YYYY-MM"
|
||||||
|
account: str
|
||||||
|
finca: str
|
||||||
|
holder_name: str
|
||||||
|
holder_cedula: str
|
||||||
|
holder_address: str
|
||||||
|
subtotal: float
|
||||||
|
interests: float
|
||||||
|
iva: float
|
||||||
|
total: float
|
||||||
|
raw_charges: list[dict] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
sa_column=Column(JSON, nullable=False, server_default="[]"),
|
||||||
|
)
|
||||||
|
source_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalReceipt(MunicipalReceiptBase, table=True):
|
||||||
|
__table_args__ = (UniqueConstraint("account", "period"),)
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
water_readings: list["WaterMeterReading"] = Relationship(
|
||||||
|
back_populates="receipt",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalReceiptCreate(MunicipalReceiptBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MunicipalReceiptRead(MunicipalReceiptBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Water Meter Reading ---
|
||||||
|
|
||||||
|
|
||||||
|
class WaterMeterReadingBase(SQLModel):
|
||||||
|
meter_id: str
|
||||||
|
period: str # "YYYY-MM"
|
||||||
|
reading_previous: float = 0
|
||||||
|
reading_current: float = 0
|
||||||
|
consumption_m3: float
|
||||||
|
agua_potable: float = 0
|
||||||
|
serv_ambientales: float = 0
|
||||||
|
alcant_sanitario: float = 0
|
||||||
|
iva: float = 0
|
||||||
|
is_historical: bool = False
|
||||||
|
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
|
||||||
|
|
||||||
|
|
||||||
|
class WaterMeterReading(WaterMeterReadingBase, table=True):
|
||||||
|
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
receipt: Optional[MunicipalReceipt] = Relationship(
|
||||||
|
back_populates="water_readings",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterMeterReadingRead(WaterMeterReadingBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.db import engine
|
from app.db import engine
|
||||||
from app.models.models import Account, AccountType, Bank, Category, Currency
|
from app.models.models import (
|
||||||
|
Account,
|
||||||
|
AccountType,
|
||||||
|
Bank,
|
||||||
|
Category,
|
||||||
|
Currency,
|
||||||
|
RecurringFrequency,
|
||||||
|
RecurringItem,
|
||||||
|
RecurringItemType,
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_CATEGORIES = [
|
DEFAULT_CATEGORIES = [
|
||||||
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"),
|
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"),
|
||||||
("Food & Delivery", "utensils", "uber eats,rappi,mcdonalds,subway,pizza,restaurant,soda,cafe,coyote ugly,el rodeo,steak house"),
|
("Food & Delivery", "utensils", "uber eats,rappi,mcdonalds,subway,pizza,restaurant,soda,cafe,coyote ugly,el rodeo,steak house"),
|
||||||
("Utilities", "zap", "c.n.f.l,cnfl,ice,aya,claro cr telecomunicaciones"),
|
("Utilities", "zap", "c.n.f.l,cnfl,ice,aya,claro cr telecomunicaciones"),
|
||||||
("Transportation", "car", "gasolina,gasolinera,uber rides,didi,parqueo,parking,peaje,estacion de servicio,estac.de serv"),
|
("Transportation", "car", "gasolina,gasolinera,uber rides,didi,parqueo,parking,peaje,estacion de servicio,estac.de serv"),
|
||||||
("Shopping", "shopping-bag", "amazon,ebay,ticotek,construplaza,epa,novex,novedades chayfer,total imports,tiendalaliga,gnc live well"),
|
("Shopping", "shopping-bag", "amazon,ebay,construplaza,epa,novex,novedades chayfer,total imports,tiendalaliga,gnc live well"),
|
||||||
("Entertainment", "film", "netflix,disney,cine,steam,playstation,blizzard,diablo"),
|
("Entertainment", "film", "netflix,disney,cine,steam,playstation,blizzard,diablo"),
|
||||||
("Health", "heart-pulse", "farmacia,hospital,clinica,laboratorio,optica,medicina regenerativa,neumi,doer fitness,kettlebell,lacrosse"),
|
("Health", "heart-pulse", "farmacia,hospital,clinica,laboratorio,optica,medicina regenerativa,neumi,doer fitness,kettlebell,lacrosse"),
|
||||||
("Education", "graduation-cap", "universidad,udemy,coursera,libro"),
|
("Education", "graduation-cap", "universidad,udemy,coursera,libro"),
|
||||||
@@ -18,6 +27,7 @@ DEFAULT_CATEGORIES = [
|
|||||||
("Telecom", "phone", "liberty,tigo,kolbi"),
|
("Telecom", "phone", "liberty,tigo,kolbi"),
|
||||||
("Parking & Fees", "circle-parking", "centro comercial curridabat,debito compass,cobro administr,compass"),
|
("Parking & Fees", "circle-parking", "centro comercial curridabat,debito compass,cobro administr,compass"),
|
||||||
("Auto", "car-front", "auto lavado,lavado"),
|
("Auto", "car-front", "auto lavado,lavado"),
|
||||||
|
("Electronics", "cpu", "extremetechcr,extreme tech,ticotek,ishop,gollo,radioshack"),
|
||||||
("Lab & Medical", "microscope", "laboratorio echandi"),
|
("Lab & Medical", "microscope", "laboratorio echandi"),
|
||||||
("Other", "tag", ""),
|
("Other", "tag", ""),
|
||||||
]
|
]
|
||||||
@@ -34,9 +44,6 @@ DEFAULT_ACCOUNTS = [
|
|||||||
(Bank.FCL, Currency.CRC, "FCL", AccountType.PENSION),
|
(Bank.FCL, Currency.CRC, "FCL", AccountType.PENSION),
|
||||||
(Bank.ROP, Currency.CRC, "ROP", AccountType.PENSION),
|
(Bank.ROP, Currency.CRC, "ROP", AccountType.PENSION),
|
||||||
(Bank.VOL, Currency.CRC, "VOL", AccountType.PENSION),
|
(Bank.VOL, Currency.CRC, "VOL", AccountType.PENSION),
|
||||||
# Savings (CRC)
|
|
||||||
(Bank.MEMP, Currency.CRC, "MEMP", AccountType.SAVINGS),
|
|
||||||
(Bank.MPAT, Currency.CRC, "MPAT", AccountType.SAVINGS),
|
|
||||||
# Liabilities
|
# Liabilities
|
||||||
(Bank.MORTGAGE, Currency.USD, "Mortgage", AccountType.LIABILITY),
|
(Bank.MORTGAGE, Currency.USD, "Mortgage", AccountType.LIABILITY),
|
||||||
# Crypto
|
# Crypto
|
||||||
@@ -45,6 +52,128 @@ DEFAULT_ACCOUNTS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RECURRING_ITEMS = [
|
||||||
|
# Incomes
|
||||||
|
{
|
||||||
|
"name": "Alquiler Apt 1",
|
||||||
|
"amount": 320000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"day_of_month": 1,
|
||||||
|
"notes": "Tenant rent - start of month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alquiler Apt 2",
|
||||||
|
"amount": 360000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"day_of_month": 15,
|
||||||
|
"notes": "Tenant rent - mid month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Salario Quincenal 1",
|
||||||
|
"amount": 1400000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"day_of_month": 15,
|
||||||
|
"notes": "Net salary - mid month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Salario Quincenal 2",
|
||||||
|
"amount": 1400000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"day_of_month": 30,
|
||||||
|
"notes": "Net salary - end of month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aguinaldo",
|
||||||
|
"amount": 3000000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.YEARLY,
|
||||||
|
"month_of_year": 12,
|
||||||
|
"notes": "Yearly bonus",
|
||||||
|
},
|
||||||
|
# Fixed expenses
|
||||||
|
{
|
||||||
|
"name": "Hipoteca",
|
||||||
|
"amount": 450000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Mortgage payment estimate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Comida y Gasolina",
|
||||||
|
"amount": 300000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Food & Gas estimate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CNFL",
|
||||||
|
"amount": 50000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Electricity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Internet",
|
||||||
|
"amount": 50000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Internet service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Municipalidad",
|
||||||
|
"amount": 30000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"override_amounts": {"3": 150000, "6": 150000, "9": 150000, "12": 150000},
|
||||||
|
"notes": "Local gov fees; 150k in property tax quarters",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tennis y Limpieza",
|
||||||
|
"amount": 150000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Tennis lessons + house cleaning",
|
||||||
|
},
|
||||||
|
# Cash transfers
|
||||||
|
{
|
||||||
|
"name": "Empleada Doméstica",
|
||||||
|
"amount": 20000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.WEEKLY,
|
||||||
|
"day_of_month": 0,
|
||||||
|
"notes": "Weekly maid payment (~80k/month)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Clases de Tennis",
|
||||||
|
"amount": 50000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Monthly tennis lessons cash transfer",
|
||||||
|
},
|
||||||
|
# Sporadic
|
||||||
|
{
|
||||||
|
"name": "CCE (Country Club)",
|
||||||
|
"amount": 720000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.YEARLY,
|
||||||
|
"month_of_year": 2,
|
||||||
|
"notes": "Yearly country club fee",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Seguro Vehicular",
|
||||||
|
"amount": 150000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.BIANNUAL,
|
||||||
|
"month_of_year": 1,
|
||||||
|
"notes": "Car insurance every 6 months (Jan, Jul)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def seed_db():
|
def seed_db():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
existing = session.exec(select(Category)).first()
|
existing = session.exec(select(Category)).first()
|
||||||
@@ -58,3 +187,9 @@ def seed_db():
|
|||||||
for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
|
for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
|
||||||
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
|
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
existing_recurring = session.exec(select(RecurringItem)).first()
|
||||||
|
if not existing_recurring:
|
||||||
|
for item_data in DEFAULT_RECURRING_ITEMS:
|
||||||
|
session.add(RecurringItem(**item_data))
|
||||||
|
session.commit()
|
||||||
|
|||||||
615
backend/app/services/budget_projection.py
Normal file
615
backend/app/services/budget_projection.py
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import calendar
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlmodel import Session, col, func, select
|
||||||
|
|
||||||
|
from app.models.models import (
|
||||||
|
BalanceOverride,
|
||||||
|
RecurringFrequency,
|
||||||
|
RecurringItem,
|
||||||
|
RecurringItemType,
|
||||||
|
Transaction,
|
||||||
|
TransactionSource,
|
||||||
|
TransactionType,
|
||||||
|
)
|
||||||
|
from app.services.exchange_rate import get_converted_amount_expr
|
||||||
|
|
||||||
|
MIN_YEAR = 2026
|
||||||
|
MAX_YEAR = 2030
|
||||||
|
# Fresh start: months before this are zeroed out
|
||||||
|
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."""
|
||||||
|
freq = item.frequency
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.MONTHLY:
|
||||||
|
if item.override_amounts and str(month) in item.override_amounts:
|
||||||
|
return float(item.override_amounts[str(month)])
|
||||||
|
return item.amount
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.WEEKLY:
|
||||||
|
# Count occurrences of the weekday in this month
|
||||||
|
# day_of_month stores day-of-week: 0=Monday
|
||||||
|
weekday = item.day_of_month if item.day_of_month is not None else 0
|
||||||
|
cal = calendar.monthcalendar(year, month)
|
||||||
|
count = sum(1 for week in cal if week[weekday] != 0)
|
||||||
|
return item.amount * count
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.QUARTERLY:
|
||||||
|
# Active in months 3, 6, 9, 12 by default
|
||||||
|
if month % 3 == 0:
|
||||||
|
if item.override_amounts and str(month) in item.override_amounts:
|
||||||
|
return float(item.override_amounts[str(month)])
|
||||||
|
return item.amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.BIANNUAL:
|
||||||
|
# Active in month_of_year and 6 months later
|
||||||
|
base = item.month_of_year or 1
|
||||||
|
second = base + 6 if base <= 6 else base - 6
|
||||||
|
if month in (base, second):
|
||||||
|
return item.amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.YEARLY:
|
||||||
|
if month == (item.month_of_year or 12):
|
||||||
|
return item.amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||||
|
"""Return (start, end) for a calendar month."""
|
||||||
|
start = datetime(year, month, 1)
|
||||||
|
if month == 12:
|
||||||
|
end = datetime(year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
end = datetime(year, month + 1, 1)
|
||||||
|
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 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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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] = {}
|
||||||
|
|
||||||
|
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
|
||||||
|
RecurringItem.item_type != RecurringItemType.SAVINGS,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
actuals_by_source = compute_actuals_by_source(session, year, month)
|
||||||
|
actuals_by_category = compute_actuals_by_category(session, year, month)
|
||||||
|
|
||||||
|
income_items = []
|
||||||
|
expense_items = []
|
||||||
|
|
||||||
|
total_income = 0.0
|
||||||
|
total_fixed_expenses = 0.0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
effective = get_effective_amount(item, month, year)
|
||||||
|
if effective is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
detail = {
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.name,
|
||||||
|
"amount": effective,
|
||||||
|
"item_type": item.item_type.value,
|
||||||
|
"frequency": item.frequency.value,
|
||||||
|
"category_name": item.category.name if item.category else None,
|
||||||
|
"category_id": item.category_id,
|
||||||
|
"used_actual": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.item_type == RecurringItemType.INCOME:
|
||||||
|
income_items.append(detail)
|
||||||
|
total_income += effective
|
||||||
|
|
||||||
|
elif item.item_type == RecurringItemType.EXPENSE:
|
||||||
|
# No-double-count: if category has actuals, use actual instead
|
||||||
|
if item.category_id and item.category_id in actuals_by_category:
|
||||||
|
actual_amount = actuals_by_category[item.category_id]
|
||||||
|
detail["amount"] = actual_amount
|
||||||
|
detail["projected_amount"] = effective
|
||||||
|
detail["used_actual"] = True
|
||||||
|
total_fixed_expenses += actual_amount
|
||||||
|
else:
|
||||||
|
total_fixed_expenses += effective
|
||||||
|
expense_items.append(detail)
|
||||||
|
|
||||||
|
# Sum actuals from sources for categories NOT covered by recurring items
|
||||||
|
covered_category_ids = {
|
||||||
|
item.category_id
|
||||||
|
for item in items
|
||||||
|
if item.item_type == RecurringItemType.EXPENSE
|
||||||
|
and item.category_id is not None
|
||||||
|
and get_effective_amount(item, month, year) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
uncovered_actual = 0.0
|
||||||
|
for cat_id, amount in actuals_by_category.items():
|
||||||
|
if cat_id not in covered_category_ids:
|
||||||
|
uncovered_actual += amount
|
||||||
|
|
||||||
|
# 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
|
||||||
|
net_balance = total_income - gran_total
|
||||||
|
|
||||||
|
return {
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"projected_income": total_income,
|
||||||
|
"projected_fixed_expenses": total_fixed_expenses,
|
||||||
|
"actual_credit_card": actual_credit_card,
|
||||||
|
"actual_cash": actual_cash,
|
||||||
|
"actual_transfers": actual_transfers,
|
||||||
|
"uncovered_actual": uncovered_actual,
|
||||||
|
"gran_total_egresos": gran_total,
|
||||||
|
"net_balance": net_balance,
|
||||||
|
"income_items": income_items,
|
||||||
|
"expense_items": expense_items,
|
||||||
|
"actuals_by_source": list(actuals_by_source.values()),
|
||||||
|
"cc_by_category": cc_by_category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_december_cumulative(session: Session, year: int) -> float:
|
||||||
|
"""Get the cumulative balance for December of a given year."""
|
||||||
|
# Check for an override first
|
||||||
|
override = session.exec(
|
||||||
|
select(BalanceOverride).where(
|
||||||
|
BalanceOverride.year == year, BalanceOverride.month == 12
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if override:
|
||||||
|
return override.override_balance
|
||||||
|
|
||||||
|
# Compute the full year to get December's cumulative
|
||||||
|
overrides = session.exec(
|
||||||
|
select(BalanceOverride).where(BalanceOverride.year == year)
|
||||||
|
).all()
|
||||||
|
override_map = {o.month: o.override_balance for o in overrides}
|
||||||
|
|
||||||
|
cumulative = 0.0
|
||||||
|
if year > FRESH_START_YEAR:
|
||||||
|
cumulative = _get_december_cumulative(session, year - 1)
|
||||||
|
|
||||||
|
for m in range(1, 13):
|
||||||
|
if year == FRESH_START_YEAR and m < FRESH_START_MONTH:
|
||||||
|
continue
|
||||||
|
data = compute_monthly_projection(session, year, m)
|
||||||
|
cumulative += data["net_balance"]
|
||||||
|
if m in override_map:
|
||||||
|
cumulative = override_map[m]
|
||||||
|
|
||||||
|
return cumulative
|
||||||
|
|
||||||
|
|
||||||
|
def compute_yearly_projection_with_cumulative(
|
||||||
|
session: Session, year: int
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Compute all 12 months with cumulative balance tracking."""
|
||||||
|
overrides = session.exec(
|
||||||
|
select(BalanceOverride).where(BalanceOverride.year == year)
|
||||||
|
).all()
|
||||||
|
override_map = {o.month: o.override_balance for o in overrides}
|
||||||
|
|
||||||
|
# Determine January carryover
|
||||||
|
if year <= FRESH_START_YEAR:
|
||||||
|
carryover = 0.0
|
||||||
|
else:
|
||||||
|
carryover = _get_december_cumulative(session, year - 1)
|
||||||
|
|
||||||
|
months = []
|
||||||
|
for m in range(1, 13):
|
||||||
|
data = compute_monthly_projection(session, year, m)
|
||||||
|
|
||||||
|
is_before_fresh_start = (
|
||||||
|
year == FRESH_START_YEAR and m < FRESH_START_MONTH
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_before_fresh_start:
|
||||||
|
data["carryover_balance"] = 0.0
|
||||||
|
data["cumulative_balance"] = 0.0
|
||||||
|
data["balance_overridden"] = False
|
||||||
|
else:
|
||||||
|
data["carryover_balance"] = carryover
|
||||||
|
cumulative = carryover + data["net_balance"]
|
||||||
|
|
||||||
|
if m in override_map:
|
||||||
|
cumulative = override_map[m]
|
||||||
|
data["balance_overridden"] = True
|
||||||
|
else:
|
||||||
|
data["balance_overridden"] = False
|
||||||
|
|
||||||
|
data["cumulative_balance"] = cumulative
|
||||||
|
carryover = cumulative
|
||||||
|
|
||||||
|
months.append(data)
|
||||||
|
|
||||||
|
return months
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from sqlalchemy import case
|
||||||
from sqlmodel import Session, col, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db import engine
|
||||||
from app.models.models import ExchangeRate
|
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 indicators: 317 = buy, 318 = sell
|
||||||
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
|
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
|
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
|
||||||
CACHE_TTL = timedelta(hours=1)
|
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:
|
def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
||||||
"""Fetch a single indicator from BCCR API."""
|
"""Fetch a single indicator from BCCR API."""
|
||||||
@@ -167,6 +184,188 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
|
|||||||
return 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]:
|
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
|
||||||
"""Get historical exchange rates."""
|
"""Get historical exchange rates."""
|
||||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|||||||
291
backend/app/services/municipal_receipt_pdf.py
Normal file
291
backend/app/services/municipal_receipt_pdf.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"""
|
||||||
|
Extract structured data from Municipalidad de Belén receipts using pdftotext + regex.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_amount(s: str) -> float:
|
||||||
|
"""Parse a Costa Rican formatted number: '1,875.00' → 1875.00"""
|
||||||
|
return float(s.replace(",", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: str) -> str:
|
||||||
|
"""Convert dd/mm/yyyy → YYYY-MM-DD"""
|
||||||
|
d, m, y = s.strip().split("/")
|
||||||
|
return f"{y}-{m.zfill(2)}-{d.zfill(2)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_period(s: str) -> str:
|
||||||
|
"""Convert mm/yyyy → YYYY-MM"""
|
||||||
|
m, y = s.strip().split("/")
|
||||||
|
return f"{y}-{m.zfill(2)}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Charge:
|
||||||
|
detail: str
|
||||||
|
interests: float
|
||||||
|
iva: float
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WaterMeter:
|
||||||
|
period: str
|
||||||
|
meter_id: str
|
||||||
|
reading_previous: int
|
||||||
|
reading_current: int
|
||||||
|
consumption_m3: int
|
||||||
|
agua_potable: float
|
||||||
|
serv_ambientales: float
|
||||||
|
alcant_sanitario: float
|
||||||
|
iva: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HistoricalConsumption:
|
||||||
|
meter_id: str
|
||||||
|
period: str
|
||||||
|
consumption_m3: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MunicipalReceiptData:
|
||||||
|
receipt_date: str # YYYY-MM-DD
|
||||||
|
due_date: str # YYYY-MM-DD
|
||||||
|
holder_name: str
|
||||||
|
holder_cedula: str
|
||||||
|
holder_address: str
|
||||||
|
account: str
|
||||||
|
finca: str
|
||||||
|
charges: list[Charge] = field(default_factory=list)
|
||||||
|
subtotal: float = 0.0
|
||||||
|
interests: float = 0.0
|
||||||
|
iva: float = 0.0
|
||||||
|
total: float = 0.0
|
||||||
|
water_meters: list[WaterMeter] = field(default_factory=list)
|
||||||
|
historical_consumption: list[HistoricalConsumption] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_to_text(pdf_bytes: bytes) -> str:
|
||||||
|
"""Convert PDF bytes to text using pdftotext -layout."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
|
||||||
|
tmp.write(pdf_bytes)
|
||||||
|
tmp.flush()
|
||||||
|
result = subprocess.run(
|
||||||
|
["pdftotext", "-layout", tmp.name, "-"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ValueError(f"pdftotext failed: {result.stderr}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# Regex patterns
|
||||||
|
RE_FECHA = re.compile(r"Fecha:\s*(\d{2}/\d{2}/\d{4})")
|
||||||
|
RE_VENCIMIENTO = re.compile(r"Fecha de vencimiento:\s*(\d{2}/\d{2}/\d{4})")
|
||||||
|
RE_NOMBRE = re.compile(r"Nombre:\s*(.+)")
|
||||||
|
RE_CEDULA = re.compile(r"Cédula:\s*(\d+)")
|
||||||
|
RE_DIRECCION = re.compile(r"Dirección:\s*(.+)")
|
||||||
|
|
||||||
|
# Charge line: DETAIL_TEXT account finca interests iva periodo_actual periodo_anterior
|
||||||
|
RE_CHARGE = re.compile(
|
||||||
|
r"^([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s.]+?)\s+"
|
||||||
|
r"(\d{4})\s+"
|
||||||
|
r"(\d{6}---\d{3})\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
RE_SUBTOTAL = re.compile(r"Sub-Total:\s+([\d,]+\.\d{2})")
|
||||||
|
RE_INTERESES = re.compile(r"Intereses:\s+([\d,]+\.\d{2})")
|
||||||
|
RE_IVA = re.compile(r"IVA\s+([\d,]+\.\d{2})")
|
||||||
|
RE_TOTAL = re.compile(r"Total:\s+([\d,]+\.\d{2})")
|
||||||
|
|
||||||
|
# Water meter line: period meter_id lec_ant lec_act consumo agua_potable serv_amb alcant iva
|
||||||
|
RE_WATER_METER = re.compile(
|
||||||
|
r"(\d{2}/\d{4})\s+"
|
||||||
|
r"(\d{4})\s+"
|
||||||
|
r"(\d{5})\s+"
|
||||||
|
r"(\d{5})\s+"
|
||||||
|
r"(\d+)\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s+"
|
||||||
|
r"([\d,]+\.\d{2})\s+"
|
||||||
|
r"([\d,]+\.\d{2})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Historical consumption: meter_id period consumption
|
||||||
|
RE_HISTORICAL = re.compile(
|
||||||
|
r"(\d{4})\s+(\d{2}/\d{4})\s+(\d{5})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_municipal_receipt(
|
||||||
|
pdf_bytes: bytes, filename: str
|
||||||
|
) -> dict:
|
||||||
|
"""Extract structured data from a municipal receipt PDF.
|
||||||
|
|
||||||
|
Returns a dict matching the target JSON schema.
|
||||||
|
"""
|
||||||
|
text = _pdf_to_text(pdf_bytes)
|
||||||
|
|
||||||
|
if "RECIBO MUNICIPAL" not in text:
|
||||||
|
raise ValueError(f"{filename}: Not a municipal receipt")
|
||||||
|
|
||||||
|
data = MunicipalReceiptData(
|
||||||
|
receipt_date="",
|
||||||
|
due_date="",
|
||||||
|
holder_name="",
|
||||||
|
holder_cedula="",
|
||||||
|
holder_address="",
|
||||||
|
account="",
|
||||||
|
finca="",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Header fields ---
|
||||||
|
m = RE_FECHA.search(text)
|
||||||
|
if m:
|
||||||
|
data.receipt_date = _parse_date(m.group(1))
|
||||||
|
|
||||||
|
m = RE_VENCIMIENTO.search(text)
|
||||||
|
if m:
|
||||||
|
data.due_date = _parse_date(m.group(1))
|
||||||
|
|
||||||
|
m = RE_NOMBRE.search(text)
|
||||||
|
if m:
|
||||||
|
data.holder_name = m.group(1).strip()
|
||||||
|
|
||||||
|
m = RE_CEDULA.search(text)
|
||||||
|
if m:
|
||||||
|
data.holder_cedula = m.group(1).strip()
|
||||||
|
|
||||||
|
m = RE_DIRECCION.search(text)
|
||||||
|
if m:
|
||||||
|
data.holder_address = m.group(1).strip().rstrip(".")
|
||||||
|
|
||||||
|
# --- Charges ---
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = RE_CHARGE.match(line.strip())
|
||||||
|
if m:
|
||||||
|
detail = m.group(1).strip()
|
||||||
|
data.account = m.group(2)
|
||||||
|
data.finca = m.group(3)
|
||||||
|
interests = _parse_amount(m.group(4))
|
||||||
|
iva = _parse_amount(m.group(5))
|
||||||
|
amount = _parse_amount(m.group(6))
|
||||||
|
data.charges.append(Charge(detail=detail, interests=interests, iva=iva, amount=amount))
|
||||||
|
|
||||||
|
# --- Totals ---
|
||||||
|
m = RE_SUBTOTAL.search(text)
|
||||||
|
if m:
|
||||||
|
data.subtotal = _parse_amount(m.group(1))
|
||||||
|
|
||||||
|
m = RE_INTERESES.search(text)
|
||||||
|
if m:
|
||||||
|
data.interests = _parse_amount(m.group(1))
|
||||||
|
|
||||||
|
m = RE_IVA.search(text)
|
||||||
|
if m:
|
||||||
|
data.iva = _parse_amount(m.group(1))
|
||||||
|
|
||||||
|
m = RE_TOTAL.search(text)
|
||||||
|
if m:
|
||||||
|
data.total = _parse_amount(m.group(1))
|
||||||
|
|
||||||
|
# --- Water meters ---
|
||||||
|
for m in RE_WATER_METER.finditer(text):
|
||||||
|
data.water_meters.append(
|
||||||
|
WaterMeter(
|
||||||
|
period=_parse_period(m.group(1)),
|
||||||
|
meter_id=m.group(2),
|
||||||
|
reading_previous=int(m.group(3)),
|
||||||
|
reading_current=int(m.group(4)),
|
||||||
|
consumption_m3=int(m.group(5)),
|
||||||
|
agua_potable=_parse_amount(m.group(6)),
|
||||||
|
serv_ambientales=_parse_amount(m.group(7)),
|
||||||
|
alcant_sanitario=_parse_amount(m.group(8)),
|
||||||
|
iva=_parse_amount(m.group(9)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Historical consumption ---
|
||||||
|
# Only parse lines AFTER "DETALLE DE CONSUMO MESES ANTERIORES"
|
||||||
|
hist_section = text.split("DETALLE DE CONSUMO MESES ANTERIORES")
|
||||||
|
if len(hist_section) > 1:
|
||||||
|
for m in RE_HISTORICAL.finditer(hist_section[1]):
|
||||||
|
data.historical_consumption.append(
|
||||||
|
HistoricalConsumption(
|
||||||
|
meter_id=m.group(1),
|
||||||
|
period=_parse_period(m.group(2)),
|
||||||
|
consumption_m3=int(m.group(3)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
if not data.receipt_date:
|
||||||
|
raise ValueError(f"{filename}: Could not parse receipt date")
|
||||||
|
if not data.charges:
|
||||||
|
raise ValueError(f"{filename}: No charges found")
|
||||||
|
|
||||||
|
# --- Build output dict ---
|
||||||
|
return {
|
||||||
|
"receipt": {
|
||||||
|
"type": "RECIBO MUNICIPAL",
|
||||||
|
"issuer": {
|
||||||
|
"name": "MUNICIPALIDAD DE BELÉN",
|
||||||
|
"phone": "(506) 2587-0000",
|
||||||
|
"fax": "(506) 2293-3667",
|
||||||
|
"website": "www.belen.go.cr",
|
||||||
|
},
|
||||||
|
"date": data.receipt_date,
|
||||||
|
"due_date": data.due_date,
|
||||||
|
"account_holder": {
|
||||||
|
"name": data.holder_name,
|
||||||
|
"cedula": data.holder_cedula,
|
||||||
|
"address": data.holder_address,
|
||||||
|
},
|
||||||
|
"account": data.account,
|
||||||
|
"finca": data.finca,
|
||||||
|
},
|
||||||
|
"charges": [
|
||||||
|
{"detail": c.detail, "interests": c.interests, "iva": c.iva, "amount": c.amount}
|
||||||
|
for c in data.charges
|
||||||
|
],
|
||||||
|
"totals": {
|
||||||
|
"subtotal": data.subtotal,
|
||||||
|
"interests": data.interests,
|
||||||
|
"iva": data.iva,
|
||||||
|
"total": data.total,
|
||||||
|
},
|
||||||
|
"water_meters": [
|
||||||
|
{
|
||||||
|
"period": wm.period,
|
||||||
|
"meter_id": wm.meter_id,
|
||||||
|
"reading_previous": wm.reading_previous,
|
||||||
|
"reading_current": wm.reading_current,
|
||||||
|
"consumption_m3": wm.consumption_m3,
|
||||||
|
"agua_potable": wm.agua_potable,
|
||||||
|
"serv_ambientales": wm.serv_ambientales,
|
||||||
|
"alcant_sanitario": wm.alcant_sanitario,
|
||||||
|
"iva": wm.iva,
|
||||||
|
}
|
||||||
|
for wm in data.water_meters
|
||||||
|
],
|
||||||
|
"historical_consumption": [
|
||||||
|
{
|
||||||
|
"meter_id": hc.meter_id,
|
||||||
|
"period": hc.period,
|
||||||
|
"consumption_m3": hc.consumption_m3,
|
||||||
|
}
|
||||||
|
for hc in data.historical_consumption
|
||||||
|
],
|
||||||
|
}
|
||||||
225
backend/app/services/pension_pdf.py
Normal file
225
backend/app/services/pension_pdf.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Parse BAC San José Pensiones PDF statements into structured fund snapshots."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FundSnapshot:
|
||||||
|
fund: str # "ROP", "FCL", or "VOL"
|
||||||
|
contract_number: str
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
|
saldo_anterior: float
|
||||||
|
aportes: float
|
||||||
|
rendimientos: float
|
||||||
|
retiros: float
|
||||||
|
traslados: float
|
||||||
|
comision: float
|
||||||
|
correccion: float
|
||||||
|
bonificacion: float
|
||||||
|
saldo_final: float
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pdftotext() -> str:
|
||||||
|
"""Find pdftotext binary, checking common install paths."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
cmd = shutil.which("pdftotext")
|
||||||
|
if cmd:
|
||||||
|
return cmd
|
||||||
|
for path in ["/opt/homebrew/bin/pdftotext", "/usr/bin/pdftotext", "/usr/local/bin/pdftotext"]:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
raise FileNotFoundError("pdftotext not found — install poppler-utils")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text(pdf_bytes: bytes) -> str:
|
||||||
|
pdftotext_bin = _find_pdftotext()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||||
|
f.write(pdf_bytes)
|
||||||
|
f.flush()
|
||||||
|
result = subprocess.run(
|
||||||
|
[pdftotext_bin, "-layout", f.name, "-"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ValueError(f"pdftotext failed: {result.stderr.strip()}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def detect_type(text: str) -> str:
|
||||||
|
"""Return 'VOL', 'ROP_FCL', or 'UNKNOWN'."""
|
||||||
|
if any(kw in text for kw in ("MARCA DE TARJETA", "ESTADO DE CUENTA", "PAGO MÍNIMO")):
|
||||||
|
return "CREDIT_CARD"
|
||||||
|
if "FONDO C VOLUNTARIO" in text:
|
||||||
|
return "VOL"
|
||||||
|
if "RÉGIMEN OBLIGATORIO" in text or ("ROP" in text and "FCL" in text):
|
||||||
|
return "ROP_FCL"
|
||||||
|
return "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_amount(s: str) -> float:
|
||||||
|
"""Parse '17,819,176.79' or '-12,693.13' into float."""
|
||||||
|
cleaned = s.replace(",", "")
|
||||||
|
return float(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_amounts(line: str) -> list[float]:
|
||||||
|
"""Extract all ¢-prefixed amounts from a line."""
|
||||||
|
return [_parse_amount(m) for m in re.findall(r"¢\s*(-?[\d,]+\.\d{2})", line)]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_period(text: str) -> tuple[date, date]:
|
||||||
|
m = re.search(r"DEL\s+(\d{2}/\d{2}/\d{4})\s+AL\s+(\d{2}/\d{2}/\d{4})", text)
|
||||||
|
if not m:
|
||||||
|
raise ValueError("Could not find period dates (DEL ... AL ...)")
|
||||||
|
start = date(int(m.group(1)[6:]), int(m.group(1)[3:5]), int(m.group(1)[:2]))
|
||||||
|
end = date(int(m.group(2)[6:]), int(m.group(2)[3:5]), int(m.group(2)[:2]))
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_summary_value(text: str, label: str) -> list[float]:
|
||||||
|
"""Find a summary line by label and return all ¢ amounts on that line."""
|
||||||
|
pattern = re.compile(re.escape(label) + r".*", re.IGNORECASE)
|
||||||
|
for line in text.split("\n"):
|
||||||
|
if pattern.search(line):
|
||||||
|
amounts = _find_amounts(line)
|
||||||
|
if amounts:
|
||||||
|
return amounts
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_SUMMARY_FIELDS = [
|
||||||
|
("Saldo Anterior", "saldo_anterior"),
|
||||||
|
("Aportes", "aportes"),
|
||||||
|
("Rendimientos", "rendimientos"),
|
||||||
|
("Retiros", "retiros"),
|
||||||
|
("Traslados", "traslados"),
|
||||||
|
("Comisión de Administración", "comision"),
|
||||||
|
("Corrección de Imputaciones", "correccion"),
|
||||||
|
("Bonificación", "bonificacion"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_final_balance(text: str, after_label: str = "Bonificación") -> list[float]:
|
||||||
|
"""Find the standalone balance line after the last summary field.
|
||||||
|
|
||||||
|
After Bonificación (or Corrección for ROP+FCL), there's a line with just
|
||||||
|
the final balance amount(s) and no label.
|
||||||
|
"""
|
||||||
|
lines = text.split("\n")
|
||||||
|
found_label = False
|
||||||
|
for line in lines:
|
||||||
|
if after_label in line:
|
||||||
|
found_label = True
|
||||||
|
continue
|
||||||
|
if found_label:
|
||||||
|
amounts = _find_amounts(line)
|
||||||
|
if amounts:
|
||||||
|
return amounts
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vol(text: str) -> list[FundSnapshot]:
|
||||||
|
period_start, period_end = _parse_period(text)
|
||||||
|
|
||||||
|
# Contract number
|
||||||
|
m = re.search(r"N°\s*Contrato:\s*(\S+)", text)
|
||||||
|
contract = m.group(1) if m else ""
|
||||||
|
|
||||||
|
data: dict[str, float] = {}
|
||||||
|
for label, field in _SUMMARY_FIELDS:
|
||||||
|
amounts = _extract_summary_value(text, label)
|
||||||
|
data[field] = amounts[0] if amounts else 0.0
|
||||||
|
|
||||||
|
finals = _find_final_balance(text, "Bonificación")
|
||||||
|
if not finals:
|
||||||
|
# Fallback: look after Corrección
|
||||||
|
finals = _find_final_balance(text, "Corrección de Imputaciones")
|
||||||
|
saldo_final = finals[0] if finals else 0.0
|
||||||
|
|
||||||
|
return [
|
||||||
|
FundSnapshot(
|
||||||
|
fund="VOL",
|
||||||
|
contract_number=contract,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
saldo_final=saldo_final,
|
||||||
|
**data,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rop_fcl(text: str) -> list[FundSnapshot]:
|
||||||
|
period_start, period_end = _parse_period(text)
|
||||||
|
|
||||||
|
# Contract numbers
|
||||||
|
m_rop = re.search(r"N°\s*Contrato\s*ROP:\s*(\S+)", text)
|
||||||
|
m_fcl = re.search(r"N°\s*Contrato\s*FCL:\s*(\S+)", text)
|
||||||
|
contract_rop = m_rop.group(1) if m_rop else ""
|
||||||
|
contract_fcl = m_fcl.group(1) if m_fcl else ""
|
||||||
|
|
||||||
|
rop_data: dict[str, float] = {}
|
||||||
|
fcl_data: dict[str, float] = {}
|
||||||
|
|
||||||
|
for label, field in _SUMMARY_FIELDS:
|
||||||
|
amounts = _extract_summary_value(text, label)
|
||||||
|
if len(amounts) >= 2:
|
||||||
|
rop_data[field] = amounts[0]
|
||||||
|
fcl_data[field] = amounts[1]
|
||||||
|
elif len(amounts) == 1:
|
||||||
|
rop_data[field] = amounts[0]
|
||||||
|
fcl_data[field] = 0.0
|
||||||
|
else:
|
||||||
|
rop_data[field] = 0.0
|
||||||
|
fcl_data[field] = 0.0
|
||||||
|
|
||||||
|
# Final balance line (after Corrección since ROP+FCL has no Bonificación)
|
||||||
|
finals = _find_final_balance(text, "Corrección de Imputaciones")
|
||||||
|
rop_final = finals[0] if len(finals) >= 1 else 0.0
|
||||||
|
fcl_final = finals[1] if len(finals) >= 2 else 0.0
|
||||||
|
|
||||||
|
return [
|
||||||
|
FundSnapshot(
|
||||||
|
fund="ROP",
|
||||||
|
contract_number=contract_rop,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
saldo_final=rop_final,
|
||||||
|
**rop_data,
|
||||||
|
),
|
||||||
|
FundSnapshot(
|
||||||
|
fund="FCL",
|
||||||
|
contract_number=contract_fcl,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
saldo_final=fcl_final,
|
||||||
|
**fcl_data,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pension_pdf(pdf_bytes: bytes, filename: str = "") -> list[FundSnapshot]:
|
||||||
|
"""Parse a pension PDF and return fund snapshots.
|
||||||
|
|
||||||
|
Raises ValueError for credit card statements or unrecognized formats.
|
||||||
|
"""
|
||||||
|
text = extract_text(pdf_bytes)
|
||||||
|
doc_type = detect_type(text)
|
||||||
|
|
||||||
|
if doc_type == "CREDIT_CARD":
|
||||||
|
raise ValueError(f"'{filename}' is a credit card statement, not a pension extract")
|
||||||
|
if doc_type == "UNKNOWN":
|
||||||
|
raise ValueError(f"'{filename}' is not a recognized BAC pension statement")
|
||||||
|
|
||||||
|
if doc_type == "VOL":
|
||||||
|
return parse_vol(text)
|
||||||
|
else:
|
||||||
|
return parse_rop_fcl(text)
|
||||||
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
|
||||||
@@ -8,3 +8,9 @@ python-multipart
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
alembic
|
alembic
|
||||||
httpx
|
httpx
|
||||||
|
pywebpush
|
||||||
|
py-vapid
|
||||||
|
python-dateutil
|
||||||
|
agent-framework==1.2.1
|
||||||
|
agent-framework-ag-ui==1.0.0b260428
|
||||||
|
agent-framework-openai==1.2.1
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
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:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
networks:
|
networks:
|
||||||
@@ -45,22 +49,32 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.prod
|
dockerfile: Dockerfile
|
||||||
|
target: runner
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
container_name: wealthysmart-frontend-prod
|
container_name: wealthysmart-frontend-prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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_HOST: wealth.cescalante.dev
|
||||||
|
VIRTUAL_PORT: "3000"
|
||||||
LETSENCRYPT_HOST: wealth.cescalante.dev
|
LETSENCRYPT_HOST: wealth.cescalante.dev
|
||||||
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
|
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "3000"
|
||||||
networks:
|
networks:
|
||||||
- wealthysmart-network
|
- wealthysmart-network
|
||||||
- nginx-prod-network
|
- nginx-prod-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ services:
|
|||||||
container_name: wealthysmart-backend-dev
|
container_name: wealthysmart-backend-dev
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
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:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -30,17 +34,52 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: dev
|
||||||
container_name: wealthysmart-frontend-dev
|
container_name: wealthysmart-frontend-dev
|
||||||
ports:
|
ports:
|
||||||
- "5175:5173"
|
- "5175:3000"
|
||||||
volumes:
|
environment:
|
||||||
- ./frontend:/app
|
NODE_ENV: development
|
||||||
- /app/node_modules
|
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:
|
volumes:
|
||||||
postgres_data:
|
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
|
# syntax=docker/dockerfile:1.7
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
||||||
RUN pnpm install --frozen-lockfile
|
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 . .
|
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="description" content="WealthySmart — Smart personal finance management" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
||||||
<title>WealthySmart</title>
|
<title>WealthySmart</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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",
|
"name": "frontend",
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "tsx server.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/ibm-plex-sans": "^5.2.8",
|
||||||
"@fontsource-variable/noto-sans": "^5.2.10",
|
"@fontsource-variable/noto-sans": "^5.2.10",
|
||||||
|
"@hono/node-server": "^1.14.4",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.13.6",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"concurrently": "^9.1.2",
|
||||||
"react": "^19.2.0",
|
"hono": "^4.12.15",
|
||||||
"react-dom": "^19.2.0",
|
"lucide-react": "^1.12.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react": "19.2.5",
|
||||||
"recharts": "^2.15.4",
|
"react-dom": "19.2.5",
|
||||||
"shadcn": "^4.1.0",
|
"react-router-dom": "^7.6.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tsx": "^4.19.4",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4",
|
||||||
"@types/react": "^19.2.8",
|
"@types/node": "^20",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react": "^19",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
"@types/react-dom": "^19",
|
||||||
"tailwindcss": "^4.1.18",
|
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||||
"typescript": "^5.9.3",
|
"tailwindcss": "^4",
|
||||||
"vite": "^7.2.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,52 +1,4 @@
|
|||||||
const CACHE_NAME = 'wealthysmart-v1';
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
const STATIC_ASSETS = ['/', '/index.html'];
|
self.addEventListener("activate", async () => {
|
||||||
|
await self.registration.unregister();
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
|
||||||
);
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.keys().then((keys) =>
|
|
||||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const { request } = event;
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
// Network-first for API calls
|
|
||||||
if (url.pathname.startsWith('/api/')) {
|
|
||||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache-first for static assets
|
|
||||||
if (url.pathname.startsWith('/assets/')) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
|
|
||||||
const clone = res.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
|
||||||
return res;
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network-first for navigation, fallback to cached index.html
|
|
||||||
if (request.mode === 'navigate') {
|
|
||||||
event.respondWith(
|
|
||||||
fetch(request).catch(() => caches.match('/index.html'))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: network with cache fallback
|
|
||||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
|
||||||
});
|
});
|
||||||
|
|||||||
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,26 +1,34 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { AuthProvider, useAuth } from './AuthContext';
|
import { CopilotKit } from "@copilotkit/react-core";
|
||||||
import { ThemeProvider } from './ThemeContext';
|
import { AuthProvider, useAuth } from "./AuthContext";
|
||||||
import Layout from './components/Layout';
|
import { ThemeProvider } from "./contexts/theme-context";
|
||||||
import Login from './pages/Login';
|
import { PrivacyProvider } from "./contexts/privacy-context";
|
||||||
import Dashboard from './pages/Dashboard';
|
import Layout from "./components/Layout";
|
||||||
import Transactions from './pages/Transactions';
|
import LoginPage from "./pages/Login";
|
||||||
import Transfers from './pages/Transfers';
|
import Asistente from "./pages/Asistente";
|
||||||
import Analytics from './pages/Analytics';
|
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 }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
if (isLoading) return null;
|
||||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@@ -29,10 +37,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route index element={<Navigate to="/asistente" replace />} />
|
||||||
<Route path="/transactions" element={<Transactions />} />
|
<Route path="/asistente" element={<Asistente />} />
|
||||||
|
<Route path="/budget" element={<Budget />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/transfers" element={<Transfers />} />
|
<Route path="/proyecciones" element={<Proyecciones />} />
|
||||||
|
<Route path="/salarios" element={<Salarios />} />
|
||||||
|
<Route path="/pensions" element={<Pensions />} />
|
||||||
|
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||||
|
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||||
|
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -42,9 +56,13 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<PrivacyProvider>
|
||||||
<AppRoutes />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
|
||||||
|
<AppRoutes />
|
||||||
|
</CopilotKit>
|
||||||
|
</AuthProvider>
|
||||||
|
</PrivacyProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
interface AuthCtx {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
logout: () => void;
|
isLoading: boolean;
|
||||||
|
logout: () => Promise<void>;
|
||||||
setAuthenticated: (v: boolean) => void;
|
setAuthenticated: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthCtx>({
|
const AuthContext = createContext<AuthCtx>({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
logout: () => {},
|
isLoading: true,
|
||||||
|
logout: async () => {},
|
||||||
setAuthenticated: () => {},
|
setAuthenticated: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token'));
|
const [isAuthenticated, setAuthenticated] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setAuthenticated(!!localStorage.getItem('token'));
|
// Probe auth state by hitting a protected endpoint.
|
||||||
window.addEventListener('storage', check);
|
// If the ws_token cookie is valid, the server returns 200; else 401.
|
||||||
return () => window.removeEventListener('storage', check);
|
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||||
|
.then((r) => setAuthenticated(r.ok))
|
||||||
|
.catch(() => setAuthenticated(false))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
localStorage.removeItem('token');
|
await apiLogout();
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
|
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
api.interceptors.response.use(
|
|
||||||
(res) => res,
|
|
||||||
(err) => {
|
|
||||||
if (err.response?.status === 401) {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
return Promise.reject(err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default api;
|
|
||||||
|
|
||||||
export async function login(username: string, password: string) {
|
|
||||||
const form = new URLSearchParams();
|
|
||||||
form.append('username', username);
|
|
||||||
form.append('password', password);
|
|
||||||
const { data } = await api.post('/auth/login', form);
|
|
||||||
localStorage.setItem('token', data.access_token);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Account {
|
|
||||||
id: number;
|
|
||||||
bank: string;
|
|
||||||
currency: string;
|
|
||||||
label: string;
|
|
||||||
balance: number;
|
|
||||||
account_type: string;
|
|
||||||
next_payment: number | null;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Category {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
auto_match_patterns: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportResult {
|
|
||||||
imported: number;
|
|
||||||
duplicates: number;
|
|
||||||
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;
|
|
||||||
currency: string;
|
|
||||||
merchant: string;
|
|
||||||
city: string | null;
|
|
||||||
date: string;
|
|
||||||
card_type: string | null;
|
|
||||||
card_last4: string | null;
|
|
||||||
authorization_code: string | null;
|
|
||||||
reference: string | null;
|
|
||||||
transaction_type: string;
|
|
||||||
source: string;
|
|
||||||
bank: string;
|
|
||||||
notes: string | null;
|
|
||||||
category_id: number | null;
|
|
||||||
category: Category | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
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 { useEffect, useState } from 'react';
|
||||||
import { Calendar } from 'lucide-react';
|
import { Calendar } from 'lucide-react';
|
||||||
import api from '../api';
|
import api from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
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 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,86 +1,139 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
Sparkles,
|
||||||
CreditCard,
|
Calculator,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ArrowLeftRight,
|
Landmark,
|
||||||
|
PiggyBank,
|
||||||
|
Droplets,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
TrendingUp,
|
||||||
Wallet,
|
Wallet,
|
||||||
Menu,
|
Menu,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
} from 'lucide-react';
|
Eye,
|
||||||
import { useState } from 'react';
|
EyeOff,
|
||||||
import { useAuth } from '../AuthContext';
|
type LucideIcon,
|
||||||
import { useTheme } from '../ThemeContext';
|
} from "lucide-react";
|
||||||
import { Button } from '@/components/ui/button';
|
import { useTheme } from "@/contexts/theme-context";
|
||||||
|
import { usePrivacy } from "@/contexts/privacy-context";
|
||||||
|
import { useAuth } from "@/AuthContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
} from '@/components/ui/sheet';
|
} from "@/components/ui/sheet";
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
interface NavSection {
|
||||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
label: string;
|
||||||
{ to: '/transactions', icon: CreditCard, label: 'Transactions' },
|
items: { to: string; icon: LucideIcon; label: string }[];
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
}
|
||||||
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
|
|
||||||
|
const navSections: NavSection[] = [
|
||||||
|
{
|
||||||
|
label: "General",
|
||||||
|
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Finanzas",
|
||||||
|
items: [
|
||||||
|
{ 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: "Servicios",
|
||||||
|
items: [
|
||||||
|
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<div key={section.label}>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 pt-4 pb-1">
|
||||||
|
{section.label}
|
||||||
|
</p>
|
||||||
|
{section.items.map(({ to, icon: Icon, label }) => (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
onClick={onNavigate}
|
||||||
|
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}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { logout } = useAuth();
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||||
|
const { logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
logout();
|
await logout();
|
||||||
navigate('/login');
|
navigate("/login", { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<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">
|
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
title="Open menu"
|
||||||
|
aria-label="Open menu"
|
||||||
|
className="md:hidden"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
<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} />
|
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||||
</div>
|
</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>
|
Wealthy<span className="text-primary">Smart</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop nav */}
|
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
|
||||||
{navItems.map(({ to, icon: Icon, label }) => (
|
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
end={to === '/'}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
'flex items-center gap-2 px-4 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'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
|
||||||
|
{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">
|
<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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -92,70 +145,69 @@ export default function Layout() {
|
|||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setMobileOpen(true)}
|
|
||||||
title="Open menu"
|
|
||||||
aria-label="Open menu"
|
|
||||||
className="md:hidden"
|
|
||||||
>
|
|
||||||
<Menu className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Mobile nav sheet */}
|
<div className="flex">
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
<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">
|
||||||
<SheetContent side="left" className="p-0">
|
<div className="flex-1">
|
||||||
<SheetHeader className="p-4">
|
<SidebarNav />
|
||||||
<SheetTitle className="flex items-center gap-2.5">
|
</div>
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
<div className="px-3 pb-4">
|
||||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
<Separator className="mb-2" />
|
||||||
</div>
|
|
||||||
<span className="font-heading">
|
|
||||||
Wealthy<span className="text-primary">Smart</span>
|
|
||||||
</span>
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<Separator />
|
|
||||||
<nav className="flex flex-col gap-1 p-4">
|
|
||||||
{navItems.map(({ to, icon: Icon, label }) => (
|
|
||||||
<SheetClose key={to} render={<span />}>
|
|
||||||
<NavLink
|
|
||||||
to={to}
|
|
||||||
end={to === '/'}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-primary/10 text-primary'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
</SheetClose>
|
|
||||||
))}
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full"
|
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
Sign out
|
Cerrar sesión
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</div>
|
||||||
</SheetContent>
|
</aside>
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
<Outlet />
|
<SheetContent side="left" className="p-0 w-64">
|
||||||
</main>
|
<SheetHeader className="p-4">
|
||||||
|
<SheetTitle className="flex items-center gap-2.5">
|
||||||
|
<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 style={{ fontFamily: "var(--font-heading)" }}>
|
||||||
|
Wealthy<span className="text-primary">Smart</span>
|
||||||
|
</span>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-col h-[calc(100%-65px)]">
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<SidebarNav onNavigate={() => setMobileOpen(false)} />
|
||||||
|
</div>
|
||||||
|
<div className="px-3 pb-4">
|
||||||
|
<Separator className="mb-2" />
|
||||||
|
<SheetClose render={<span />}>
|
||||||
|
<button
|
||||||
|
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" />
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</SheetClose>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<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 />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|||||||
165
frontend/src/components/PensionManualEntryModal.tsx
Normal file
165
frontend/src/components/PensionManualEntryModal.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||||
|
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';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onImported: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCRC = (n: number) =>
|
||||||
|
new Intl.NumberFormat('es-CR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CRC',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(n);
|
||||||
|
|
||||||
|
const FUND_LABELS: Record<string, string> = {
|
||||||
|
ROP: 'ROP',
|
||||||
|
FCL: 'FCL',
|
||||||
|
VOL: 'Voluntario',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PensionManualEntryModal({ onClose, onImported }: Props) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [parsed, setParsed] = useState<PensionParsedEntry[] | null>(null);
|
||||||
|
const [parseError, setParseError] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [result, setResult] = useState<PensionUploadResult | null>(null);
|
||||||
|
|
||||||
|
const handlePreview = () => {
|
||||||
|
setParseError('');
|
||||||
|
const entries = parsePensionPaste(text);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
setParseError('No se encontraron datos de fondos. Verifica que el texto pegado tenga el formato correcto.');
|
||||||
|
setParsed(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setParsed(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!parsed) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const { data } = await submitPensionManualEntries(parsed);
|
||||||
|
setResult(data);
|
||||||
|
if (data.imported > 0 || data.updated > 0) onImported();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<ClipboardPaste className="w-4 h-4 text-primary" />
|
||||||
|
Ingresar Datos de Pensión
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle className="h-4 w-4 text-primary" />
|
||||||
|
<AlertTitle className="text-primary">Datos Guardados</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{result.imported > 0 && `${result.imported} nuevo(s)`}
|
||||||
|
{result.imported > 0 && result.updated > 0 && ' · '}
|
||||||
|
{result.updated > 0 && `${result.updated} actualizado(s)`}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button onClick={onClose} className="w-full">Listo</Button>
|
||||||
|
</div>
|
||||||
|
) : !parsed ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pegar resumen del período</Label>
|
||||||
|
<Textarea
|
||||||
|
className="h-56 font-mono text-xs resize-y"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={`Pega aquí el texto del resumen de BAC Pensiones.\n\nEjemplo:\nResumen del Período\tROP\tFCL\nSaldo Anterior\t¢ 18,684,764.98\t¢ 650,467.87\nAportes\t¢ 120,012.00\t¢ 60,006.00\n...\n\nSepara ROP+FCL y Voluntario con una línea "---"`}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pega los bloques de ROP+FCL y Fondo Voluntario. Sepáralos con "---".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parseError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{parseError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||||
|
<Button onClick={handlePreview} disabled={!text.trim()}>
|
||||||
|
Vista Previa
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Fondo</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Período</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Saldo Ant.</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Aportes</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Rendim.</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Saldo Final</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parsed.map((e, i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="px-3 py-2 font-medium">{FUND_LABELS[e.fund] ?? e.fund}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||||
|
{e.period_start} — {e.period_end}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right font-mono text-xs">{formatCRC(e.saldo_anterior)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-mono text-xs">{formatCRC(e.aportes)}</td>
|
||||||
|
<td className={`px-3 py-2 text-right font-mono text-xs ${e.rendimientos < 0 ? 'text-red-500' : 'text-green-600'}`}>
|
||||||
|
{formatCRC(e.rendimientos)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right font-mono text-xs font-semibold">{formatCRC(e.saldo_final)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setParsed(null)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? 'Guardando...' : 'Confirmar'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
|
ArrowRightFromLine,
|
||||||
|
Banknote,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import api, { type Transaction } from '../api';
|
import api, { type Transaction } from '@/lib/api';
|
||||||
import TransactionModal from './TransactionModal';
|
import TransactionModal from './TransactionModal';
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -30,7 +32,9 @@ export interface TransactionListProps {
|
|||||||
emptyIcon?: React.ReactNode;
|
emptyIcon?: React.ReactNode;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
showCategory?: boolean;
|
showCategory?: boolean;
|
||||||
|
showSourceIcon?: boolean;
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransactionList({
|
export default function TransactionList({
|
||||||
@@ -43,7 +47,9 @@ export default function TransactionList({
|
|||||||
emptyIcon,
|
emptyIcon,
|
||||||
emptyMessage = 'No transactions found',
|
emptyMessage = 'No transactions found',
|
||||||
showCategory = true,
|
showCategory = true,
|
||||||
|
showSourceIcon = false,
|
||||||
addLabel = 'Add Transaction',
|
addLabel = 'Add Transaction',
|
||||||
|
onToggleDeferred,
|
||||||
}: TransactionListProps) {
|
}: TransactionListProps) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||||
@@ -68,8 +74,8 @@ export default function TransactionList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => getTransactionColumns({ showCategory, onEdit: handleEdit, onDelete: (id) => setDeleteId(id) }),
|
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
|
||||||
[showCategory],
|
[showCategory, showSourceIcon, onToggleDeferred],
|
||||||
);
|
);
|
||||||
|
|
||||||
const empty = transactions.length === 0 && !loading;
|
const empty = transactions.length === 0 && !loading;
|
||||||
@@ -119,7 +125,16 @@ export default function TransactionList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
{showCategory && tx.category && (
|
{showCategory && tx.category && (
|
||||||
@@ -128,6 +143,7 @@ export default function TransactionList({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
|
data-sensitive
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-sm font-medium shrink-0',
|
'font-mono text-sm font-medium shrink-0',
|
||||||
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
|
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
|
||||||
@@ -137,6 +153,18 @@ export default function TransactionList({
|
|||||||
{formatAmount(tx.amount, tx.currency)}
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<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)}>
|
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -33,7 +34,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
merchant: '',
|
merchant: '',
|
||||||
amount: '',
|
amount: '',
|
||||||
currency: 'CRC',
|
currency: 'CRC',
|
||||||
date: new Date().toISOString().slice(0, 16),
|
date: formatLocalDatetime(new Date()),
|
||||||
transaction_type: 'COMPRA',
|
transaction_type: 'COMPRA',
|
||||||
source,
|
source,
|
||||||
bank: 'BAC',
|
bank: 'BAC',
|
||||||
@@ -47,7 +48,10 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/categories/').then((r) => setCategories(r.data));
|
api.get('/categories/').then((r) => {
|
||||||
|
const sorted = [...r.data].sort((a: Category, b: Category) => a.name.localeCompare(b.name));
|
||||||
|
setCategories(sorted);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,6 +151,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="CRC">CRC (₡)</SelectItem>
|
<SelectItem value="CRC">CRC (₡)</SelectItem>
|
||||||
<SelectItem value="USD">USD ($)</SelectItem>
|
<SelectItem value="USD">USD ($)</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR (€)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +183,11 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
onValueChange={(v) => setForm({ ...form, category_id: v === 'auto' ? '' : v })}
|
onValueChange={(v) => setForm({ ...form, category_id: v === 'auto' ? '' : v })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{form.category_id
|
||||||
|
? categories.find((c) => c.id === Number(form.category_id))?.name ?? form.category_id
|
||||||
|
: 'Auto-detect'}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">Auto-detect</SelectItem>
|
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||||
|
|||||||
541
frontend/src/components/budget/MonthlyDetail.tsx
Normal file
541
frontend/src/components/budget/MonthlyDetail.tsx
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
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 { 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,
|
||||||
|
CreditCard,
|
||||||
|
Banknote,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
type PaletteMode = 'chatgpt' | 'gemini';
|
||||||
|
|
||||||
|
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 | null;
|
||||||
|
loading?: boolean;
|
||||||
|
onNavigateToTransactions?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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="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">
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Pie Charts */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{/* Income Pie */}
|
||||||
|
<Card>
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground py-8 text-center">Sin ingresos</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expenses Pie */}
|
||||||
|
<Card>
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground py-8 text-center">Sin egresos fijos</p>
|
||||||
|
)}
|
||||||
|
</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(ccTotal, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</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">
|
||||||
|
<Banknote className="w-4 h-4" />
|
||||||
|
Efectivo o Transferencias
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{cashTransferActuals.map((src) => {
|
||||||
|
const meta = SOURCE_LABELS[src.source];
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
</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 />
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Info className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">No cubierto por fijos</span>
|
||||||
|
</div>
|
||||||
|
<span data-sensitive className="font-mono">{formatAmount(detail.uncovered_actual, 'CRC')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Card className={cn(
|
||||||
|
'border-2',
|
||||||
|
detail.net_balance >= 0 ? 'border-primary/30' : 'border-destructive/30',
|
||||||
|
)}>
|
||||||
|
<CardContent className="pt-6 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Total Ingresos</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium text-primary">
|
||||||
|
+{formatAmount(detail.total_projected_income, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Gran Total Egresos</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium">
|
||||||
|
-{formatAmount(detail.gran_total_egresos, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold">Balance Neto</span>
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
'font-mono font-bold text-lg',
|
||||||
|
detail.net_balance >= 0 ? 'text-primary' : 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{detail.net_balance >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(detail.net_balance, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
frontend/src/components/budget/RecurringItemDialog.tsx
Normal file
309
frontend/src/components/budget/RecurringItemDialog.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
type RecurringItem,
|
||||||
|
type RecurringItemCreate,
|
||||||
|
type RecurringItemUpdate,
|
||||||
|
type RecurringItemType,
|
||||||
|
type RecurringFrequency,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
|
||||||
|
{ value: 'INCOME', label: 'Ingreso' },
|
||||||
|
{ value: 'EXPENSE', label: 'Egreso' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [
|
||||||
|
{ value: 'WEEKLY', label: 'Semanal' },
|
||||||
|
{ value: 'MONTHLY', label: 'Mensual' },
|
||||||
|
{ value: 'QUARTERLY', label: 'Trimestral' },
|
||||||
|
{ value: 'BIANNUAL', label: 'Semestral' },
|
||||||
|
{ value: 'YEARLY', label: 'Anual' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTH_LABELS = [
|
||||||
|
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface RecurringItemDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
item?: RecurringItem | null;
|
||||||
|
onSave: (data: RecurringItemCreate | RecurringItemUpdate) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecurringItemDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
item,
|
||||||
|
onSave,
|
||||||
|
}: RecurringItemDialogProps) {
|
||||||
|
const isEdit = !!item;
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [itemType, setItemType] = useState<RecurringItemType>('EXPENSE');
|
||||||
|
const [frequency, setFrequency] = useState<RecurringFrequency>('MONTHLY');
|
||||||
|
const [dayOfMonth, setDayOfMonth] = useState('');
|
||||||
|
const [monthOfYear, setMonthOfYear] = useState('');
|
||||||
|
const [overrides, setOverrides] = useState<{ month: string; amount: string }[]>([]);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (item) {
|
||||||
|
setName(item.name);
|
||||||
|
setAmount(String(item.amount));
|
||||||
|
setItemType(item.item_type);
|
||||||
|
setFrequency(item.frequency);
|
||||||
|
setDayOfMonth(item.day_of_month != null ? String(item.day_of_month) : '');
|
||||||
|
setMonthOfYear(item.month_of_year != null ? String(item.month_of_year) : '');
|
||||||
|
setOverrides(
|
||||||
|
item.override_amounts
|
||||||
|
? Object.entries(item.override_amounts).map(([m, a]) => ({
|
||||||
|
month: m,
|
||||||
|
amount: String(a),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
setNotes(item.notes || '');
|
||||||
|
} else {
|
||||||
|
setName('');
|
||||||
|
setAmount('');
|
||||||
|
setItemType('EXPENSE');
|
||||||
|
setFrequency('MONTHLY');
|
||||||
|
setDayOfMonth('');
|
||||||
|
setMonthOfYear('');
|
||||||
|
setOverrides([]);
|
||||||
|
setNotes('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, item]);
|
||||||
|
|
||||||
|
const showDayOfMonth = frequency === 'MONTHLY' || frequency === 'WEEKLY';
|
||||||
|
const showMonthOfYear = frequency === 'YEARLY' || frequency === 'BIANNUAL';
|
||||||
|
const showOverrides = frequency === 'MONTHLY';
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const overrideAmounts =
|
||||||
|
overrides.length > 0
|
||||||
|
? Object.fromEntries(
|
||||||
|
overrides
|
||||||
|
.filter((o) => o.month && o.amount)
|
||||||
|
.map((o) => [o.month, parseFloat(o.amount)]),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
item_type: itemType,
|
||||||
|
frequency,
|
||||||
|
day_of_month: dayOfMonth ? parseInt(dayOfMonth) : null,
|
||||||
|
month_of_year: monthOfYear ? parseInt(monthOfYear) : null,
|
||||||
|
override_amounts: overrideAmounts,
|
||||||
|
notes: notes || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSave(data);
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar' : 'Nuevo'} Item Recurrente</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ri-name">Nombre</Label>
|
||||||
|
<Input id="ri-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ri-amount">Monto (CRC)</Label>
|
||||||
|
<Input
|
||||||
|
id="ri-amount"
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Tipo</Label>
|
||||||
|
<Select value={itemType} onValueChange={(v) => v && setItemType(v as RecurringItemType)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Frecuencia</Label>
|
||||||
|
<Select value={frequency} onValueChange={(v) => v && setFrequency(v as RecurringFrequency)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FREQ_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDayOfMonth && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ri-day">
|
||||||
|
{frequency === 'WEEKLY' ? 'Día de semana (0=Lun)' : 'Día del mes'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ri-day"
|
||||||
|
type="number"
|
||||||
|
value={dayOfMonth}
|
||||||
|
onChange={(e) => setDayOfMonth(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMonthOfYear && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Mes</Label>
|
||||||
|
<Select value={monthOfYear} onValueChange={(v) => v && setMonthOfYear(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleccionar" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||||
|
<SelectItem key={m} value={String(m)}>
|
||||||
|
{MONTH_LABELS[m]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showOverrides && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Montos por mes (sobreescrituras)
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOverrides([...overrides, { month: '', amount: '' }])}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Agregar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{overrides.map((o, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={o.month}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (!v) return;
|
||||||
|
const next = [...overrides];
|
||||||
|
next[idx].month = v;
|
||||||
|
setOverrides(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue placeholder="Mes" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||||
|
<SelectItem key={m} value={String(m)}>
|
||||||
|
{MONTH_LABELS[m]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Monto"
|
||||||
|
value={o.amount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...overrides];
|
||||||
|
next[idx].amount = e.target.value;
|
||||||
|
setOverrides(next);
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setOverrides(overrides.filter((_, i) => i !== idx))}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ri-notes">Notas</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ri-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={saving || !name || !amount}>
|
||||||
|
{saving ? 'Guardando...' : isEdit ? 'Guardar' : 'Crear'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
frontend/src/components/budget/RecurringItemsManager.tsx
Normal file
183
frontend/src/components/budget/RecurringItemsManager.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
type RecurringItem,
|
||||||
|
type RecurringItemCreate,
|
||||||
|
type RecurringItemUpdate,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
|
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
||||||
|
import { Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import RecurringItemDialog from './RecurringItemDialog';
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FREQ_LABELS: Record<string, string> = {
|
||||||
|
WEEKLY: 'Semanal',
|
||||||
|
MONTHLY: 'Mensual',
|
||||||
|
QUARTERLY: 'Trimestral',
|
||||||
|
BIANNUAL: 'Semestral',
|
||||||
|
YEARLY: 'Anual',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RecurringItemsManagerProps {
|
||||||
|
items: RecurringItem[];
|
||||||
|
onAdd: (data: RecurringItemCreate) => Promise<void>;
|
||||||
|
onUpdate: (id: number, data: RecurringItemUpdate) => Promise<void>;
|
||||||
|
onDelete: (id: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecurringItemsManager({
|
||||||
|
items,
|
||||||
|
onAdd,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: RecurringItemsManagerProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editItem, setEditItem] = useState<RecurringItem | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleEdit = (item: RecurringItem) => {
|
||||||
|
setEditItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditItem(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (data: RecurringItemCreate | RecurringItemUpdate) => {
|
||||||
|
if (editItem) {
|
||||||
|
await onUpdate(editItem.id, data as RecurringItemUpdate);
|
||||||
|
} else {
|
||||||
|
await onAdd(data as RecurringItemCreate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (deleteId != null) {
|
||||||
|
await onDelete(deleteId);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<RecurringItem, unknown>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Nombre" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{row.original.name}</span>
|
||||||
|
{!row.original.is_active && (
|
||||||
|
<Badge variant="outline" className="ml-2 text-[10px]">inactivo</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'item_type',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Tipo" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const meta = TYPE_LABELS[row.original.item_type];
|
||||||
|
return <Badge variant={meta?.variant ?? 'secondary'}>{meta?.label ?? row.original.item_type}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'frequency',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Frecuencia" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm">{FREQ_LABELS[row.original.frequency] ?? row.original.frequency}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'amount',
|
||||||
|
meta: { className: 'text-right' },
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span data-sensitive className="font-mono text-sm">
|
||||||
|
{formatAmount(row.original.amount, row.original.currency)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
meta: { className: 'text-right' },
|
||||||
|
size: 80,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Editar"
|
||||||
|
aria-label="Editar"
|
||||||
|
onClick={() => handleEdit(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Eliminar"
|
||||||
|
aria-label="Eliminar"
|
||||||
|
onClick={() => setDeleteId(row.original.id)}
|
||||||
|
className="hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Items Recurrentes</h3>
|
||||||
|
<Button size="sm" onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Nuevo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
pagination
|
||||||
|
pageSize={20}
|
||||||
|
initialSorting={[{ id: 'item_type', desc: false }]}
|
||||||
|
emptyMessage="No hay items recurrentes."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RecurringItemDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
item={editItem}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteId != null && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Eliminar item"
|
||||||
|
message="Esta acción no se puede deshacer."
|
||||||
|
confirmLabel="Eliminar"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/components/budget/YearlyOverview.tsx
Normal file
207
frontend/src/components/budget/YearlyOverview.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
import { type MonthlyProjection } from '@/lib/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||||
|
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FRESH_START_YEAR = 2026;
|
||||||
|
const FRESH_START_MONTH = 3;
|
||||||
|
|
||||||
|
interface YearlyOverviewProps {
|
||||||
|
months: MonthlyProjection[];
|
||||||
|
selectedMonth: number;
|
||||||
|
year: number;
|
||||||
|
onSelectMonth: (month: number) => void;
|
||||||
|
onSaveOverride: (month: number, value: number) => Promise<void>;
|
||||||
|
onClearOverride: (month: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function YearlyOverview({
|
||||||
|
months,
|
||||||
|
selectedMonth,
|
||||||
|
year,
|
||||||
|
onSelectMonth,
|
||||||
|
onSaveOverride,
|
||||||
|
onClearOverride,
|
||||||
|
}: YearlyOverviewProps) {
|
||||||
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [editingMonth, setEditingMonth] = useState<number | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingMonth !== null && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [editingMonth]);
|
||||||
|
|
||||||
|
const handleStartEdit = (m: MonthlyProjection) => {
|
||||||
|
setEditingMonth(m.month);
|
||||||
|
setEditValue(String(Math.round(m.cumulative_balance)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (editingMonth === null) return;
|
||||||
|
const trimmed = editValue.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
await onClearOverride(editingMonth);
|
||||||
|
} else {
|
||||||
|
const num = parseFloat(trimmed);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
await onSaveOverride(editingMonth, num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditingMonth(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditingMonth(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Mes</TableHead>
|
||||||
|
<TableHead className="text-right">Ingresos</TableHead>
|
||||||
|
<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">Acum. Anterior</TableHead>
|
||||||
|
<TableHead className="text-right">Neto Mes</TableHead>
|
||||||
|
<TableHead className="text-right">Balance Acum.</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{months.map((m) => {
|
||||||
|
const isSelected = m.month === selectedMonth;
|
||||||
|
const isCurrent = m.month === currentMonth && m.year === currentYear;
|
||||||
|
const isBeforeFreshStart =
|
||||||
|
year === FRESH_START_YEAR && m.month < FRESH_START_MONTH;
|
||||||
|
const isEditing = editingMonth === m.month;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={m.month}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-colors',
|
||||||
|
isSelected && 'bg-accent',
|
||||||
|
isCurrent && !isSelected && 'bg-accent/40',
|
||||||
|
isBeforeFreshStart && 'opacity-40',
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectMonth(m.month)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{MONTH_NAMES[m.month]}
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell data-sensitive className="text-right font-mono text-sm text-primary">
|
||||||
|
{formatAmount(m.projected_income, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell data-sensitive className="text-right font-mono text-sm">
|
||||||
|
{formatAmount(m.projected_fixed_expenses, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell data-sensitive className="text-right font-mono text-sm text-muted-foreground">
|
||||||
|
{formatAmount(m.uncovered_actual, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell data-sensitive className="text-right font-mono text-sm font-medium">
|
||||||
|
{formatAmount(m.gran_total_egresos, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={cn(
|
||||||
|
'text-right font-mono text-sm',
|
||||||
|
m.carryover_balance >= 0
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBeforeFreshStart
|
||||||
|
? '—'
|
||||||
|
: <span data-sensitive>
|
||||||
|
{m.carryover_balance >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(m.carryover_balance, 'CRC')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
'text-right font-mono text-sm font-semibold',
|
||||||
|
m.net_balance >= 0 ? 'text-primary' : 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.net_balance >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(m.net_balance, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="text-right font-mono text-sm font-semibold p-0 pr-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (isBeforeFreshStart) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isEditing) handleStartEdit(m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBeforeFreshStart ? (
|
||||||
|
<span className="px-2">—</span>
|
||||||
|
) : isEditing ? (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleSave}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="h-7 w-36 text-right font-mono text-sm ml-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded cursor-pointer hover:bg-muted/50',
|
||||||
|
m.cumulative_balance >= 0
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.balance_overridden && (
|
||||||
|
<Pencil className="w-3 h-3 text-amber-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span data-sensitive>
|
||||||
|
{m.cumulative_balance >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(m.cumulative_balance, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 { 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 { formatAmount } from '@/lib/format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -10,14 +10,18 @@ import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'
|
|||||||
|
|
||||||
interface TransactionColumnOptions {
|
interface TransactionColumnOptions {
|
||||||
showCategory: boolean;
|
showCategory: boolean;
|
||||||
|
showSourceIcon?: boolean;
|
||||||
onEdit: (tx: Transaction) => void;
|
onEdit: (tx: Transaction) => void;
|
||||||
onDelete: (txId: number) => void;
|
onDelete: (txId: number) => void;
|
||||||
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTransactionColumns({
|
export function getTransactionColumns({
|
||||||
showCategory,
|
showCategory,
|
||||||
|
showSourceIcon,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onToggleDeferred,
|
||||||
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
||||||
const columns: ColumnDef<Transaction, unknown>[] = [
|
const columns: ColumnDef<Transaction, unknown>[] = [
|
||||||
{
|
{
|
||||||
@@ -43,18 +47,29 @@ export function getTransactionColumns({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-6 h-6 rounded flex items-center justify-center shrink-0',
|
'w-6 h-6 rounded flex items-center justify-center shrink-0',
|
||||||
tx.transaction_type === 'DEVOLUCION'
|
tx.transaction_type === 'COMPRA'
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-destructive/10 text-destructive'
|
||||||
: 'bg-destructive/10 text-destructive',
|
: 'bg-primary/10 text-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tx.transaction_type === 'DEVOLUCION' ? (
|
{tx.transaction_type === 'COMPRA' ? (
|
||||||
<TrendingUp className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="w-3 h-3" />
|
<TrendingDown className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate">{tx.merchant}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -88,12 +103,13 @@ export function getTransactionColumns({
|
|||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
data-sensitive
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono font-medium',
|
'font-mono font-medium',
|
||||||
tx.transaction_type === 'DEVOLUCION' && 'text-primary',
|
tx.transaction_type !== 'COMPRA' && 'text-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
|
||||||
{formatAmount(tx.amount, tx.currency)}
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -108,6 +124,18 @@ export function getTransactionColumns({
|
|||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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-slot="tabs"
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -22,7 +22,7 @@ function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -56,10 +56,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|||||||
<TabsPrimitive.Tab
|
<TabsPrimitive.Tab
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
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",
|
"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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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);
|
||||||
103
frontend/src/hooks/useBudget.ts
Normal file
103
frontend/src/hooks/useBudget.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
type YearlyProjection,
|
||||||
|
type MonthlyDetail,
|
||||||
|
type RecurringItem,
|
||||||
|
type RecurringItemCreate,
|
||||||
|
type RecurringItemUpdate,
|
||||||
|
getYearlyProjection,
|
||||||
|
getMonthlyDetail,
|
||||||
|
getRecurringItems,
|
||||||
|
createRecurringItem,
|
||||||
|
updateRecurringItem as apiUpdateItem,
|
||||||
|
deleteRecurringItem as apiDeleteItem,
|
||||||
|
upsertBalanceOverride,
|
||||||
|
deleteBalanceOverride,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
export function useBudget(initialYear: number) {
|
||||||
|
const [year, setYear] = useState(initialYear);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<number>(new Date().getMonth() + 1);
|
||||||
|
const [projection, setProjection] = useState<YearlyProjection | null>(null);
|
||||||
|
const [monthDetail, setMonthDetail] = useState<MonthlyDetail | null>(null);
|
||||||
|
const [recurringItems, setRecurringItems] = useState<RecurringItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [monthLoading, setMonthLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchProjection = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getYearlyProjection(year);
|
||||||
|
setProjection(data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
const fetchMonthDetail = useCallback(async () => {
|
||||||
|
setMonthLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getMonthlyDetail(year, selectedMonth);
|
||||||
|
setMonthDetail(data);
|
||||||
|
} finally {
|
||||||
|
setMonthLoading(false);
|
||||||
|
}
|
||||||
|
}, [year, selectedMonth]);
|
||||||
|
|
||||||
|
const fetchRecurringItems = useCallback(async () => {
|
||||||
|
const { data } = await getRecurringItems();
|
||||||
|
setRecurringItems(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjection();
|
||||||
|
fetchRecurringItems();
|
||||||
|
}, [fetchProjection, fetchRecurringItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMonthDetail();
|
||||||
|
}, [fetchMonthDetail]);
|
||||||
|
|
||||||
|
const addItem = async (data: RecurringItemCreate) => {
|
||||||
|
await createRecurringItem(data);
|
||||||
|
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = async (id: number, data: RecurringItemUpdate) => {
|
||||||
|
await apiUpdateItem(id, data);
|
||||||
|
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = async (id: number) => {
|
||||||
|
await apiDeleteItem(id);
|
||||||
|
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBalanceOverride = async (overrideYear: number, month: number, value: number) => {
|
||||||
|
await upsertBalanceOverride(overrideYear, month, value);
|
||||||
|
await fetchProjection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearBalanceOverride = async (overrideYear: number, month: number) => {
|
||||||
|
await deleteBalanceOverride(overrideYear, month);
|
||||||
|
await fetchProjection();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
setYear,
|
||||||
|
selectedMonth,
|
||||||
|
setSelectedMonth,
|
||||||
|
projection,
|
||||||
|
monthDetail,
|
||||||
|
recurringItems,
|
||||||
|
loading,
|
||||||
|
monthLoading,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
saveBalanceOverride,
|
||||||
|
clearBalanceOverride,
|
||||||
|
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/noto-sans";
|
||||||
@import "@fontsource-variable/ibm-plex-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 *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: "Noto Sans Variable", sans-serif;
|
||||||
|
--font-heading: "IBM Plex Sans Variable", sans-serif;
|
||||||
|
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@@ -25,11 +28,11 @@
|
|||||||
--border: oklch(0.92 0.004 286.32);
|
--border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
--chart-1: oklch(0.905 0.182 98.111);
|
--chart-1: oklch(0.55 0.16 145);
|
||||||
--chart-2: oklch(0.795 0.184 86.047);
|
--chart-2: oklch(0.62 0.19 25);
|
||||||
--chart-3: oklch(0.681 0.162 75.834);
|
--chart-3: oklch(0.58 0.14 250);
|
||||||
--chart-4: oklch(0.554 0.135 66.442);
|
--chart-4: oklch(0.68 0.15 80);
|
||||||
--chart-5: oklch(0.476 0.114 61.907);
|
--chart-5: oklch(0.52 0.13 320);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -39,6 +42,8 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
|
||||||
|
--copilot-kit-primary-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -60,11 +65,11 @@
|
|||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
--chart-1: oklch(0.905 0.182 98.111);
|
--chart-1: oklch(0.60 0.16 145);
|
||||||
--chart-2: oklch(0.795 0.184 86.047);
|
--chart-2: oklch(0.67 0.19 25);
|
||||||
--chart-3: oklch(0.681 0.162 75.834);
|
--chart-3: oklch(0.63 0.14 250);
|
||||||
--chart-4: oklch(0.554 0.135 66.442);
|
--chart-4: oklch(0.73 0.15 80);
|
||||||
--chart-5: oklch(0.476 0.114 61.907);
|
--chart-5: oklch(0.57 0.13 320);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.704 0.14 182.503);
|
--sidebar-primary: oklch(0.704 0.14 182.503);
|
||||||
@@ -73,11 +78,39 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--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 {
|
@theme inline {
|
||||||
--font-sans: 'Noto Sans Variable', sans-serif;
|
--font-sans: var(--font-sans);
|
||||||
--font-heading: 'IBM Plex Sans Variable', sans-serif;
|
--font-heading: var(--font-heading);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -121,11 +154,16 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
font-family: var(--font-sans);
|
||||||
html {
|
}
|
||||||
@apply font-sans;
|
}
|
||||||
}
|
|
||||||
|
/* Privacy mode: blur sensitive financial data */
|
||||||
|
.privacy [data-sensitive] {
|
||||||
|
filter: blur(8px);
|
||||||
|
user-select: none;
|
||||||
|
transition: filter 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|||||||
470
frontend/src/lib/api.ts
Normal file
470
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
const BASE_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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestConfig {
|
||||||
|
params?: Record<string, string | number | boolean | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
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 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;
|
||||||
|
currency: string;
|
||||||
|
label: string;
|
||||||
|
balance: number;
|
||||||
|
account_type: string;
|
||||||
|
next_payment: number | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
auto_match_patterns: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
imported: number;
|
||||||
|
duplicates: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: number;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
merchant: string;
|
||||||
|
city: string | null;
|
||||||
|
date: string;
|
||||||
|
card_type: string | null;
|
||||||
|
card_last4: string | null;
|
||||||
|
authorization_code: string | null;
|
||||||
|
reference: string | null;
|
||||||
|
transaction_type: string;
|
||||||
|
source: string;
|
||||||
|
bank: string;
|
||||||
|
notes: string | null;
|
||||||
|
category_id: number | null;
|
||||||
|
category: Category | null;
|
||||||
|
deferred_to_next_cycle: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Budget / Recurring Items ---
|
||||||
|
|
||||||
|
export type RecurringItemType = "INCOME" | "EXPENSE";
|
||||||
|
export type RecurringFrequency =
|
||||||
|
| "WEEKLY"
|
||||||
|
| "MONTHLY"
|
||||||
|
| "QUARTERLY"
|
||||||
|
| "BIANNUAL"
|
||||||
|
| "YEARLY";
|
||||||
|
|
||||||
|
export interface RecurringItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
item_type: RecurringItemType;
|
||||||
|
frequency: RecurringFrequency;
|
||||||
|
day_of_month: number | null;
|
||||||
|
month_of_year: number | null;
|
||||||
|
override_amounts: Record<string, number> | null;
|
||||||
|
category_id: number | null;
|
||||||
|
is_active: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
category: Category | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringItemCreate {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
currency?: string;
|
||||||
|
item_type: RecurringItemType;
|
||||||
|
frequency?: RecurringFrequency;
|
||||||
|
day_of_month?: number | null;
|
||||||
|
month_of_year?: number | null;
|
||||||
|
override_amounts?: Record<string, number> | null;
|
||||||
|
category_id?: number | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringItemUpdate {
|
||||||
|
name?: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
item_type?: RecurringItemType;
|
||||||
|
frequency?: RecurringFrequency;
|
||||||
|
day_of_month?: number | null;
|
||||||
|
month_of_year?: number | null;
|
||||||
|
override_amounts?: Record<string, number> | null;
|
||||||
|
category_id?: number | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringItemDetail {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
projected_amount: number | null;
|
||||||
|
used_actual: boolean;
|
||||||
|
item_type: string;
|
||||||
|
frequency: string;
|
||||||
|
category_name: string | null;
|
||||||
|
category_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActualsBySource {
|
||||||
|
source: string;
|
||||||
|
total_compra: number;
|
||||||
|
total_devolucion: number;
|
||||||
|
net: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyProjection {
|
||||||
|
month: number;
|
||||||
|
year: number;
|
||||||
|
projected_income: number;
|
||||||
|
projected_fixed_expenses: number;
|
||||||
|
actual_credit_card: number;
|
||||||
|
actual_cash: number;
|
||||||
|
actual_transfers: number;
|
||||||
|
uncovered_actual: number;
|
||||||
|
gran_total_egresos: number;
|
||||||
|
net_balance: number;
|
||||||
|
carryover_balance: number;
|
||||||
|
cumulative_balance: number;
|
||||||
|
balance_overridden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YearlyProjection {
|
||||||
|
year: number;
|
||||||
|
months: MonthlyProjection[];
|
||||||
|
annual_income: number;
|
||||||
|
annual_expenses: number;
|
||||||
|
annual_net: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyDetail {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
income_items: RecurringItemDetail[];
|
||||||
|
expense_items: RecurringItemDetail[];
|
||||||
|
actuals_by_source: ActualsBySource[];
|
||||||
|
total_projected_income: number;
|
||||||
|
total_projected_expenses: number;
|
||||||
|
uncovered_actual: number;
|
||||||
|
gran_total_egresos: number;
|
||||||
|
net_balance: number;
|
||||||
|
cc_by_category: { category_name: string; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
|
||||||
|
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
|
||||||
|
export const deleteRecurringItem = (id: number) =>
|
||||||
|
api.delete(`/budget/recurring/${id}`);
|
||||||
|
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,
|
||||||
|
) =>
|
||||||
|
api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
|
||||||
|
export const deleteBalanceOverride = (year: number, month: number) =>
|
||||||
|
api.delete(`/budget/balance-override/${year}/${month}`);
|
||||||
|
|
||||||
|
// --- Salarios ---
|
||||||
|
|
||||||
|
export interface SalariosSummary {
|
||||||
|
count: number;
|
||||||
|
total_amount: number;
|
||||||
|
latest_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
|
||||||
|
api.get<Transaction[]>("/salarios/", { params });
|
||||||
|
export const getSalariosSummary = () =>
|
||||||
|
api.get<SalariosSummary>("/salarios/summary");
|
||||||
|
|
||||||
|
// --- Pensions ---
|
||||||
|
|
||||||
|
export interface PensionSnapshot {
|
||||||
|
id: number;
|
||||||
|
fund: string;
|
||||||
|
contract_number: string;
|
||||||
|
period_start: string;
|
||||||
|
period_end: string;
|
||||||
|
saldo_anterior: number;
|
||||||
|
aportes: number;
|
||||||
|
rendimientos: number;
|
||||||
|
retiros: number;
|
||||||
|
traslados: number;
|
||||||
|
comision: number;
|
||||||
|
correccion: number;
|
||||||
|
bonificacion: number;
|
||||||
|
saldo_final: number;
|
||||||
|
source_filename: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PensionUploadResult {
|
||||||
|
imported: number;
|
||||||
|
updated: number;
|
||||||
|
duplicates: number;
|
||||||
|
errors: string[];
|
||||||
|
snapshots: PensionSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PensionManualEntry {
|
||||||
|
fund: string;
|
||||||
|
period_start: string;
|
||||||
|
period_end: string;
|
||||||
|
saldo_anterior: number;
|
||||||
|
aportes: number;
|
||||||
|
rendimientos: number;
|
||||||
|
retiros: number;
|
||||||
|
traslados: number;
|
||||||
|
comision: number;
|
||||||
|
correccion: number;
|
||||||
|
bonificacion: number;
|
||||||
|
saldo_final: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadPensionPDFs = (files: File[]) => {
|
||||||
|
const form = new FormData();
|
||||||
|
files.forEach((f) => form.append("files", f));
|
||||||
|
return api.post<PensionUploadResult>("/pensions/upload", form);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPensionSnapshots = () =>
|
||||||
|
api.get<PensionSnapshot[]>("/pensions/snapshots");
|
||||||
|
export const getPensionFundSummary = () =>
|
||||||
|
api.get<PensionSnapshot[]>("/pensions/fund-summary");
|
||||||
|
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
|
||||||
|
api.post<PensionUploadResult>("/pensions/manual", { entries });
|
||||||
|
|
||||||
|
// --- Municipal Receipts ---
|
||||||
|
|
||||||
|
export interface MunicipalCharge {
|
||||||
|
detail: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterMeterReading {
|
||||||
|
id: number;
|
||||||
|
meter_id: string;
|
||||||
|
period: string;
|
||||||
|
reading_previous: number;
|
||||||
|
reading_current: number;
|
||||||
|
consumption_m3: number;
|
||||||
|
agua_potable: number;
|
||||||
|
serv_ambientales: number;
|
||||||
|
alcant_sanitario: number;
|
||||||
|
iva: number;
|
||||||
|
is_historical: boolean;
|
||||||
|
receipt_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MunicipalReceipt {
|
||||||
|
id: number;
|
||||||
|
receipt_date: string;
|
||||||
|
due_date: string;
|
||||||
|
period: string;
|
||||||
|
account: string;
|
||||||
|
finca: string;
|
||||||
|
holder_name: string;
|
||||||
|
holder_cedula: string;
|
||||||
|
holder_address: string;
|
||||||
|
subtotal: number;
|
||||||
|
interests: number;
|
||||||
|
iva: number;
|
||||||
|
total: number;
|
||||||
|
raw_charges: MunicipalCharge[];
|
||||||
|
source_filename: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MunicipalReceiptDetail extends MunicipalReceipt {
|
||||||
|
water_readings: WaterMeterReading[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MunicipalReceiptUploadResult {
|
||||||
|
imported: number;
|
||||||
|
updated: number;
|
||||||
|
errors: string[];
|
||||||
|
receipt: MunicipalReceipt | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadMunicipalReceipt = (file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
return api.post<MunicipalReceiptUploadResult>(
|
||||||
|
"/municipal-receipts/upload",
|
||||||
|
form,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMunicipalReceipts = () =>
|
||||||
|
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", {
|
||||||
|
params: months ? { months } : undefined,
|
||||||
|
});
|
||||||
@@ -5,9 +5,17 @@ export function formatAmount(amount: number, currency: string) {
|
|||||||
if (currency === 'USD') {
|
if (currency === 'USD') {
|
||||||
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
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 })}`;
|
return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatLocalDatetime(d: Date): string {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string) {
|
export function formatDate(dateStr: string) {
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|||||||
179
frontend/src/lib/parsePensionPaste.ts
Normal file
179
frontend/src/lib/parsePensionPaste.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
export interface PensionParsedEntry {
|
||||||
|
fund: string;
|
||||||
|
period_start: string; // YYYY-MM-DD
|
||||||
|
period_end: string;
|
||||||
|
saldo_anterior: number;
|
||||||
|
aportes: number;
|
||||||
|
rendimientos: number;
|
||||||
|
retiros: number;
|
||||||
|
traslados: number;
|
||||||
|
comision: number;
|
||||||
|
correccion: number;
|
||||||
|
bonificacion: number;
|
||||||
|
saldo_final: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAmount(raw: string): number {
|
||||||
|
const cleaned = raw.replace(/[¢\s]/g, '').replace(/,/g, '');
|
||||||
|
const num = parseFloat(cleaned);
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateDMY(raw: string): string {
|
||||||
|
const m = raw.match(/(\d{2})\/(\d{2})\/(\d{4})/);
|
||||||
|
if (!m) return '';
|
||||||
|
return `${m[3]}-${m[2]}-${m[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAmounts(line: string): number[] {
|
||||||
|
const matches = line.match(/¢\s*-?[\d,.]+/g);
|
||||||
|
if (!matches) return [];
|
||||||
|
return matches.map(parseAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field labels in the order they appear in the bank statement
|
||||||
|
const FIELD_LABELS: [RegExp, string][] = [
|
||||||
|
[/saldo\s*anterior/i, 'saldo_anterior'],
|
||||||
|
[/aportes/i, 'aportes'],
|
||||||
|
[/rendimientos/i, 'rendimientos'],
|
||||||
|
[/retiros/i, 'retiros'],
|
||||||
|
[/traslados/i, 'traslados'],
|
||||||
|
[/comisi[oó]n/i, 'comision'],
|
||||||
|
[/bonificaci[oó]n/i, 'bonificacion'],
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BlockResult {
|
||||||
|
funds: string[];
|
||||||
|
fields: Record<string, number[]>;
|
||||||
|
period_start: string;
|
||||||
|
period_end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlock(lines: string[]): BlockResult | null {
|
||||||
|
const result: BlockResult = {
|
||||||
|
funds: [],
|
||||||
|
fields: {},
|
||||||
|
period_start: '',
|
||||||
|
period_end: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect fund columns from header
|
||||||
|
const headerLine = lines.find((l) => /resumen del per[ií]odo/i.test(l));
|
||||||
|
if (!headerLine) return null;
|
||||||
|
|
||||||
|
if (/\bROP\b/i.test(headerLine) && /\bFCL\b/i.test(headerLine)) {
|
||||||
|
result.funds = ['ROP', 'FCL'];
|
||||||
|
} else if (/voluntario/i.test(headerLine) || /\bVOL\b/i.test(headerLine)) {
|
||||||
|
result.funds = ['VOL'];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 1: Try same-line parsing (label + amounts on same line)
|
||||||
|
// Strategy 2: Collect standalone amount lines for split-format parsing
|
||||||
|
const detectedFieldOrder: string[] = [];
|
||||||
|
const standaloneAmounts: number[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Check for period
|
||||||
|
const periodMatch = line.match(/del\s+(\d{2}\/\d{2}\/\d{4})\s+al\s+(\d{2}\/\d{2}\/\d{4})/i);
|
||||||
|
if (periodMatch) {
|
||||||
|
result.period_start = parseDateDMY(periodMatch[1]);
|
||||||
|
result.period_end = parseDateDMY(periodMatch[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for "Saldo Actual" line (always has amounts inline)
|
||||||
|
if (/saldo\s*actual/i.test(line)) {
|
||||||
|
const amounts = extractAmounts(line);
|
||||||
|
if (amounts.length > 0) {
|
||||||
|
result.fields['saldo_final'] = amounts;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this line matches a field label
|
||||||
|
let matchedLabel = false;
|
||||||
|
for (const [regex, key] of FIELD_LABELS) {
|
||||||
|
if (regex.test(line)) {
|
||||||
|
matchedLabel = true;
|
||||||
|
const amounts = extractAmounts(line);
|
||||||
|
if (amounts.length > 0) {
|
||||||
|
// Strategy 1: amounts on same line as label
|
||||||
|
result.fields[key] = amounts;
|
||||||
|
} else {
|
||||||
|
// Strategy 2: label-only line, record the order
|
||||||
|
detectedFieldOrder.push(key);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not a label line, check if it's a standalone amount line
|
||||||
|
if (!matchedLabel) {
|
||||||
|
const amounts = extractAmounts(line);
|
||||||
|
if (amounts.length === 1) {
|
||||||
|
standaloneAmounts.push(amounts[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have standalone amounts and field labels, map them
|
||||||
|
// Format: N labels, then N amounts for fund1, then N amounts for fund2, ...
|
||||||
|
if (detectedFieldOrder.length > 0 && standaloneAmounts.length > 0) {
|
||||||
|
const numFields = detectedFieldOrder.length;
|
||||||
|
const numFunds = result.funds.length;
|
||||||
|
|
||||||
|
if (standaloneAmounts.length >= numFields * numFunds) {
|
||||||
|
for (let f = 0; f < numFunds; f++) {
|
||||||
|
for (let i = 0; i < numFields; i++) {
|
||||||
|
const key = detectedFieldOrder[i];
|
||||||
|
if (!result.fields[key]) result.fields[key] = [];
|
||||||
|
result.fields[key].push(standaloneAmounts[f * numFields + i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePensionPaste(text: string): PensionParsedEntry[] {
|
||||||
|
// Split into blocks by "---" or multiple blank lines
|
||||||
|
const blocks = text.split(/(?:^|\n)-{3,}(?:\n|$)|\n{3,}/);
|
||||||
|
const entries: PensionParsedEntry[] = [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const lines = block.split('\n').filter((l) => l.trim());
|
||||||
|
if (lines.length < 3) continue;
|
||||||
|
|
||||||
|
const parsed = parseBlock(lines);
|
||||||
|
if (!parsed || !parsed.period_start || !parsed.period_end) continue;
|
||||||
|
|
||||||
|
for (let i = 0; i < parsed.funds.length; i++) {
|
||||||
|
const fund = parsed.funds[i];
|
||||||
|
const get = (key: string): number => {
|
||||||
|
const vals = parsed.fields[key];
|
||||||
|
if (!vals) return 0;
|
||||||
|
return vals[i] ?? vals[0] ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
fund,
|
||||||
|
period_start: parsed.period_start,
|
||||||
|
period_end: parsed.period_end,
|
||||||
|
saldo_anterior: get('saldo_anterior'),
|
||||||
|
aportes: get('aportes'),
|
||||||
|
rendimientos: get('rendimientos'),
|
||||||
|
retiros: get('retiros'),
|
||||||
|
traslados: get('traslados'),
|
||||||
|
comision: get('comision'),
|
||||||
|
correccion: 0,
|
||||||
|
bonificacion: get('bonificacion'),
|
||||||
|
saldo_final: get('saldo_final'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
51
frontend/src/lib/push-notifications.ts
Normal file
51
frontend/src/lib/push-notifications.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeToPush(): Promise<void> {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ publicKey: string }>('/notifications/vapid-public-key');
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
if (existing) {
|
||||||
|
await sendSubscriptionToServer(existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(data.publicKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendSubscriptionToServer(subscription);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Push subscription failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||||
|
const json = subscription.toJSON();
|
||||||
|
await api.post('/notifications/subscribe', {
|
||||||
|
endpoint: json.endpoint,
|
||||||
|
keys: json.keys,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from "react-dom/client";
|
||||||
import App from './App';
|
import App from "./App";
|
||||||
import './index.css';
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.register('/sw.js');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { BarChart3 } from 'lucide-react';
|
import { BarChart3 } from 'lucide-react';
|
||||||
|
|
||||||
import api from '../api';
|
import api from '@/lib/api';
|
||||||
import BillingCycleSelector from '../components/BillingCycleSelector';
|
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -46,9 +46,8 @@ interface DailySpending {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)',
|
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
|
||||||
'oklch(0.7 0.15 30)', 'oklch(0.65 0.2 300)', 'oklch(0.6 0.15 150)',
|
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
|
||||||
'oklch(0.75 0.12 60)', 'oklch(0.55 0.18 250)',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatCRC(value: number) {
|
function formatCRC(value: number) {
|
||||||
@@ -132,7 +131,7 @@ export default function Analytics() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center">
|
<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>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={byCategory}
|
data={byCategory}
|
||||||
@@ -168,7 +167,7 @@ export default function Analytics() {
|
|||||||
style={{ background: COLORS[i % COLORS.length] }}
|
style={{ background: COLORS[i % COLORS.length] }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground truncate">{cat.category_name}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +189,7 @@ export default function Analytics() {
|
|||||||
No data
|
No data
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={trendChartConfig} className="h-[300px] w-full">
|
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
|
||||||
<BarChart data={trend}>
|
<BarChart data={trend}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
@@ -229,7 +228,7 @@ export default function Analytics() {
|
|||||||
No data for this period
|
No data for this period
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={dailyChartConfig} className="h-[240px] w-full">
|
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
|
||||||
<LineChart data={daily}>
|
<LineChart data={daily}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
@@ -287,11 +286,11 @@ export default function Analytics() {
|
|||||||
style={{ background: COLORS[i % COLORS.length] }}
|
style={{ background: COLORS[i % COLORS.length] }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm flex-1">{cat.category_name}</span>
|
<span className="text-sm flex-1">{cat.category_name}</span>
|
||||||
<span className="text-xs text-muted-foreground">{cat.count} txns</span>
|
<span data-sensitive 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-sm font-mono font-medium w-32 text-right">
|
||||||
{formatCRC(cat.total)}
|
{formatCRC(cat.total)}
|
||||||
</span>
|
</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
|
<div
|
||||||
className="h-1.5 rounded-full"
|
className="h-1.5 rounded-full"
|
||||||
style={{
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
frontend/src/pages/Budget.tsx
Normal file
204
frontend/src/pages/Budget.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react';
|
||||||
|
|
||||||
|
import api, { type Transaction } from '@/lib/api';
|
||||||
|
import { useBudget } from '@/hooks/useBudget';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
||||||
|
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
||||||
|
import TransactionList from '@/components/TransactionList';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_YEAR = 2026;
|
||||||
|
const MAX_YEAR = 2030;
|
||||||
|
|
||||||
|
export default function Budget() {
|
||||||
|
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
|
||||||
|
const {
|
||||||
|
year,
|
||||||
|
setYear,
|
||||||
|
selectedMonth,
|
||||||
|
setSelectedMonth,
|
||||||
|
monthDetail,
|
||||||
|
recurringItems,
|
||||||
|
monthLoading,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
refresh,
|
||||||
|
} = useBudget(currentYear);
|
||||||
|
|
||||||
|
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_AND_TRANSFER'>('CREDIT_CARD');
|
||||||
|
|
||||||
|
const fetchTransactions = useCallback(async () => {
|
||||||
|
setTxLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
search: txSearch || undefined,
|
||||||
|
limit: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Calculator className="w-6 h-6 text-primary" />
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</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>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Resumen</TabsTrigger>
|
||||||
|
<TabsTrigger value="items">Items Recurrentes</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="mt-4">
|
||||||
|
<Tabs
|
||||||
|
value={subTab}
|
||||||
|
onValueChange={(v) => setSubTab(v as typeof subTab)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<MonthlyDetail
|
||||||
|
detail={monthDetail}
|
||||||
|
loading={monthLoading || !monthDetail}
|
||||||
|
onNavigateToTransactions={handleNavigateToTransactions}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="transactions" className="space-y-3 mt-4">
|
||||||
|
<Tabs
|
||||||
|
value={txSource}
|
||||||
|
onValueChange={(v) => setTxSource(v as typeof txSource)}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
|
||||||
|
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<TransactionList
|
||||||
|
transactions={transactions}
|
||||||
|
loading={txLoading}
|
||||||
|
source={txSource === 'CREDIT_CARD' ? 'CREDIT_CARD' : 'CASH'}
|
||||||
|
search={txSearch}
|
||||||
|
onSearchChange={setTxSearch}
|
||||||
|
onRefresh={() => {
|
||||||
|
fetchTransactions();
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
showCategory={txSource === 'CREDIT_CARD'}
|
||||||
|
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>
|
||||||
|
</Tabs>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="items" className="mt-4">
|
||||||
|
<RecurringItemsManager
|
||||||
|
items={recurringItems}
|
||||||
|
onAdd={addItem}
|
||||||
|
onUpdate={updateItem}
|
||||||
|
onDelete={deleteItem}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
RefreshCw,
|
|
||||||
CreditCard,
|
|
||||||
Pencil,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import api, { type Account, type Transaction } from '../api';
|
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
|
||||||
import { formatAmount, formatDate } 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 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 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 { 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 className="text-foreground font-bold">{netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')}</span></span>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<span>Assets <span className="text-foreground">{formatAmount(netWorthBreakdown.assets, 'CRC')}</span></span>
|
|
||||||
<span>Liabilities <span 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 className="text-lg font-bold font-mono">Buy: ₡{exchangeRate.buy_rate.toFixed(2)}</span>
|
|
||||||
<span 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 === 'DEVOLUCION' ? 'bg-primary/10 text-primary' : 'bg-destructive/10 text-destructive'
|
|
||||||
)}>
|
|
||||||
{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 className={cn(
|
|
||||||
'font-mono text-sm font-medium shrink-0 ml-4',
|
|
||||||
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
|
|
||||||
)}>
|
|
||||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}{formatAmount(tx.amount, tx.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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,32 +1,31 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type FormEvent } from "react";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
|
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';
|
export default function LoginPage() {
|
||||||
import { useAuth } from '../AuthContext';
|
const [username, setUsername] = useState("");
|
||||||
import { Button } from '@/components/ui/button';
|
const [password, setPassword] = useState("");
|
||||||
import { Input } from '@/components/ui/input';
|
const [error, setError] = useState("");
|
||||||
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('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setAuthenticated } = useAuth();
|
const { setAuthenticated } = useAuth();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
navigate('/');
|
navigate("/asistente", { replace: true });
|
||||||
} catch {
|
} catch {
|
||||||
setError('Invalid credentials');
|
setError("Invalid credentials");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,7 +38,7 @@ export default function Login() {
|
|||||||
<div className="w-10 h-10 rounded-lg bg-primary flex items-center justify-center">
|
<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} />
|
<Wallet className="w-6 h-6 text-primary-foreground" strokeWidth={2.5} />
|
||||||
</div>
|
</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>
|
Wealthy<span className="text-primary">Smart</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +81,7 @@ export default function Login() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" disabled={loading} className="w-full h-10">
|
<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" />}
|
{!loading && <ArrowRight className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
871
frontend/src/pages/Pensions.tsx
Normal file
871
frontend/src/pages/Pensions.tsx
Normal file
@@ -0,0 +1,871 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
PiggyBank,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
TrendingUp,
|
||||||
|
Calendar,
|
||||||
|
Percent,
|
||||||
|
Banknote,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
uploadPensionPDFs,
|
||||||
|
getPensionFundSummary,
|
||||||
|
getPensionSnapshots,
|
||||||
|
type PensionSnapshot,
|
||||||
|
type PensionUploadResult,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
|
||||||
|
import { ClipboardPaste } from 'lucide-react';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FundKey = 'FCL' | 'ROP' | 'VOL';
|
||||||
|
|
||||||
|
interface FundDef {
|
||||||
|
key: FundKey;
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
color: string;
|
||||||
|
startBalance: number;
|
||||||
|
monthlyContribution: number;
|
||||||
|
annualRate: number;
|
||||||
|
isDividend: boolean;
|
||||||
|
withdrawalRule: string;
|
||||||
|
defaultTargetAge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectionState {
|
||||||
|
contribution: number;
|
||||||
|
rate: number;
|
||||||
|
targetAge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartDataPoint extends Record<FundKey, number> {
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipEntry {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CURRENT_AGE = 30;
|
||||||
|
|
||||||
|
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'VOL'];
|
||||||
|
|
||||||
|
const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
|
||||||
|
FCL: {
|
||||||
|
key: 'FCL',
|
||||||
|
name: 'FCL',
|
||||||
|
fullName: 'Fondo de Capitalización Laboral',
|
||||||
|
color: '#3b82f6',
|
||||||
|
startBalance: 650_468,
|
||||||
|
monthlyContribution: 150_000,
|
||||||
|
annualRate: 7.5,
|
||||||
|
isDividend: false,
|
||||||
|
withdrawalRule: 'Retirable cada 5 años o al cambio de empleo',
|
||||||
|
defaultTargetAge: 35,
|
||||||
|
},
|
||||||
|
ROP: {
|
||||||
|
key: 'ROP',
|
||||||
|
name: 'ROP',
|
||||||
|
fullName: 'Régimen Obligatorio de Pensiones',
|
||||||
|
color: '#10b981',
|
||||||
|
startBalance: 18_684_765,
|
||||||
|
monthlyContribution: 120_000,
|
||||||
|
annualRate: 6.0,
|
||||||
|
isDividend: false,
|
||||||
|
withdrawalRule: 'Retirable a los 65 años',
|
||||||
|
defaultTargetAge: 65,
|
||||||
|
},
|
||||||
|
VOL: {
|
||||||
|
key: 'VOL',
|
||||||
|
name: 'VOL',
|
||||||
|
fullName: 'Fondo Voluntario',
|
||||||
|
color: '#f43f5e',
|
||||||
|
startBalance: 2_500_381,
|
||||||
|
monthlyContribution: 400_000,
|
||||||
|
annualRate: 8.0,
|
||||||
|
isDividend: false,
|
||||||
|
withdrawalRule: 'Accesible a los 57 años',
|
||||||
|
defaultTargetAge: 57,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTH_NAMES_ES = [
|
||||||
|
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||||
|
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const formatCRC = (amount: number): string =>
|
||||||
|
new Intl.NumberFormat('es-CR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CRC',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
|
||||||
|
function buildChartFromSnapshots(snapshots: PensionSnapshot[]): ChartDataPoint[] {
|
||||||
|
// Group by period_end month key (YYYY-MM)
|
||||||
|
const byMonth = new Map<string, Record<string, number>>();
|
||||||
|
|
||||||
|
for (const snap of snapshots) {
|
||||||
|
const d = new Date(snap.period_end);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
if (!byMonth.has(key)) byMonth.set(key, {});
|
||||||
|
const entry = byMonth.get(key)!;
|
||||||
|
const fund = snap.fund as FundKey;
|
||||||
|
// Keep the latest saldo_final per fund per month
|
||||||
|
entry[fund] = Math.round(snap.saldo_final);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chronologically and take last 12
|
||||||
|
const sortedKeys = Array.from(byMonth.keys()).sort();
|
||||||
|
const last12 = sortedKeys.slice(-12);
|
||||||
|
|
||||||
|
return last12.map((key) => {
|
||||||
|
const [yearStr, monthStr] = key.split('-');
|
||||||
|
const monthIdx = parseInt(monthStr, 10) - 1;
|
||||||
|
const yearShort = yearStr.slice(2);
|
||||||
|
const label = `${MONTH_NAMES_ES[monthIdx]} ${yearShort}`;
|
||||||
|
const values = byMonth.get(key)!;
|
||||||
|
return {
|
||||||
|
month: label,
|
||||||
|
FCL: values.FCL ?? 0,
|
||||||
|
ROP: values.ROP ?? 0,
|
||||||
|
VOL: values.VOL ?? 0,
|
||||||
|
} as ChartDataPoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcProjection(
|
||||||
|
currentBalance: number,
|
||||||
|
monthlyContribution: number,
|
||||||
|
annualRate: number,
|
||||||
|
targetAge: number,
|
||||||
|
isDividend: boolean,
|
||||||
|
): number {
|
||||||
|
const years = Math.max(0, targetAge - CURRENT_AGE);
|
||||||
|
if (years === 0) return currentBalance;
|
||||||
|
|
||||||
|
if (isDividend) {
|
||||||
|
let balance = currentBalance;
|
||||||
|
const rate = annualRate / 100;
|
||||||
|
for (let y = 0; y < years; y++) {
|
||||||
|
balance = balance + monthlyContribution * 12 + balance * rate;
|
||||||
|
}
|
||||||
|
return balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = annualRate / 100 / 12;
|
||||||
|
const n = years * 12;
|
||||||
|
if (r === 0) return currentBalance + monthlyContribution * n;
|
||||||
|
return (
|
||||||
|
currentBalance * Math.pow(1 + r, n) +
|
||||||
|
monthlyContribution * ((Math.pow(1 + r, n) - 1) / r)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge API snapshots into the default fund definitions. */
|
||||||
|
function applySnapshots(
|
||||||
|
snapshots: PensionSnapshot[],
|
||||||
|
): Record<FundKey, FundDef> {
|
||||||
|
const funds = { ...FUNDS_DEFAULT };
|
||||||
|
for (const snap of snapshots) {
|
||||||
|
const key = snap.fund as FundKey;
|
||||||
|
if (key in funds) {
|
||||||
|
funds[key] = { ...funds[key], startBalance: Math.round(snap.saldo_final) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return funds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chart Tooltip ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipEntry[];
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[220px]">
|
||||||
|
<p className="font-semibold mb-2 text-foreground">{label}</p>
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium text-foreground">{formatCRC(entry.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Pensions() {
|
||||||
|
const [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]);
|
||||||
|
const [allSnapshots, setAllSnapshots] = useState<PensionSnapshot[]>([]);
|
||||||
|
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
|
||||||
|
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
|
||||||
|
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
|
||||||
|
ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 },
|
||||||
|
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
|
||||||
|
});
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadResult, setUploadResult] = useState<PensionUploadResult | null>(null);
|
||||||
|
const [showManualEntry, setShowManualEntry] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [summaryRes, snapshotsRes] = await Promise.all([
|
||||||
|
getPensionFundSummary(),
|
||||||
|
getPensionSnapshots(),
|
||||||
|
]);
|
||||||
|
setFundSummary(summaryRes.data);
|
||||||
|
setAllSnapshots(snapshotsRes.data);
|
||||||
|
} catch {
|
||||||
|
// API not available or no data yet — use defaults
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]);
|
||||||
|
|
||||||
|
// Build a map of fund -> latest snapshot for rendimientos display
|
||||||
|
const snapshotByFund = useMemo(() => {
|
||||||
|
const map: Partial<Record<FundKey, PensionSnapshot>> = {};
|
||||||
|
for (const snap of fundSummary) {
|
||||||
|
map[snap.fund as FundKey] = snap;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [fundSummary]);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => buildChartFromSnapshots(allSnapshots), [allSnapshots]);
|
||||||
|
|
||||||
|
const chartDateRange = useMemo(() => {
|
||||||
|
if (chartData.length < 2) return '';
|
||||||
|
return `${chartData[0].month} — ${chartData[chartData.length - 1].month}`;
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
const roiEarned = useMemo(() => {
|
||||||
|
return FUND_KEYS.reduce<Record<FundKey, number>>((acc, key) => {
|
||||||
|
const snap = snapshotByFund[key];
|
||||||
|
if (snap) {
|
||||||
|
// Use real rendimientos from the API
|
||||||
|
acc[key] = Math.round(snap.rendimientos);
|
||||||
|
} else {
|
||||||
|
// Fallback: approximate from chart data
|
||||||
|
const fund = FUNDS[key];
|
||||||
|
const len = chartData.length;
|
||||||
|
if (len >= 2 && fund.isDividend) {
|
||||||
|
acc[key] = Math.max(0, Math.round(
|
||||||
|
chartData[len - 1][key] - chartData[len - 2][key] - fund.monthlyContribution,
|
||||||
|
));
|
||||||
|
} else if (len > 0) {
|
||||||
|
const activeMonths = chartData.filter((d) => d[key] > 0).length;
|
||||||
|
acc[key] = Math.round(fund.startBalance * (fund.annualRate / 100) * (activeMonths / 12));
|
||||||
|
} else {
|
||||||
|
acc[key] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<FundKey, number>);
|
||||||
|
}, [FUNDS, chartData, snapshotByFund]);
|
||||||
|
|
||||||
|
const toggleFund = (key: FundKey) => {
|
||||||
|
setVisibleFunds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
if (next.size > 1) next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProjection = (key: FundKey, field: keyof ProjectionState, value: string) => {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
setProjections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: { ...prev[key], [field]: isNaN(num) ? 0 : num },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFiles = useCallback((files: FileList | null) => {
|
||||||
|
if (!files) return;
|
||||||
|
const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf');
|
||||||
|
setUploadedFiles((prev) => [...prev, ...pdfs]);
|
||||||
|
setUploadResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (uploadedFiles.length === 0) return;
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadResult(null);
|
||||||
|
try {
|
||||||
|
const { data } = await uploadPensionPDFs(uploadedFiles);
|
||||||
|
setUploadResult(data);
|
||||||
|
setUploadedFiles([]);
|
||||||
|
// Refresh fund summary with new data
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
setUploadResult({
|
||||||
|
imported: 0,
|
||||||
|
updated: 0,
|
||||||
|
duplicates: 0,
|
||||||
|
errors: [err instanceof Error ? err.message : 'Error al subir archivos'],
|
||||||
|
snapshots: [],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<PiggyBank className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold font-heading">Pensiones</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Seguimiento de aportes, rendimientos y proyecciones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Banknote className="w-4 h-4" />
|
||||||
|
Fondos
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
|
{FUND_KEYS.map((key) => {
|
||||||
|
const fund = FUNDS[key];
|
||||||
|
const snap = snapshotByFund[key];
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={key}
|
||||||
|
className="border-l-4 overflow-hidden"
|
||||||
|
style={{ borderLeftColor: fund.color }}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="font-bold text-base" style={{ color: fund.color }}>
|
||||||
|
{fund.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant={fund.isDividend ? 'secondary' : 'outline'} className="text-xs">
|
||||||
|
{fund.isDividend ? 'Dividendos' : 'Interés'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
|
||||||
|
{fund.fullName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||||
|
Balance actual
|
||||||
|
</p>
|
||||||
|
<p data-sensitive className="text-xl font-bold font-mono mt-0.5 leading-tight">
|
||||||
|
{formatCRC(fund.startBalance)}
|
||||||
|
</p>
|
||||||
|
{snap && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
al {new Date(snap.period_end).toLocaleDateString('es-CR')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{snap ? (
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Aportes</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium">
|
||||||
|
{formatCRC(snap.aportes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Rendimientos</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium text-emerald-500">
|
||||||
|
{formatCRC(snap.rendimientos)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Comisión</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium text-destructive">
|
||||||
|
{formatCRC(snap.comision)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Aporte mensual</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium">
|
||||||
|
{formatCRC(fund.monthlyContribution)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Tasa anual</span>
|
||||||
|
<span className="font-mono font-medium">{fund.annualRate}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-1.5 text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
|
||||||
|
<Calendar className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{fund.withdrawalRule}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 2: Growth Chart ──────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Evolución del Balance{chartDateRange && ` (${chartDateRange})`}
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{FUND_KEYS.map((key) => {
|
||||||
|
const fund = FUNDS[key];
|
||||||
|
const active = visibleFunds.has(key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => toggleFund(key)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
style={{
|
||||||
|
borderColor: fund.color,
|
||||||
|
background: active ? fund.color + '22' : 'transparent',
|
||||||
|
color: active ? fund.color : 'var(--muted-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ background: active ? fund.color : 'var(--muted-foreground)' }}
|
||||||
|
/>
|
||||||
|
{fund.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||||
|
axisLine={{ stroke: 'var(--border)' }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v: number) => `${(v / 1_000_000).toFixed(1)}M`}
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={52}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltipContent />} />
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{FUND_KEYS.map((key) =>
|
||||||
|
visibleFunds.has(key) ? (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
stroke={FUNDS[key].color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 3: ROI Summary ───────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Percent className="w-4 h-4" />
|
||||||
|
Rendimiento — Último Periodo
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{FUND_KEYS.map((key) => {
|
||||||
|
const fund = FUNDS[key];
|
||||||
|
const snap = snapshotByFund[key];
|
||||||
|
const earned = roiEarned[key];
|
||||||
|
return (
|
||||||
|
<Card key={key}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: fund.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-bold text-sm">{fund.name}</span>
|
||||||
|
</div>
|
||||||
|
{snap ? (
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
{new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
|
||||||
|
{' — '}
|
||||||
|
{new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p data-sensitive className="text-lg font-bold font-mono" style={{ color: fund.color }}>
|
||||||
|
{earned >= 0 ? '+' : ''}{formatCRC(earned)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">en rendimientos</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 4: Projections ───────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Proyecciones
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Basado en edad actual de {CURRENT_AGE} años. Edita los campos para simular escenarios.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
|
{FUND_KEYS.map((key) => {
|
||||||
|
const fund = FUNDS[key];
|
||||||
|
const proj = projections[key];
|
||||||
|
const projected = calcProjection(
|
||||||
|
fund.startBalance,
|
||||||
|
proj.contribution,
|
||||||
|
proj.rate,
|
||||||
|
proj.targetAge,
|
||||||
|
fund.isDividend,
|
||||||
|
);
|
||||||
|
const years = Math.max(0, proj.targetAge - CURRENT_AGE);
|
||||||
|
return (
|
||||||
|
<Card key={key} className="border-l-4" style={{ borderLeftColor: fund.color }}>
|
||||||
|
<CardHeader className="pb-2 pt-4 px-4">
|
||||||
|
<CardTitle className="text-sm font-bold" style={{ color: fund.color }}>
|
||||||
|
{fund.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Aporte mensual (CRC)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={proj.contribution}
|
||||||
|
onChange={(e) => updateProjection(key, 'contribution', e.target.value)}
|
||||||
|
className="h-8 text-sm font-mono mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
{fund.isDividend ? 'Tasa dividendo (%)' : 'Tasa anual (%)'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={proj.rate}
|
||||||
|
onChange={(e) => updateProjection(key, 'rate', e.target.value)}
|
||||||
|
className="h-8 text-sm font-mono mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Edad objetivo</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={proj.targetAge}
|
||||||
|
onChange={(e) => updateProjection(key, 'targetAge', e.target.value)}
|
||||||
|
className="h-8 text-sm font-mono mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="text-center space-y-0.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Valor en {years} {years === 1 ? 'año' : 'años'}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
data-sensitive
|
||||||
|
className="text-lg font-bold font-mono leading-tight"
|
||||||
|
style={{ color: fund.color }}
|
||||||
|
>
|
||||||
|
{formatCRC(Math.round(projected))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Manual Entry Modal ──────────────────────────────────────────── */}
|
||||||
|
{showManualEntry && (
|
||||||
|
<PensionManualEntryModal
|
||||||
|
onClose={() => setShowManualEntry(false)}
|
||||||
|
onImported={loadData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Estados de Cuenta
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowManualEntry(true)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||||
|
Ingresar manualmente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
|
||||||
|
aria-label="Seleccionar archivos PDF"
|
||||||
|
className={[
|
||||||
|
'border-2 border-dashed rounded-lg p-8',
|
||||||
|
'flex flex-col items-center justify-center gap-3',
|
||||||
|
'cursor-pointer transition-colors select-none',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
isDragging
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50 hover:bg-muted/30',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{isDragging
|
||||||
|
? 'Suelta los archivos aquí'
|
||||||
|
: 'Arrastra PDFs aquí o toca para seleccionar'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Solo archivos PDF · Múltiples archivos soportados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">
|
||||||
|
{uploadedFiles.length}{' '}
|
||||||
|
{uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{uploadedFiles.map((file, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={`Eliminar ${file.name}`}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<Button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploadedFiles.length === 0 || isUploading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{isUploading ? 'Procesando...' : 'Subir PDFs'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Upload result */}
|
||||||
|
{uploadResult && (
|
||||||
|
<div className={[
|
||||||
|
'rounded-lg border p-4 space-y-2',
|
||||||
|
uploadResult.errors.length > 0 && uploadResult.imported === 0
|
||||||
|
? 'border-destructive/50 bg-destructive/5'
|
||||||
|
: 'border-emerald-500/50 bg-emerald-500/5',
|
||||||
|
].join(' ')}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(uploadResult.imported > 0 || uploadResult.updated > 0) ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{uploadResult.imported > 0 && `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}`}
|
||||||
|
{uploadResult.imported > 0 && uploadResult.updated > 0 && ' · '}
|
||||||
|
{uploadResult.updated > 0 && `${uploadResult.updated} actualizado(s)`}
|
||||||
|
{uploadResult.imported === 0 && uploadResult.updated === 0 && 'Ningún extracto nuevo importado'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{uploadResult.duplicates > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{uploadResult.duplicates} {uploadResult.duplicates === 1 ? 'duplicado omitido' : 'duplicados omitidos'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{uploadResult.errors.map((err, i) => (
|
||||||
|
<p key={i} className="text-xs text-destructive">{err}</p>
|
||||||
|
))}
|
||||||
|
{uploadResult.snapshots.length > 0 && (
|
||||||
|
<div className="space-y-1 pt-1">
|
||||||
|
{uploadResult.snapshots.map((snap) => (
|
||||||
|
<div key={snap.id} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{snap.fund} · {new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
|
||||||
|
{' — '}
|
||||||
|
{new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium">{formatCRC(snap.saldo_final)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
frontend/src/pages/Salarios.tsx
Normal file
163
frontend/src/pages/Salarios.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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 '@/lib/api';
|
||||||
|
import { formatAmount, formatDate } from '@/lib/format';
|
||||||
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
|
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export default function Salarios() {
|
||||||
|
const [deposits, setDeposits] = useState<Transaction[]>([]);
|
||||||
|
const [summary, setSummary] = useState<SalariosSummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [depRes, sumRes] = await Promise.all([
|
||||||
|
getSalarios({ limit: 500 }),
|
||||||
|
getSalariosSummary(),
|
||||||
|
]);
|
||||||
|
setDeposits(depRes.data);
|
||||||
|
setSummary(sumRes.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, []);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<Transaction, unknown>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'date',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Fecha" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const d = new Date(row.original.date);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{formatDate(row.original.date)}</span>
|
||||||
|
<span className="text-muted-foreground ml-1 text-xs">{d.getFullYear()}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'merchant',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Detalle" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{row.original.merchant}</span>
|
||||||
|
{row.original.notes && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate max-w-xs">{row.original.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'amount',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Monto" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span data-sensitive className="font-mono font-bold text-primary">
|
||||||
|
+{formatAmount(row.original.amount, row.original.currency)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { className: 'text-right' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'bank',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Banco" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="outline">{row.original.bank}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'reference',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Comprobante" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{row.original.reference || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Landmark className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold font-heading">Salarios</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
|
||||||
|
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<Hash className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Depósitos</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold font-mono">{summary.count}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<Banknote className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Total acumulado</span>
|
||||||
|
</div>
|
||||||
|
<span data-sensitive className="text-2xl font-bold font-mono text-primary">
|
||||||
|
{formatAmount(summary.total_amount, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<CalendarDays className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Último depósito</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold font-mono">
|
||||||
|
{summary.latest_date ? formatDate(summary.latest_date) : '—'}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={deposits}
|
||||||
|
pagination
|
||||||
|
pageSize={25}
|
||||||
|
initialSorting={[{ id: 'date', desc: true }]}
|
||||||
|
emptyMessage="No hay depósitos registrados aún."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
807
frontend/src/pages/ServiciosMunicipales.tsx
Normal file
807
frontend/src/pages/ServiciosMunicipales.tsx
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
Droplets,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Receipt,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
CalendarDays,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@/components/ui/accordion';
|
||||||
|
import {
|
||||||
|
uploadMunicipalReceipt,
|
||||||
|
getMunicipalReceipts,
|
||||||
|
getWaterConsumption,
|
||||||
|
type MunicipalReceipt,
|
||||||
|
type MunicipalReceiptUploadResult,
|
||||||
|
type WaterMeterReading,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const METER_COLORS: Record<string, string> = {
|
||||||
|
'7335': '#3b82f6',
|
||||||
|
'7345': '#10b981',
|
||||||
|
'9345': '#f59e0b',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTH_NAMES_ES = [
|
||||||
|
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||||
|
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_METER_COLOR = '#8b5cf6';
|
||||||
|
|
||||||
|
// ─── Utilities ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const formatCRC = (amount: number): string =>
|
||||||
|
`₡${amount.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
|
function periodLabel(period: string): string {
|
||||||
|
const [yearStr, monthStr] = period.split('-');
|
||||||
|
const monthIdx = parseInt(monthStr, 10) - 1;
|
||||||
|
return `${MONTH_NAMES_ES[monthIdx]} ${yearStr.slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function meterColor(meterId: string): string {
|
||||||
|
return METER_COLORS[meterId] ?? DEFAULT_METER_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chart Data ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ChartPoint {
|
||||||
|
period: string;
|
||||||
|
label: string;
|
||||||
|
[meterId: string]: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartData(readings: WaterMeterReading[]): ChartPoint[] {
|
||||||
|
const byPeriod = new Map<string, Record<string, number>>();
|
||||||
|
|
||||||
|
for (const r of readings) {
|
||||||
|
if (!byPeriod.has(r.period)) byPeriod.set(r.period, {});
|
||||||
|
byPeriod.get(r.period)![r.meter_id] = r.consumption_m3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = Array.from(byPeriod.keys()).sort();
|
||||||
|
return sorted.map((period) => ({
|
||||||
|
period,
|
||||||
|
label: periodLabel(period),
|
||||||
|
...byPeriod.get(period)!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeterIds(readings: WaterMeterReading[]): string[] {
|
||||||
|
return [...new Set(readings.map((r) => r.meter_id))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Charge Trend Data ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CHARGE_COLORS = [
|
||||||
|
'#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ChargeTrendPoint {
|
||||||
|
period: string;
|
||||||
|
label: string;
|
||||||
|
[chargeDetail: string]: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChargeTrendData(receipts: MunicipalReceipt[]): ChargeTrendPoint[] {
|
||||||
|
const sorted = [...receipts].sort((a, b) => a.period.localeCompare(b.period));
|
||||||
|
return sorted.map((r) => {
|
||||||
|
const point: ChargeTrendPoint = { period: r.period, label: periodLabel(r.period) };
|
||||||
|
for (const charge of r.raw_charges) {
|
||||||
|
point[charge.detail] = charge.amount;
|
||||||
|
}
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChargeNames(receipts: MunicipalReceipt[]): string[] {
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const r of receipts) {
|
||||||
|
for (const c of r.raw_charges) {
|
||||||
|
names.add(c.detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chart Tooltip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TooltipEntry {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipEntry[];
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const total = payload.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
return (
|
||||||
|
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[180px]">
|
||||||
|
<p className="font-semibold mb-2 text-foreground">{label}</p>
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">Medidor {entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium text-foreground">{entry.value} m³</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Separator className="my-1.5" />
|
||||||
|
<div className="flex justify-between text-xs font-medium">
|
||||||
|
<span className="text-muted-foreground">Total</span>
|
||||||
|
<span className="font-mono">{total} m³</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ServiciosMunicipales() {
|
||||||
|
const [receipts, setReceipts] = useState<MunicipalReceipt[]>([]);
|
||||||
|
const [waterReadings, setWaterReadings] = useState<WaterMeterReading[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Chart visibility state
|
||||||
|
const [hiddenMeters, setHiddenMeters] = useState<Set<string>>(new Set());
|
||||||
|
const [hiddenCharges, setHiddenCharges] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Upload state
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [receiptsRes, waterRes] = await Promise.all([
|
||||||
|
getMunicipalReceipts(),
|
||||||
|
getWaterConsumption(24),
|
||||||
|
]);
|
||||||
|
setReceipts(receiptsRes.data);
|
||||||
|
setWaterReadings(waterRes.data);
|
||||||
|
} catch {
|
||||||
|
// API not available yet
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// Derived data
|
||||||
|
const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]);
|
||||||
|
const meterIds = useMemo(() => getMeterIds(waterReadings), [waterReadings]);
|
||||||
|
const chargeTrendData = useMemo(() => buildChargeTrendData(receipts), [receipts]);
|
||||||
|
const chargeNames = useMemo(() => getChargeNames(receipts), [receipts]);
|
||||||
|
|
||||||
|
const latestReceipt = receipts[0] ?? null;
|
||||||
|
|
||||||
|
const avgMonthly = useMemo(() => {
|
||||||
|
if (receipts.length === 0) return 0;
|
||||||
|
const sum = receipts.reduce((s, r) => s + r.total, 0);
|
||||||
|
return sum / receipts.length;
|
||||||
|
}, [receipts]);
|
||||||
|
|
||||||
|
const currentConsumption = useMemo(() => {
|
||||||
|
if (chartData.length === 0) return { total: 0, prev: 0 };
|
||||||
|
const latest = chartData[chartData.length - 1];
|
||||||
|
const prev = chartData.length >= 2 ? chartData[chartData.length - 2] : null;
|
||||||
|
const sumValues = (point: ChartPoint) =>
|
||||||
|
meterIds.reduce((s, id) => s + ((point[id] as number) || 0), 0);
|
||||||
|
return {
|
||||||
|
total: sumValues(latest),
|
||||||
|
prev: prev ? sumValues(prev) : 0,
|
||||||
|
};
|
||||||
|
}, [chartData, meterIds]);
|
||||||
|
|
||||||
|
const consumptionDelta = currentConsumption.prev > 0
|
||||||
|
? currentConsumption.total - currentConsumption.prev
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Upload handlers
|
||||||
|
const handleFiles = useCallback((files: FileList | null) => {
|
||||||
|
if (!files) return;
|
||||||
|
const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf');
|
||||||
|
if (pdfs.length > 0) {
|
||||||
|
setUploadedFiles((prev) => [...prev, ...pdfs]);
|
||||||
|
setUploadResults([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (uploadedFiles.length === 0) return;
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadResults([]);
|
||||||
|
const results: MunicipalReceiptUploadResult[] = [];
|
||||||
|
for (const file of uploadedFiles) {
|
||||||
|
try {
|
||||||
|
const { data } = await uploadMunicipalReceipt(file);
|
||||||
|
results.push(data);
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
imported: 0,
|
||||||
|
updated: 0,
|
||||||
|
errors: [`${file.name}: ${err instanceof Error ? err.message : 'Error al subir'}`],
|
||||||
|
receipt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploadResults(results);
|
||||||
|
setUploadedFiles([]);
|
||||||
|
await loadData();
|
||||||
|
setIsUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Droplets className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold font-heading">Servicios Municipales</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Municipalidad de Belén — recibos y consumo de agua
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Summary Cards ───────────────────────────────────────────────── */}
|
||||||
|
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<Receipt className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||||
|
Último recibo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p data-sensitive className="text-lg font-bold font-mono">
|
||||||
|
{latestReceipt ? formatCRC(latestReceipt.total) : '—'}
|
||||||
|
</p>
|
||||||
|
{latestReceipt && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{periodLabel(latestReceipt.period)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<CalendarDays className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||||
|
Promedio mensual
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p data-sensitive className="text-lg font-bold font-mono">
|
||||||
|
{receipts.length > 0 ? formatCRC(avgMonthly) : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{receipts.length} {receipts.length === 1 ? 'recibo' : 'recibos'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<Droplets className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||||
|
Consumo actual
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono">
|
||||||
|
{currentConsumption.total > 0 ? `${currentConsumption.total} m³` : '—'}
|
||||||
|
</p>
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{periodLabel(chartData[chartData.length - 1].period)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
{consumptionDelta <= 0 ? (
|
||||||
|
<TrendingDown className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingUp className="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||||
|
Variación
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-lg font-bold font-mono ${
|
||||||
|
consumptionDelta <= 0 ? 'text-emerald-500' : 'text-amber-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentConsumption.prev > 0
|
||||||
|
? `${consumptionDelta > 0 ? '+' : ''}${consumptionDelta} m³`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
vs mes anterior
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Water Consumption Chart ─────────────────────────────────────── */}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Droplets className="w-4 h-4" />
|
||||||
|
Consumo de Agua (m³)
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||||
|
axisLine={{ stroke: 'var(--border)' }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={32}
|
||||||
|
unit=" m³"
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltipContent />} />
|
||||||
|
<Legend
|
||||||
|
onClick={(e) => {
|
||||||
|
const id = e.dataKey as string;
|
||||||
|
setHiddenMeters((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
formatter={(value: string) => (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: hiddenMeters.has(value) ? 'var(--muted-foreground)' : 'var(--foreground)',
|
||||||
|
opacity: hiddenMeters.has(value) ? 0.4 : 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Medidor {value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{meterIds.map((id) => (
|
||||||
|
<Bar
|
||||||
|
key={id}
|
||||||
|
dataKey={id}
|
||||||
|
name={id}
|
||||||
|
fill={meterColor(id)}
|
||||||
|
radius={[3, 3, 0, 0]}
|
||||||
|
maxBarSize={32}
|
||||||
|
hide={hiddenMeters.has(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Charge Trend Chart ─────────────────────────────────────────── */}
|
||||||
|
{chargeTrendData.length > 1 && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Receipt className="w-4 h-4" />
|
||||||
|
Evolución de Cargos
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={chargeTrendData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||||
|
axisLine={{ stroke: 'var(--border)' }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v: number) => v >= 1000 ? `₡${(v / 1000).toFixed(0)}k` : `₡${v}`}
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={52}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload, label }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[220px]">
|
||||||
|
<p className="font-semibold mb-2 text-foreground">{label}</p>
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs">{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium text-foreground text-xs">
|
||||||
|
{formatCRC(entry.value as number)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
onClick={(e) => {
|
||||||
|
const name = e.dataKey as string;
|
||||||
|
setHiddenCharges((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(name) ? next.delete(name) : next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
formatter={(value: string) => (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: hiddenCharges.has(value) ? 'var(--muted-foreground)' : 'var(--foreground)',
|
||||||
|
opacity: hiddenCharges.has(value) ? 0.4 : 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>{value}</span>
|
||||||
|
)}
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
{chargeNames.map((name, i) => (
|
||||||
|
<Line
|
||||||
|
key={name}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={name}
|
||||||
|
name={name}
|
||||||
|
stroke={CHARGE_COLORS[i % CHARGE_COLORS.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
connectNulls
|
||||||
|
hide={hiddenCharges.has(name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Receipt History ──────────────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Receipt className="w-4 h-4" />
|
||||||
|
Historial de Recibos
|
||||||
|
</h2>
|
||||||
|
{loading && receipts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||||
|
Cargando...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : receipts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
<Receipt className="w-8 h-8 mx-auto mb-2 opacity-40" />
|
||||||
|
<p className="text-sm">No hay recibos aún. Sube un PDF para comenzar.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Accordion>
|
||||||
|
{receipts.map((receipt) => (
|
||||||
|
<AccordionItem key={receipt.id} value={String(receipt.id)}>
|
||||||
|
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||||
|
<div className="flex items-center justify-between w-full pr-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{periodLabel(receipt.period)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground hidden sm:inline">
|
||||||
|
Vence{' '}
|
||||||
|
{new Date(receipt.due_date).toLocaleDateString('es-CR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span data-sensitive className="font-mono font-bold text-sm">
|
||||||
|
{formatCRC(receipt.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Charges breakdown */}
|
||||||
|
<div className="rounded-lg border border-border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50">
|
||||||
|
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Detalle
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Monto
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{receipt.raw_charges.map((charge, i) => (
|
||||||
|
<tr key={i} className="border-t border-border">
|
||||||
|
<td className="px-3 py-2 text-foreground">{charge.detail}</td>
|
||||||
|
<td data-sensitive className="px-3 py-2 text-right font-mono">
|
||||||
|
{formatCRC(charge.amount)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
{receipt.interests > 0 && (
|
||||||
|
<tr className="border-t border-border bg-muted/30">
|
||||||
|
<td className="px-3 py-1.5 text-xs text-muted-foreground">Intereses</td>
|
||||||
|
<td data-sensitive className="px-3 py-1.5 text-right font-mono text-xs">
|
||||||
|
{formatCRC(receipt.interests)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{receipt.iva > 0 && (
|
||||||
|
<tr className="border-t border-border bg-muted/30">
|
||||||
|
<td className="px-3 py-1.5 text-xs text-muted-foreground">IVA</td>
|
||||||
|
<td data-sensitive className="px-3 py-1.5 text-right font-mono text-xs">
|
||||||
|
{formatCRC(receipt.iva)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr className="border-t-2 border-border font-bold">
|
||||||
|
<td className="px-3 py-2">Total</td>
|
||||||
|
<td data-sensitive className="px-3 py-2 text-right font-mono">
|
||||||
|
{formatCRC(receipt.total)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>Cuenta: {receipt.account}</span>
|
||||||
|
<span>Finca: {receipt.finca}</span>
|
||||||
|
<span>
|
||||||
|
Fecha:{' '}
|
||||||
|
{new Date(receipt.receipt_date).toLocaleDateString('es-CR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── PDF Upload ──────────────────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Subir Recibos
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||||
|
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
|
||||||
|
aria-label="Seleccionar archivos PDF"
|
||||||
|
className={[
|
||||||
|
'border-2 border-dashed rounded-lg p-8',
|
||||||
|
'flex flex-col items-center justify-center gap-3',
|
||||||
|
'cursor-pointer transition-colors select-none',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
isDragging
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50 hover:bg-muted/30',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{isDragging
|
||||||
|
? 'Suelta los archivos aquí'
|
||||||
|
: 'Arrastra PDFs aquí o toca para seleccionar'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Solo archivos PDF · Múltiples archivos soportados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">
|
||||||
|
{uploadedFiles.length}{' '}
|
||||||
|
{uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{uploadedFiles.map((file, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={`Eliminar ${file.name}`}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<Button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploadedFiles.length === 0 || isUploading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{isUploading ? 'Extrayendo datos...' : `Subir ${uploadedFiles.length > 1 ? `${uploadedFiles.length} Recibos` : 'Recibo'}`}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Upload results */}
|
||||||
|
{uploadResults.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{uploadResults.map((result, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={[
|
||||||
|
'rounded-lg border p-3 space-y-1',
|
||||||
|
result.errors.length > 0 && !result.receipt
|
||||||
|
? 'border-destructive/50 bg-destructive/5'
|
||||||
|
: 'border-emerald-500/50 bg-emerald-500/5',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{result.receipt ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{result.imported > 0 && 'Recibo importado'}
|
||||||
|
{result.updated > 0 && 'Recibo actualizado'}
|
||||||
|
{!result.receipt && 'Error al procesar'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.receipt && (
|
||||||
|
<div className="flex items-center justify-between text-xs pl-6">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{periodLabel(result.receipt.period)}
|
||||||
|
</span>
|
||||||
|
<span data-sensitive className="font-mono font-medium">
|
||||||
|
{formatCRC(result.receipt.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.errors.map((err, j) => (
|
||||||
|
<p key={j} className="text-xs text-destructive pl-6">{err}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 className="font-mono text-foreground">{formatAmount(totalCRC, 'CRC')}</span></>
|
|
||||||
)}
|
|
||||||
{totalUSD !== 0 && (
|
|
||||||
<> · <span 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,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "server.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react-swc';
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
// CopilotKit runtime (Hono server, dev only)
|
||||||
target: 'http://localhost:8001',
|
"/api/copilotkit": {
|
||||||
|
target: "http://localhost:3001",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
// All other API calls → Python backend
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8001",
|
||||||
changeOrigin: true,
|
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