mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +02:00
Compare commits
51 Commits
58ab395d95
...
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 | ||
|
|
2cd0d3b2e1 | ||
|
|
46f2d8679c | ||
|
|
4d468036c6 |
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()
|
||||||
59
backend/app/api/v1/endpoints/settings.py
Normal file
59
backend/app/api/v1/endpoints/settings.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=UserSettingsRead)
|
||||||
|
def get_settings(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
settings = session.exec(
|
||||||
|
select(UserSettings).where(UserSettings.key == "default")
|
||||||
|
).first()
|
||||||
|
if not settings:
|
||||||
|
settings = UserSettings(key="default", data=DEFAULT_SETTINGS)
|
||||||
|
session.add(settings)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/", response_model=UserSettingsRead)
|
||||||
|
def update_settings(
|
||||||
|
body: UserSettingsUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
settings = session.exec(
|
||||||
|
select(UserSettings).where(UserSettings.key == "default")
|
||||||
|
).first()
|
||||||
|
if not settings:
|
||||||
|
settings = UserSettings(key="default", data=body.data)
|
||||||
|
else:
|
||||||
|
settings.data = body.data
|
||||||
|
settings.updated_at = datetime.utcnow()
|
||||||
|
session.add(settings)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(settings)
|
||||||
|
return settings
|
||||||
@@ -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,16 @@ 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,
|
||||||
tokens,
|
tokens,
|
||||||
transactions,
|
transactions,
|
||||||
)
|
)
|
||||||
@@ -20,3 +27,10 @@ api_router.include_router(import_transactions.router)
|
|||||||
api_router.include_router(exchange_rate.router)
|
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(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,13 +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 JSON, Column, UniqueConstraint
|
||||||
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):
|
||||||
@@ -19,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"
|
||||||
|
|
||||||
@@ -124,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):
|
||||||
@@ -152,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 ---
|
||||||
@@ -195,3 +215,255 @@ class APITokenRead(SQLModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
expires_at: Optional[datetime]
|
expires_at: Optional[datetime]
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
# --- User Settings ---
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettings(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
key: str = Field(index=True, unique=True, default="default")
|
||||||
|
data: dict = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
sa_column=Column(JSON, nullable=False, server_default="{}"),
|
||||||
|
)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingsRead(SQLModel):
|
||||||
|
key: str
|
||||||
|
data: dict
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingsUpdate(SQLModel):
|
||||||
|
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,18 +1,45 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
# Fallback APIs (no API key required, all support CRC)
|
||||||
|
EXCHANGERATE_API_URL = "https://open.er-api.com/v6/latest/USD"
|
||||||
|
CURRENCY_API_URL = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json"
|
||||||
|
CURRENCY_API_FALLBACK_URL = "https://latest.currency-api.pages.dev/v1/currencies/usd.json"
|
||||||
|
FLOATRATES_URL = "https://www.floatrates.com/daily/usd.json"
|
||||||
|
|
||||||
|
# Typical buy/sell spread for USD/CRC (~0.5% each side of mid-market)
|
||||||
|
_SPREAD = 0.005
|
||||||
|
|
||||||
_cache: dict[str, tuple[ExchangeRate, datetime]] = {}
|
_cache: dict[str, tuple[ExchangeRate, datetime]] = {}
|
||||||
|
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
|
||||||
CACHE_TTL = timedelta(hours=1)
|
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."""
|
||||||
@@ -29,10 +56,7 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
|||||||
resp = httpx.get(BCCR_URL, params=params, timeout=10)
|
resp = httpx.get(BCCR_URL, params=params, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
# Parse XML response
|
|
||||||
root = ET.fromstring(resp.text)
|
root = ET.fromstring(resp.text)
|
||||||
# The value is in INGC011_DES_DATOS > NUM_VALOR
|
|
||||||
ns = {"": "http://ws.sdde.bccr.fi.cr"}
|
|
||||||
for datos in root.iter():
|
for datos in root.iter():
|
||||||
if datos.tag.endswith("NUM_VALOR"):
|
if datos.tag.endswith("NUM_VALOR"):
|
||||||
return float(datos.text.strip().replace(",", "."))
|
return float(datos.text.strip().replace(",", "."))
|
||||||
@@ -41,14 +65,92 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_bccr() -> tuple[float, float] | None:
|
||||||
|
"""Try BCCR official API. Returns (buy, sell) or None."""
|
||||||
|
today = datetime.now().strftime("%d/%m/%Y")
|
||||||
|
buy = _fetch_bccr_rate(317, today)
|
||||||
|
sell = _fetch_bccr_rate(318, today)
|
||||||
|
if buy is not None and sell is not None:
|
||||||
|
return (buy, sell)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mid_to_buy_sell(mid: float) -> tuple[float, float]:
|
||||||
|
"""Convert a mid-market rate to approximate buy/sell with a spread."""
|
||||||
|
return (mid * (1 - _SPREAD), mid * (1 + _SPREAD))
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_exchangerate_api() -> tuple[float, float] | None:
|
||||||
|
"""Try ExchangeRate-API (open.er-api.com). No key required."""
|
||||||
|
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")
|
||||||
|
if crc:
|
||||||
|
return _mid_to_buy_sell(float(crc))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_currency_api() -> tuple[float, float] | None:
|
||||||
|
"""Try fawazahmed0/currency-api (CDN-hosted). No key required."""
|
||||||
|
for url in (CURRENCY_API_URL, CURRENCY_API_FALLBACK_URL):
|
||||||
|
try:
|
||||||
|
resp = httpx.get(url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
crc = data.get("usd", {}).get("crc")
|
||||||
|
if crc:
|
||||||
|
return _mid_to_buy_sell(float(crc))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_floatrates() -> tuple[float, float] | None:
|
||||||
|
"""Try FloatRates. No key required."""
|
||||||
|
try:
|
||||||
|
resp = httpx.get(FLOATRATES_URL, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
crc_data = data.get("crc")
|
||||||
|
if crc_data and "rate" in crc_data:
|
||||||
|
return _mid_to_buy_sell(float(crc_data["rate"]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_rate_from_apis() -> tuple[float, float] | None:
|
||||||
|
"""Try all sources in order: BCCR → ExchangeRate-API → currency-api → FloatRates."""
|
||||||
|
for fetcher in (_fetch_bccr, _fetch_exchangerate_api, _fetch_currency_api, _fetch_floatrates):
|
||||||
|
result = fetcher()
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _remember(rate: ExchangeRate) -> ExchangeRate:
|
||||||
|
"""Store rate in both TTL cache and permanent last-known holder."""
|
||||||
|
global _last_known
|
||||||
|
_cache["current"] = (rate, datetime.utcnow())
|
||||||
|
_last_known = rate
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
def get_current_rate(session: Session) -> ExchangeRate | None:
|
def get_current_rate(session: Session) -> ExchangeRate | None:
|
||||||
"""Get current USD/CRC rate. Uses in-memory cache + DB fallback."""
|
"""Get current USD/CRC rate. Never returns None once a rate has been fetched."""
|
||||||
# Check memory cache
|
global _last_known
|
||||||
|
|
||||||
|
# 1. Fresh memory cache (< 1 hour)
|
||||||
cached = _cache.get("current")
|
cached = _cache.get("current")
|
||||||
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
||||||
return cached[0]
|
return cached[0]
|
||||||
|
|
||||||
# Check DB for recent rate
|
# 2. Fresh DB rate (< 1 hour)
|
||||||
one_hour_ago = datetime.utcnow() - CACHE_TTL
|
one_hour_ago = datetime.utcnow() - CACHE_TTL
|
||||||
db_rate = session.exec(
|
db_rate = session.exec(
|
||||||
select(ExchangeRate)
|
select(ExchangeRate)
|
||||||
@@ -56,31 +158,212 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
|
|||||||
.order_by(col(ExchangeRate.fetched_at).desc())
|
.order_by(col(ExchangeRate.fetched_at).desc())
|
||||||
).first()
|
).first()
|
||||||
if db_rate:
|
if db_rate:
|
||||||
_cache["current"] = (db_rate, datetime.utcnow())
|
return _remember(db_rate)
|
||||||
return db_rate
|
|
||||||
|
|
||||||
# Fetch from BCCR
|
# 3. Try all API sources
|
||||||
today = datetime.now().strftime("%d/%m/%Y")
|
result = _fetch_rate_from_apis()
|
||||||
buy = _fetch_bccr_rate(317, today)
|
if result is not None:
|
||||||
sell = _fetch_bccr_rate(318, today)
|
buy, sell = result
|
||||||
|
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
|
||||||
|
session.add(rate)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(rate)
|
||||||
|
return _remember(rate)
|
||||||
|
|
||||||
if buy is None or sell is None:
|
# 4. Stale DB rate (any age)
|
||||||
# Fallback: return most recent DB rate regardless of age
|
fallback = session.exec(
|
||||||
fallback = session.exec(
|
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
|
||||||
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
|
).first()
|
||||||
).first()
|
if fallback:
|
||||||
return fallback
|
return _remember(fallback)
|
||||||
|
|
||||||
rate = ExchangeRate(
|
# 5. Last known in-memory rate (survives even if DB is empty)
|
||||||
date=datetime.utcnow(),
|
if _last_known:
|
||||||
buy_rate=buy,
|
return _last_known
|
||||||
sell_rate=sell,
|
|
||||||
)
|
return None
|
||||||
session.add(rate)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(rate)
|
def _fetch_fiat_crc_mid(code: str) -> float | None:
|
||||||
_cache["current"] = (rate, datetime.utcnow())
|
"""Derive {code}/CRC mid-market rate from ExchangeRate-API (USD-based).
|
||||||
return rate
|
|
||||||
|
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]:
|
||||||
|
|||||||
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
|
|
||||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default-translucent",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -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,28 +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": {
|
||||||
"axios": "^1.13.6",
|
"@ag-ui/client": "0.0.52",
|
||||||
"lucide-react": "^0.562.0",
|
"@base-ui/react": "^1.4.1",
|
||||||
"react": "^19.2.0",
|
"@copilotkit/react-core": "1.56.4",
|
||||||
"react-dom": "^19.2.0",
|
"@copilotkit/react-ui": "1.56.4",
|
||||||
"react-router-dom": "^7.12.0",
|
"@copilotkit/runtime": "1.56.4",
|
||||||
"recharts": "^3.8.0"
|
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
||||||
|
"@fontsource-variable/noto-sans": "^5.2.10",
|
||||||
|
"@hono/node-server": "^1.14.4",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"hono": "^4.12.15",
|
||||||
|
"lucide-react": "^1.12.0",
|
||||||
|
"react": "19.2.5",
|
||||||
|
"react-dom": "19.2.5",
|
||||||
|
"react-router-dom": "^7.6.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tsx": "^4.19.4",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8215
frontend/pnpm-lock.yaml
generated
8215
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,77 +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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
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,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Calendar, ChevronDown } from 'lucide-react';
|
import { Calendar } from 'lucide-react';
|
||||||
import api from '../api';
|
import api from '@/lib/api';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
export interface CycleOption {
|
export interface CycleOption {
|
||||||
year: number;
|
year: number;
|
||||||
@@ -22,33 +29,34 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
|
|||||||
api.get('/transactions/cycles').then((r) => setCycles(r.data));
|
api.get('/transactions/cycles').then((r) => setCycles(r.data));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedKey = value ? `${value.year}-${value.month}` : '';
|
const selectedKey = value ? `${value.year}-${value.month}` : 'all';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4 text-text-muted" />
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
<div className="relative">
|
<Select
|
||||||
<select
|
value={selectedKey}
|
||||||
value={selectedKey}
|
onValueChange={(val) => {
|
||||||
onChange={(e) => {
|
if (val === 'all') {
|
||||||
if (!e.target.value) {
|
onChange(null);
|
||||||
onChange(null);
|
} else {
|
||||||
} else {
|
const [y, m] = val.split('-').map(Number);
|
||||||
const [y, m] = e.target.value.split('-').map(Number);
|
onChange({ year: y, month: m });
|
||||||
onChange({ year: y, month: m });
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
className="appearance-none bg-input-bg border border-border rounded-lg pl-3 pr-9 py-2 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
|
<SelectTrigger className="w-[180px]">
|
||||||
>
|
<SelectValue />
|
||||||
<option value="">All time</option>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All time</SelectItem>
|
||||||
{cycles.map((c) => (
|
{cycles.map((c) => (
|
||||||
<option key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
|
<SelectItem key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
|
||||||
{c.label} ({c.count})
|
{c.label} ({c.count})
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { AlertTriangle, X } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogMedia,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,45 +22,26 @@ interface Props {
|
|||||||
|
|
||||||
export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) {
|
export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={onCancel}>
|
<AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||||
<div
|
<AlertDialogContent>
|
||||||
className="bg-surface border border-border rounded-xl w-full max-w-sm animate-fade-in"
|
<AlertDialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<AlertDialogMedia className="bg-destructive/10">
|
||||||
>
|
<AlertTriangle className="text-destructive" />
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
</AlertDialogMedia>
|
||||||
<h3 className="font-semibold">{title}</h3>
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
<button onClick={onCancel} className="text-text-muted hover:text-text-primary transition-colors">
|
<AlertDialogDescription>{message}</AlertDialogDescription>
|
||||||
<X className="w-5 h-5" />
|
</AlertDialogHeader>
|
||||||
</button>
|
<AlertDialogFooter>
|
||||||
</div>
|
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
<div className="px-5 py-5">
|
variant="destructive"
|
||||||
<div className="flex gap-3 items-start">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-red-500 dark:text-red-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-text-secondary pt-2">{message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 px-5 pb-5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-red-500 hover:bg-red-600 text-white transition-colors disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{loading ? 'Deleting...' : confirmLabel}
|
{loading ? 'Deleting...' : confirmLabel}
|
||||||
</button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +1,213 @@
|
|||||||
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,
|
||||||
X,
|
|
||||||
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 { useTheme } from "@/contexts/theme-context";
|
||||||
|
import { usePrivacy } from "@/contexts/privacy-context";
|
||||||
|
import { useAuth } from "@/AuthContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetClose,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
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-surface text-text-primary">
|
<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-surface/90">
|
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center">
|
<Button
|
||||||
<Wallet className="w-4 h-4 text-[#FEFAE0]" strokeWidth={2.5} />
|
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">
|
||||||
|
<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">
|
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
|
||||||
Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span>
|
Wealthy<span className="text-primary">Smart</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop nav */}
|
<div className="flex items-center gap-1">
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
|
||||||
{navItems.map(({ to, icon: Icon, label }) => (
|
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
<NavLink
|
</Button>
|
||||||
key={to}
|
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
|
||||||
to={to}
|
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
end={to === '/'}
|
</Button>
|
||||||
className={({ isActive }) =>
|
<Button
|
||||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
variant="ghost"
|
||||||
isActive
|
size="icon"
|
||||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
|
||||||
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-colors"
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="hidden md:flex items-center gap-2 text-text-muted hover:text-text-secondary text-sm transition-colors"
|
title="Sign out"
|
||||||
|
aria-label="Sign out"
|
||||||
|
className="hidden md:inline-flex"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
|
||||||
className="md:hidden text-text-muted"
|
|
||||||
>
|
|
||||||
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile nav */}
|
|
||||||
{mobileOpen && (
|
|
||||||
<div className="md:hidden border-t border-border px-4 pb-4 space-y-1">
|
|
||||||
{navItems.map(({ to, icon: Icon, label }) => (
|
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
end={to === '/'}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
|
||||||
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-text-muted hover:text-text-primary hover:bg-surface-hover w-full"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
<div className="flex">
|
||||||
<Outlet />
|
<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">
|
||||||
</main>
|
<div className="flex-1">
|
||||||
|
<SidebarNav />
|
||||||
|
</div>
|
||||||
|
<div className="px-3 pb-4">
|
||||||
|
<Separator className="mb-2" />
|
||||||
|
<button
|
||||||
|
onClick={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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
|
<SheetContent side="left" className="p-0 w-64">
|
||||||
|
<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,24 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { X, 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 { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -28,114 +46,100 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
|
|
||||||
const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<ClipboardPaste className="w-4 h-4 text-[#606C38] dark:text-[#7a8a4a]" />
|
<ClipboardPaste className="w-4 h-4 text-primary" />
|
||||||
<h3 className="font-semibold">Import Bank Statement</h3>
|
Import Bank Statement
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!result ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Bank</Label>
|
||||||
|
<Select value={bank} onValueChange={setBank}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="BAC">BAC</SelectItem>
|
||||||
|
<SelectItem value="BCR">BCR</SelectItem>
|
||||||
|
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Source</Label>
|
||||||
|
<Select value={source} onValueChange={setSource}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
|
||||||
|
<SelectItem value="CASH">Cash</SelectItem>
|
||||||
|
<SelectItem value="TRANSFER">Transfer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Statement Text</Label>
|
||||||
|
<Textarea
|
||||||
|
className="h-48 font-mono text-xs resize-y"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={`Paste bank statement lines here...\n\nFormat: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CRC`}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
One transaction per line. Tab-separated columns.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleImport} disabled={importing || !text.trim()}>
|
||||||
|
{importing ? 'Importing...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
|
) : (
|
||||||
<X className="w-5 h-5" />
|
<div className="space-y-4">
|
||||||
</button>
|
<Alert>
|
||||||
</div>
|
<CheckCircle className="h-4 w-4 text-primary" />
|
||||||
|
<AlertTitle className="text-primary">Import Complete</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{result.imported} imported
|
||||||
|
{result.duplicates > 0 && ` · ${result.duplicates} duplicates skipped`}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div className="p-5 space-y-4">
|
{result.errors.length > 0 && (
|
||||||
{!result ? (
|
<Alert variant="destructive">
|
||||||
<>
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<AlertTitle>{result.errors.length} errors</AlertTitle>
|
||||||
<div>
|
<AlertDescription>
|
||||||
<label className={labelClass}>Bank</label>
|
<ul className="text-xs font-mono max-h-32 overflow-y-auto space-y-1 mt-1">
|
||||||
<select className={inputClass} value={bank} onChange={(e) => setBank(e.target.value)}>
|
|
||||||
<option value="BAC">BAC</option>
|
|
||||||
<option value="BCR">BCR</option>
|
|
||||||
<option value="DAVIVIENDA">Davivienda</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={labelClass}>Source</label>
|
|
||||||
<select className={inputClass} value={source} onChange={(e) => setSource(e.target.value)}>
|
|
||||||
<option value="CREDIT_CARD">Credit Card</option>
|
|
||||||
<option value="CASH">Cash</option>
|
|
||||||
<option value="TRANSFER">Transfer</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={labelClass}>Statement Text</label>
|
|
||||||
<textarea
|
|
||||||
className={`${inputClass} h-48 font-mono text-xs resize-y`}
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
placeholder={`Paste bank statement lines here...\n\nFormat: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CRC`}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-text-faint mt-1">
|
|
||||||
One transaction per line. Tab-separated columns.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={importing || !text.trim()}
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{importing ? 'Importing...' : 'Import'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3 p-4 bg-[#606C38]/10 border border-[#606C38]/20 rounded-lg">
|
|
||||||
<CheckCircle className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a] flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-[#606C38] dark:text-[#7a8a4a]">Import Complete</p>
|
|
||||||
<p className="text-sm text-text-secondary mt-1">
|
|
||||||
{result.imported} imported
|
|
||||||
{result.duplicates > 0 && ` · ${result.duplicates} duplicates skipped`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result.errors.length > 0 && (
|
|
||||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-500 dark:text-amber-400" />
|
|
||||||
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">
|
|
||||||
{result.errors.length} errors
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ul className="text-xs text-text-secondary space-y-1 font-mono max-h-32 overflow-y-auto">
|
|
||||||
{result.errors.map((err, i) => (
|
{result.errors.map((err, i) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={i}>{err}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</AlertDescription>
|
||||||
)}
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<Button onClick={onClose} className="w-full">
|
||||||
onClick={onClose}
|
Done
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors"
|
</Button>
|
||||||
>
|
</div>
|
||||||
Done
|
)}
|
||||||
</button>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
frontend/src/components/TransactionList.tsx
Normal file
223
frontend/src/components/TransactionList.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
ArrowLeftRight,
|
||||||
|
ArrowRightFromLine,
|
||||||
|
Banknote,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import api, { type Transaction } from '@/lib/api';
|
||||||
|
import TransactionModal from './TransactionModal';
|
||||||
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
|
import { getTransactionColumns } from '@/components/transactions/transaction-columns';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TransactionListProps {
|
||||||
|
transactions: Transaction[];
|
||||||
|
loading: boolean;
|
||||||
|
source: 'CREDIT_CARD' | 'CASH' | 'TRANSFER';
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
emptyIcon?: React.ReactNode;
|
||||||
|
emptyMessage?: string;
|
||||||
|
showCategory?: boolean;
|
||||||
|
showSourceIcon?: boolean;
|
||||||
|
addLabel?: string;
|
||||||
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransactionList({
|
||||||
|
transactions,
|
||||||
|
loading,
|
||||||
|
source,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
onRefresh,
|
||||||
|
emptyIcon,
|
||||||
|
emptyMessage = 'No transactions found',
|
||||||
|
showCategory = true,
|
||||||
|
showSourceIcon = false,
|
||||||
|
addLabel = 'Add Transaction',
|
||||||
|
onToggleDeferred,
|
||||||
|
}: TransactionListProps) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleEdit = (tx: Transaction) => {
|
||||||
|
setEditing(tx);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (deleteId === null) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/transactions/${deleteId}`);
|
||||||
|
setDeleteId(null);
|
||||||
|
onRefresh();
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
|
||||||
|
[showCategory, showSourceIcon, onToggleDeferred],
|
||||||
|
);
|
||||||
|
|
||||||
|
const empty = transactions.length === 0 && !loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Search + Add */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
placeholder="Search merchants..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile list */}
|
||||||
|
<Card className="md:hidden">
|
||||||
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
|
{empty ? (
|
||||||
|
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
|
||||||
|
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
transactions.map((tx) => (
|
||||||
|
<div key={tx.id} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<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 flex-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||||
|
{showSourceIcon && (
|
||||||
|
tx.source === 'CASH'
|
||||||
|
? <Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
|
: tx.source === 'TRANSFER'
|
||||||
|
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
|
{showCategory && tx.category && (
|
||||||
|
<span className="ml-1.5 text-muted-foreground/60">{tx.category.name}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-sm font-medium shrink-0',
|
||||||
|
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
||||||
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
{onToggleDeferred && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
|
||||||
|
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
|
||||||
|
onClick={() => onToggleDeferred(tx)}
|
||||||
|
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
|
||||||
|
>
|
||||||
|
<ArrowRightFromLine className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Delete transaction"
|
||||||
|
aria-label="Delete transaction"
|
||||||
|
onClick={() => setDeleteId(tx.id)}
|
||||||
|
className="hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Desktop table */}
|
||||||
|
<Card className="hidden md:block">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={transactions}
|
||||||
|
pagination
|
||||||
|
pageSize={25}
|
||||||
|
initialSorting={[{ id: 'date', desc: true }]}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{modalOpen && (
|
||||||
|
<TransactionModal
|
||||||
|
transaction={editing}
|
||||||
|
source={source}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSaved={onRefresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteId !== null && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Delete Transaction"
|
||||||
|
message="This transaction will be permanently deleted. This action cannot be undone."
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import api, { type Category, type Transaction } from '@/lib/api';
|
||||||
import api, { type Category, type Transaction } from '../api';
|
import { formatLocalDatetime } from '@/lib/format';
|
||||||
|
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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
transaction?: Transaction | null;
|
transaction?: Transaction | null;
|
||||||
@@ -15,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',
|
||||||
@@ -29,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(() => {
|
||||||
@@ -83,43 +105,35 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
|
|
||||||
const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
<div className="bg-surface border border-border rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<DialogHeader>
|
||||||
<h3 className="font-semibold">
|
<DialogTitle>
|
||||||
{transaction ? 'Edit Transaction' : 'New Transaction'}
|
{transaction ? 'Edit Transaction' : 'New Transaction'}
|
||||||
</h3>
|
</DialogTitle>
|
||||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
|
</DialogHeader>
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-500 dark:text-red-400">
|
<Alert variant="destructive">
|
||||||
{error}
|
<AlertCircle className="h-4 w-4" />
|
||||||
</div>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2 space-y-2">
|
||||||
<label className={labelClass}>Merchant</label>
|
<Label>Merchant</Label>
|
||||||
<input
|
<Input
|
||||||
className={inputClass}
|
|
||||||
value={form.merchant}
|
value={form.merchant}
|
||||||
onChange={(e) => setForm({ ...form, merchant: e.target.value })}
|
onChange={(e) => setForm({ ...form, merchant: e.target.value })}
|
||||||
placeholder="e.g. AUTO MERCADO ON LINE"
|
placeholder="e.g. AUTO MERCADO ON LINE"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>Amount</label>
|
<Label>Amount</Label>
|
||||||
<input
|
<Input
|
||||||
className={inputClass}
|
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={form.amount}
|
value={form.amount}
|
||||||
@@ -128,69 +142,79 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>Currency</label>
|
<Label>Currency</Label>
|
||||||
<select
|
<Select value={form.currency} onValueChange={(v) => setForm({ ...form, currency: v })}>
|
||||||
className={inputClass}
|
<SelectTrigger className="w-full">
|
||||||
value={form.currency}
|
<SelectValue />
|
||||||
onChange={(e) => setForm({ ...form, currency: e.target.value })}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="CRC">CRC (₡)</option>
|
<SelectItem value="CRC">CRC (₡)</SelectItem>
|
||||||
<option value="USD">USD ($)</option>
|
<SelectItem value="USD">USD ($)</SelectItem>
|
||||||
</select>
|
<SelectItem value="EUR">EUR (€)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>Date</label>
|
<Label>Date</Label>
|
||||||
<input
|
<Input
|
||||||
className={inputClass}
|
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={form.date}
|
value={form.date}
|
||||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>Type</label>
|
<Label>Type</Label>
|
||||||
<select
|
<Select value={form.transaction_type} onValueChange={(v) => setForm({ ...form, transaction_type: v })}>
|
||||||
className={inputClass}
|
<SelectTrigger className="w-full">
|
||||||
value={form.transaction_type}
|
<SelectValue />
|
||||||
onChange={(e) => setForm({ ...form, transaction_type: e.target.value })}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="COMPRA">Compra</option>
|
<SelectItem value="COMPRA">Compra</SelectItem>
|
||||||
<option value="DEVOLUCION">Devolución</option>
|
<SelectItem value="DEVOLUCION">Devolución</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>Category</label>
|
<Label>Category</Label>
|
||||||
<select
|
<Select
|
||||||
className={inputClass}
|
value={form.category_id ? String(form.category_id) : 'auto'}
|
||||||
value={form.category_id}
|
onValueChange={(v) => setForm({ ...form, category_id: v === 'auto' ? '' : v })}
|
||||||
onChange={(e) => setForm({ ...form, category_id: e.target.value })}
|
|
||||||
>
|
>
|
||||||
<option value="">Auto-detect</option>
|
<SelectTrigger className="w-full">
|
||||||
{categories.map((c) => (
|
<SelectValue>
|
||||||
<option key={c.id} value={c.id}>
|
{form.category_id
|
||||||
{c.name}
|
? categories.find((c) => c.id === Number(form.category_id))?.name ?? form.category_id
|
||||||
</option>
|
: 'Auto-detect'}
|
||||||
))}
|
</SelectValue>
|
||||||
</select>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>Bank</label>
|
<Label>Bank</Label>
|
||||||
<select
|
<Select value={form.bank} onValueChange={(v) => setForm({ ...form, bank: v })}>
|
||||||
className={inputClass}
|
<SelectTrigger className="w-full">
|
||||||
value={form.bank}
|
<SelectValue />
|
||||||
onChange={(e) => setForm({ ...form, bank: e.target.value })}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="BAC">BAC</option>
|
<SelectItem value="BAC">BAC</SelectItem>
|
||||||
<option value="BCR">BCR</option>
|
<SelectItem value="BCR">BCR</SelectItem>
|
||||||
<option value="DAVIVIENDA">Davivienda</option>
|
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>City</label>
|
<Label>City</Label>
|
||||||
<input
|
<Input
|
||||||
className={inputClass}
|
|
||||||
value={form.city}
|
value={form.city}
|
||||||
onChange={(e) => setForm({ ...form, city: e.target.value })}
|
onChange={(e) => setForm({ ...form, city: e.target.value })}
|
||||||
placeholder="SAN JOSE, Costa Rica"
|
placeholder="SAN JOSE, Costa Rica"
|
||||||
@@ -198,19 +222,17 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
</div>
|
</div>
|
||||||
{source === 'CREDIT_CARD' && (
|
{source === 'CREDIT_CARD' && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>Card Type</label>
|
<Label>Card Type</Label>
|
||||||
<input
|
<Input
|
||||||
className={inputClass}
|
|
||||||
value={form.card_type}
|
value={form.card_type}
|
||||||
onChange={(e) => setForm({ ...form, card_type: e.target.value })}
|
onChange={(e) => setForm({ ...form, card_type: e.target.value })}
|
||||||
placeholder="MASTER"
|
placeholder="MASTER"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className={labelClass}>Card Last 4</label>
|
<Label>Card Last 4</Label>
|
||||||
<input
|
<Input
|
||||||
className={inputClass}
|
|
||||||
value={form.card_last4}
|
value={form.card_last4}
|
||||||
onChange={(e) => setForm({ ...form, card_last4: e.target.value })}
|
onChange={(e) => setForm({ ...form, card_last4: e.target.value })}
|
||||||
placeholder="6585"
|
placeholder="6585"
|
||||||
@@ -219,10 +241,9 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2 space-y-2">
|
||||||
<label className={labelClass}>Notes</label>
|
<Label>Notes</Label>
|
||||||
<input
|
<Input
|
||||||
className={inputClass}
|
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
placeholder="Optional notes"
|
placeholder="Optional notes"
|
||||||
@@ -230,24 +251,16 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
<DialogFooter>
|
||||||
<button
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit" disabled={saving}>
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
|
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
frontend/src/components/transactions/transaction-columns.tsx
Normal file
165
frontend/src/components/transactions/transaction-columns.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
import { type Transaction } from '@/lib/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
||||||
|
|
||||||
|
interface TransactionColumnOptions {
|
||||||
|
showCategory: boolean;
|
||||||
|
showSourceIcon?: boolean;
|
||||||
|
onEdit: (tx: Transaction) => void;
|
||||||
|
onDelete: (txId: number) => void;
|
||||||
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTransactionColumns({
|
||||||
|
showCategory,
|
||||||
|
showSourceIcon,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleDeferred,
|
||||||
|
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
||||||
|
const columns: ColumnDef<Transaction, unknown>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'date',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
|
||||||
|
{new Date(row.original.date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'merchant',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Merchant" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const tx = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-6 h-6 rounded flex items-center justify-center shrink-0',
|
||||||
|
tx.transaction_type === 'COMPRA'
|
||||||
|
? 'bg-destructive/10 text-destructive'
|
||||||
|
: 'bg-primary/10 text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tx.transaction_type === 'COMPRA' ? (
|
||||||
|
<TrendingDown className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{tx.merchant}</span>
|
||||||
|
{showSourceIcon && tx.source === 'CASH' && (
|
||||||
|
<Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
|
||||||
|
)}
|
||||||
|
{showSourceIcon && tx.source === 'TRANSFER' && (
|
||||||
|
<ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
|
||||||
|
)}
|
||||||
|
{tx.deferred_to_next_cycle && (
|
||||||
|
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-amber-600 border-amber-300">
|
||||||
|
Diferida
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (showCategory) {
|
||||||
|
columns.push({
|
||||||
|
accessorFn: (row) => row.category?.name ?? '',
|
||||||
|
id: 'category',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const category = row.original.category;
|
||||||
|
return category ? (
|
||||||
|
<Badge variant="secondary">{category.name}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push(
|
||||||
|
{
|
||||||
|
accessorKey: 'amount',
|
||||||
|
meta: { className: 'text-right' },
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Amount" className="justify-end" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const tx = row.original;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
'font-mono font-medium',
|
||||||
|
tx.transaction_type !== 'COMPRA' && 'text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
|
||||||
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
meta: { className: 'text-right' },
|
||||||
|
size: 80,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const tx = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{onToggleDeferred && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
|
||||||
|
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
|
||||||
|
onClick={() => onToggleDeferred(tx)}
|
||||||
|
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
|
||||||
|
>
|
||||||
|
<ArrowRightFromLine className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Edit transaction"
|
||||||
|
aria-label="Edit transaction"
|
||||||
|
onClick={() => onEdit(tx)}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Delete transaction"
|
||||||
|
aria-label="Delete transaction"
|
||||||
|
onClick={() => onDelete(tx.id)}
|
||||||
|
className="hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
72
frontend/src/components/ui/accordion.tsx
Normal file
72
frontend/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Root
|
||||||
|
data-slot="accordion"
|
||||||
|
className={cn("flex w-full flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("not-last:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AccordionPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||||
|
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AccordionPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Panel
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
187
frontend/src/components/ui/alert-dialog.tsx
Normal file
187
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
76
frontend/src/components/ui/alert.tsx
Normal file
76
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
52
frontend/src/components/ui/badge.tsx
Normal file
52
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
103
frontend/src/components/ui/card.tsx
Normal file
103
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
356
frontend/src/components/ui/chart.tsx
Normal file
356
frontend/src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
41
frontend/src/components/ui/data-table-column-header.tsx
Normal file
41
frontend/src/components/ui/data-table-column-header.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { type Column } from '@tanstack/react-table';
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface DataTableColumnHeaderProps<TData, TValue> {
|
||||||
|
column: Column<TData, TValue>;
|
||||||
|
title: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableColumnHeader<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <div className={cn(className)}>{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = column.getIsSorted();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn('-ml-3 h-8', className)}
|
||||||
|
onClick={() => column.toggleSorting(sorted === 'asc')}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{sorted === 'desc' ? (
|
||||||
|
<ArrowDown className="ml-1 h-3.5 w-3.5" />
|
||||||
|
) : sorted === 'asc' ? (
|
||||||
|
<ArrowUp className="ml-1 h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="ml-1 h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/components/ui/data-table.tsx
Normal file
128
frontend/src/components/ui/data-table.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type SortingState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
pagination?: boolean;
|
||||||
|
pageSize?: number;
|
||||||
|
emptyMessage?: React.ReactNode;
|
||||||
|
initialSorting?: SortingState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
pagination = false,
|
||||||
|
pageSize = 25,
|
||||||
|
emptyMessage = 'No results.',
|
||||||
|
initialSorting = [],
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
...(pagination && {
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
initialState: { pagination: { pageSize } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className={(header.column.columnDef.meta as Record<string, string>)?.className}
|
||||||
|
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className={(cell.column.columnDef.meta as Record<string, string>)?.className}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||||
|
{emptyMessage}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{pagination && table.getPageCount() > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
266
frontend/src/components/ui/dropdown-menu.tsx
Normal file
266
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
20
frontend/src/components/ui/input.tsx
Normal file
20
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
20
frontend/src/components/ui/label.tsx
Normal file
20
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
199
frontend/src/components/ui/select.tsx
Normal file
199
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
25
frontend/src/components/ui/separator.tsx
Normal file
25
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
136
frontend/src/components/ui/sheet.tsx
Normal file
136
frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
frontend/src/components/ui/table.tsx
Normal file
116
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
80
frontend/src/components/ui/tabs.tsx
Normal file
80
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:flex-row data-[orientation=vertical]:items-start",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Panel
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
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,46 +1,169 @@
|
|||||||
@import 'tailwindcss';
|
@import "@fontsource-variable/noto-sans";
|
||||||
|
@import "@fontsource-variable/ibm-plex-sans";
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "@copilotkit/react-core/v2/styles.css";
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
:root {
|
||||||
--color-surface: #FFFDF5;
|
--font-sans: "Noto Sans Variable", sans-serif;
|
||||||
--color-surface-secondary: #fefae0;
|
--font-heading: "IBM Plex Sans Variable", sans-serif;
|
||||||
--color-surface-card: rgba(96, 108, 56, 0.08);
|
|
||||||
--color-surface-hover: rgba(96, 108, 56, 0.12);
|
--background: oklch(1 0 0);
|
||||||
--color-border: rgba(96, 108, 56, 0.25);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
--color-border-subtle: rgba(96, 108, 56, 0.15);
|
--card: oklch(1 0 0);
|
||||||
--color-text-primary: #283618;
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
--color-text-secondary: #606C38;
|
--popover: oklch(1 0 0);
|
||||||
--color-text-muted: #8a9462;
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
--color-text-faint: #c2c9a7;
|
--primary: oklch(0.511 0.096 186.391);
|
||||||
--color-input-bg: #f5f1d0;
|
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
|
--chart-1: oklch(0.55 0.16 145);
|
||||||
|
--chart-2: oklch(0.62 0.19 25);
|
||||||
|
--chart-3: oklch(0.58 0.14 250);
|
||||||
|
--chart-4: oklch(0.68 0.15 80);
|
||||||
|
--chart-5: oklch(0.52 0.13 320);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.6 0.118 184.704);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
|
||||||
|
--copilot-kit-primary-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--color-surface: #020617;
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--color-surface-secondary: #0f172a;
|
--foreground: oklch(0.985 0 0);
|
||||||
--color-surface-card: rgba(15, 23, 42, 0.6);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
--color-surface-hover: rgba(30, 41, 59, 0.3);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--color-border: rgba(30, 41, 59, 0.6);
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
--color-border-subtle: rgba(30, 41, 59, 0.4);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--color-text-primary: #ffffff;
|
--primary: oklch(0.437 0.078 188.216);
|
||||||
--color-text-secondary: #94a3b8;
|
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||||
--color-text-muted: #64748b;
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--color-text-faint: #334155;
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--color-input-bg: rgba(15, 23, 42, 0.8);
|
--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);
|
||||||
|
--chart-1: oklch(0.60 0.16 145);
|
||||||
|
--chart-2: oklch(0.67 0.19 25);
|
||||||
|
--chart-3: oklch(0.63 0.14 250);
|
||||||
|
--chart-4: oklch(0.73 0.15 80);
|
||||||
|
--chart-5: oklch(0.57 0.13 320);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.704 0.14 182.503);
|
||||||
|
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
|
||||||
|
--copilot-kit-primary-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* Wire CopilotKit v2 CSS variables to WealthySmart's dark palette.
|
||||||
background-color: var(--color-surface);
|
The v2 CSS sets --background/--muted/etc directly on [data-copilotkit]
|
||||||
color: var(--color-text-primary);
|
elements (unlayered), overriding inherited values from .dark on <html>.
|
||||||
transition: background-color 0.2s, color 0.2s;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@theme inline {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
--font-sans: var(--font-sans);
|
||||||
to { opacity: 1; transform: translateY(0); }
|
--font-heading: var(--font-heading);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in {
|
@layer base {
|
||||||
animation: fade-in 0.4s ease-out both;
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--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,
|
||||||
|
});
|
||||||
25
frontend/src/lib/colors.ts
Normal file
25
frontend/src/lib/colors.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface ColorClasses {
|
||||||
|
bg: string;
|
||||||
|
ring: string;
|
||||||
|
text: string;
|
||||||
|
borderLeft: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_MAP: Record<string, ColorClasses> = {
|
||||||
|
'primary': { bg: 'bg-primary/10', ring: 'ring-primary/20', text: 'text-primary', borderLeft: 'border-l-primary' },
|
||||||
|
'destructive': { bg: 'bg-destructive/10', ring: 'ring-destructive/20', text: 'text-destructive', borderLeft: 'border-l-destructive' },
|
||||||
|
'chart-1': { bg: 'bg-chart-1/10', ring: 'ring-chart-1/20', text: 'text-chart-1', borderLeft: 'border-l-chart-1' },
|
||||||
|
'chart-2': { bg: 'bg-chart-2/10', ring: 'ring-chart-2/20', text: 'text-chart-2', borderLeft: 'border-l-chart-2' },
|
||||||
|
'chart-3': { bg: 'bg-chart-3/10', ring: 'ring-chart-3/20', text: 'text-chart-3', borderLeft: 'border-l-chart-3' },
|
||||||
|
'chart-4': { bg: 'bg-chart-4/10', ring: 'ring-chart-4/20', text: 'text-chart-4', borderLeft: 'border-l-chart-4' },
|
||||||
|
'chart-5': { bg: 'bg-chart-5/10', ring: 'ring-chart-5/20', text: 'text-chart-5', borderLeft: 'border-l-chart-5' },
|
||||||
|
'accent': { bg: 'bg-accent/10', ring: 'ring-accent/20', text: 'text-accent-foreground', borderLeft: 'border-l-accent' },
|
||||||
|
'muted': { bg: 'bg-muted/50', ring: 'ring-muted', text: 'text-muted-foreground', borderLeft: 'border-l-muted' },
|
||||||
|
'secondary': { bg: 'bg-secondary/50', ring: 'ring-secondary', text: 'text-secondary-foreground', borderLeft: 'border-l-secondary' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COLOR_OPTIONS = Object.keys(COLOR_MAP);
|
||||||
|
|
||||||
|
export function getColorClasses(colorName: string): ColorClasses {
|
||||||
|
return COLOR_MAP[colorName] ?? COLOR_MAP['primary'];
|
||||||
|
}
|
||||||
21
frontend/src/lib/format.ts
Normal file
21
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export function formatAmount(amount: number, currency: string) {
|
||||||
|
const abs = Math.abs(amount);
|
||||||
|
if (currency === 'BTC') return abs.toFixed(8);
|
||||||
|
if (currency === 'XMR') return abs.toFixed(4);
|
||||||
|
if (currency === 'USD') {
|
||||||
|
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
if (currency === 'EUR') {
|
||||||
|
return `€${abs.toLocaleString('en-US', { 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) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,16 +7,20 @@ import {
|
|||||||
Bar,
|
Bar,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
} 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 { useTheme } from '../ThemeContext';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@/components/ui/chart';
|
||||||
|
|
||||||
interface CategorySpending {
|
interface CategorySpending {
|
||||||
category_id: number | null;
|
category_id: number | null;
|
||||||
@@ -42,18 +46,29 @@ interface DailySpending {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'#606C38', '#BC6C25', '#8b5cf6', '#DDA15E', '#ef4444',
|
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
|
||||||
'#ec4899', '#283618', '#f97316', '#6366f1', '#8a9462',
|
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
|
||||||
'#e879f9', '#c2c9a7', '#fb923c', '#a78bfa', '#7a8a4a',
|
|
||||||
'#fbbf24',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatCRC(value: number) {
|
function formatCRC(value: number) {
|
||||||
return `₡${Math.round(value).toLocaleString('es-CR')}`;
|
return `₡${Math.round(value).toLocaleString('es-CR')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trendChartConfig = {
|
||||||
|
total_crc: {
|
||||||
|
label: 'Total CRC',
|
||||||
|
color: 'var(--chart-1)',
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
const dailyChartConfig = {
|
||||||
|
total: {
|
||||||
|
label: 'Daily Spending',
|
||||||
|
color: 'var(--chart-2)',
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export default function Analytics() {
|
export default function Analytics() {
|
||||||
const { theme } = useTheme();
|
|
||||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
||||||
const [byCategory, setByCategory] = useState<CategorySpending[]>([]);
|
const [byCategory, setByCategory] = useState<CategorySpending[]>([]);
|
||||||
const [trend, setTrend] = useState<MonthlyTrend[]>([]);
|
const [trend, setTrend] = useState<MonthlyTrend[]>([]);
|
||||||
@@ -79,194 +94,216 @@ export default function Analytics() {
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [cycle]);
|
}, [cycle]);
|
||||||
|
|
||||||
const tooltipStyle = {
|
// Build dynamic chart config for pie chart
|
||||||
background: theme === 'dark' ? '#1e293b' : '#FEFAE0',
|
const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => {
|
||||||
border: `1px solid ${theme === 'dark' ? '#334155' : 'rgba(96,108,56,0.25)'}`,
|
acc[cat.category_name] = {
|
||||||
borderRadius: '8px',
|
label: cat.category_name,
|
||||||
fontSize: '12px',
|
color: COLORS[i % COLORS.length],
|
||||||
color: theme === 'dark' ? '#e2e8f0' : '#283618',
|
};
|
||||||
};
|
return acc;
|
||||||
|
}, {});
|
||||||
const tickColor = theme === 'dark' ? '#64748b' : '#8a9462';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarChart3 className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a]" />
|
<BarChart3 className="w-5 h-5 text-primary" />
|
||||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
<h1 className="text-2xl font-bold font-heading">Analytics</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-muted mt-1">Spending breakdown and trends</p>
|
<p className="text-sm text-muted-foreground mt-1">Spending breakdown and trends</p>
|
||||||
</div>
|
</div>
|
||||||
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Spending by Category - Donut */}
|
{/* Spending by Category - Donut */}
|
||||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
<Card>
|
||||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
<CardHeader>
|
||||||
Spending by Category
|
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||||
</h2>
|
Spending by Category
|
||||||
{byCategory.length === 0 ? (
|
</CardTitle>
|
||||||
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
|
</CardHeader>
|
||||||
No data for this period
|
<CardContent>
|
||||||
</div>
|
{byCategory.length === 0 ? (
|
||||||
) : (
|
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
<div className="flex flex-col items-center">
|
No data for this period
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={byCategory}
|
|
||||||
dataKey="total"
|
|
||||||
nameKey="category_name"
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={100}
|
|
||||||
paddingAngle={2}
|
|
||||||
strokeWidth={0}
|
|
||||||
>
|
|
||||||
{byCategory.map((_, i) => (
|
|
||||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={tooltipStyle}
|
|
||||||
formatter={(value: any) => formatCRC(Number(value))}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
|
|
||||||
{byCategory.slice(0, 10).map((cat, i) => (
|
|
||||||
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
|
|
||||||
<div
|
|
||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
|
||||||
style={{ background: COLORS[i % COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className="text-text-secondary truncate">{cat.category_name}</span>
|
|
||||||
<span className="text-text-faint ml-auto">{cat.percentage}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="flex flex-col items-center">
|
||||||
</div>
|
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={byCategory}
|
||||||
|
dataKey="total"
|
||||||
|
nameKey="category_name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={2}
|
||||||
|
strokeWidth={0}
|
||||||
|
>
|
||||||
|
{byCategory.map((_, i) => (
|
||||||
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
formatter={(value) => formatCRC(Number(value))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
|
||||||
|
{byCategory.slice(0, 10).map((cat, i) => (
|
||||||
|
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: COLORS[i % COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground truncate">{cat.category_name}</span>
|
||||||
|
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Monthly Trend - Bar */}
|
{/* Monthly Trend - Bar */}
|
||||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
<Card>
|
||||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
<CardHeader>
|
||||||
Monthly Spending (CRC)
|
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||||
</h2>
|
Monthly Spending (CRC)
|
||||||
{trend.length === 0 ? (
|
</CardTitle>
|
||||||
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
|
</CardHeader>
|
||||||
No data
|
<CardContent>
|
||||||
</div>
|
{trend.length === 0 ? (
|
||||||
) : (
|
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
No data
|
||||||
<BarChart data={trend}>
|
</div>
|
||||||
<XAxis
|
) : (
|
||||||
dataKey="label"
|
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
|
||||||
tick={{ fill: tickColor, fontSize: 11 }}
|
<BarChart data={trend}>
|
||||||
axisLine={false}
|
<XAxis
|
||||||
tickLine={false}
|
dataKey="label"
|
||||||
/>
|
axisLine={false}
|
||||||
<YAxis
|
tickLine={false}
|
||||||
tick={{ fill: tickColor, fontSize: 11 }}
|
/>
|
||||||
axisLine={false}
|
<YAxis
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(v) => `₡${(v / 1000).toFixed(0)}k`}
|
tickLine={false}
|
||||||
/>
|
tickFormatter={(v) => `₡${(v / 1000).toFixed(0)}k`}
|
||||||
<Tooltip
|
/>
|
||||||
contentStyle={tooltipStyle}
|
<ChartTooltip
|
||||||
formatter={(value: any) => formatCRC(Number(value))}
|
content={
|
||||||
/>
|
<ChartTooltipContent
|
||||||
<Bar dataKey="total_crc" fill="#606C38" radius={[4, 4, 0, 0]} />
|
formatter={(value) => formatCRC(Number(value))}
|
||||||
</BarChart>
|
/>
|
||||||
</ResponsiveContainer>
|
}
|
||||||
)}
|
/>
|
||||||
</div>
|
<Bar dataKey="total_crc" fill="var(--color-total_crc)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Daily Spending - Line */}
|
{/* Daily Spending - Line */}
|
||||||
<div className="bg-surface-card border border-border rounded-xl p-5 lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
<CardHeader>
|
||||||
Daily Spending
|
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||||
</h2>
|
Daily Spending
|
||||||
{daily.length === 0 ? (
|
</CardTitle>
|
||||||
<div className="h-48 flex items-center justify-center text-text-faint text-sm">
|
</CardHeader>
|
||||||
No data for this period
|
<CardContent>
|
||||||
</div>
|
{daily.length === 0 ? (
|
||||||
) : (
|
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
No data for this period
|
||||||
<LineChart data={daily}>
|
</div>
|
||||||
<XAxis
|
) : (
|
||||||
dataKey="date"
|
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
|
||||||
tick={{ fill: tickColor, fontSize: 10 }}
|
<LineChart data={daily}>
|
||||||
axisLine={false}
|
<XAxis
|
||||||
tickLine={false}
|
dataKey="date"
|
||||||
tickFormatter={(v) => {
|
axisLine={false}
|
||||||
const d = new Date(v);
|
tickLine={false}
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
tickFormatter={(v) => {
|
||||||
}}
|
const d = new Date(v);
|
||||||
/>
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
<YAxis
|
}}
|
||||||
tick={{ fill: tickColor, fontSize: 11 }}
|
/>
|
||||||
axisLine={false}
|
<YAxis
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(v) => `₡${(v / 1000).toFixed(0)}k`}
|
tickLine={false}
|
||||||
/>
|
tickFormatter={(v) => `₡${(v / 1000).toFixed(0)}k`}
|
||||||
<Tooltip
|
/>
|
||||||
contentStyle={tooltipStyle}
|
<ChartTooltip
|
||||||
formatter={(value: any) => formatCRC(Number(value))}
|
content={
|
||||||
labelFormatter={(label) => new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
<ChartTooltipContent
|
||||||
/>
|
formatter={(value) => formatCRC(Number(value))}
|
||||||
<Line
|
labelFormatter={(label) =>
|
||||||
type="monotone"
|
new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
dataKey="total"
|
}
|
||||||
stroke="#BC6C25"
|
/>
|
||||||
strokeWidth={2}
|
}
|
||||||
dot={{ fill: '#BC6C25', r: 3 }}
|
/>
|
||||||
activeDot={{ r: 5 }}
|
<Line
|
||||||
/>
|
type="monotone"
|
||||||
</LineChart>
|
dataKey="total"
|
||||||
</ResponsiveContainer>
|
stroke="var(--color-total)"
|
||||||
)}
|
strokeWidth={2}
|
||||||
</div>
|
dot={{ fill: 'var(--color-total)', r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top categories summary */}
|
{/* Top categories summary */}
|
||||||
{byCategory.length > 0 && (
|
{byCategory.length > 0 && (
|
||||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
<Card>
|
||||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
<CardHeader>
|
||||||
Top Categories
|
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||||
</h2>
|
Top Categories
|
||||||
<div className="space-y-3">
|
</CardTitle>
|
||||||
{byCategory.slice(0, 8).map((cat, i) => (
|
</CardHeader>
|
||||||
<div key={cat.category_name} className="flex items-center gap-3">
|
<CardContent>
|
||||||
<div
|
<div className="space-y-3">
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
{byCategory.slice(0, 8).map((cat, i) => (
|
||||||
style={{ background: COLORS[i % COLORS.length] }}
|
<div key={cat.category_name} className="flex items-center gap-3">
|
||||||
/>
|
|
||||||
<span className="text-sm flex-1">{cat.category_name}</span>
|
|
||||||
<span className="text-xs text-text-muted">{cat.count} txns</span>
|
|
||||||
<span className="text-sm font-mono font-medium w-32 text-right">
|
|
||||||
{formatCRC(cat.total)}
|
|
||||||
</span>
|
|
||||||
<div className="w-24 bg-surface-hover rounded-full h-1.5">
|
|
||||||
<div
|
<div
|
||||||
className="h-1.5 rounded-full"
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
style={{
|
style={{ background: COLORS[i % COLORS.length] }}
|
||||||
width: `${cat.percentage}%`,
|
|
||||||
background: COLORS[i % COLORS.length],
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<span className="text-sm flex-1">{cat.category_name}</span>
|
||||||
|
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
|
||||||
|
<span data-sensitive className="text-sm font-mono font-medium w-32 text-right">
|
||||||
|
{formatCRC(cat.total)}
|
||||||
|
</span>
|
||||||
|
<div data-sensitive className="w-24 bg-muted rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${cat.percentage}%`,
|
||||||
|
background: COLORS[i % COLORS.length],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user