mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 11:28:49 +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_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
||||
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
|
||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||
AGENT_MODEL=${{ secrets.AGENT_MODEL }}
|
||||
ENVEOF
|
||||
sed -i 's/^[[:space:]]*//' .env.prod
|
||||
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -2,7 +2,15 @@ node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
*.db.bak
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
docs/legacy_budget_analysis.md
|
||||
|
||||
# Reference clones of framework repos (read-only, not tracked)
|
||||
tech_docs/
|
||||
|
||||
# Claude Code local state
|
||||
.claude/
|
||||
|
||||
24
CLAUDE.md
24
CLAUDE.md
@@ -13,9 +13,33 @@ Personal finance management web app.
|
||||
cd frontend && pnpm install && pnpm run dev
|
||||
```
|
||||
|
||||
## Local Docker
|
||||
|
||||
```bash
|
||||
# Backend + DB containers
|
||||
docker exec wealthysmart-db-dev psql -U wealthy_user -d wealthysmart -c 'SQL;'
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
- Deployed via Gitea Actions (self-hosted runner on VPS)
|
||||
- Push to `main` triggers: GitHub → webhook → Gitea mirror sync → Actions workflow → Docker build & deploy
|
||||
- Domain: wealth.cescalante.dev
|
||||
- Reverse proxy: nginx-proxy + acme-companion (auto TLS)
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **Single server**: `ssh old-vps` — runs everything (WealthySmart, n8n, Forgejo, Vaultwarden, nginx-proxy)
|
||||
- `ssh production` is **NOT valid** — do not use
|
||||
- n8n UI: https://n8n.cescalante.dev — n8n DB queryable via `docker exec portfolio-db-prod psql -U portfolio_user -d n8n`
|
||||
|
||||
## n8n Flows
|
||||
|
||||
Four automated flows on old-vps feed data into WealthySmart:
|
||||
|
||||
1. **BAC Credit Card** — Gmail trigger → POST /transactions/
|
||||
2. **Salary Deposits** — Gmail trigger → POST /transactions/
|
||||
3. **Municipal Receipts** — Cron trigger → POST /municipal-receipts/upload
|
||||
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
|
||||
|
||||
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --pre -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
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 pydantic import BaseModel
|
||||
from sqlalchemy import case
|
||||
from sqlmodel import Session, func, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import Category, Transaction
|
||||
from app.api.v1.endpoints.transactions import get_cycle_range
|
||||
from app.services.budget_projection import get_cycle_range
|
||||
from app.services.exchange_rate import get_converted_amount_expr
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
@@ -43,10 +45,12 @@ def spending_by_category(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
query = (
|
||||
select(
|
||||
Transaction.category_id,
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
func.sum(amount_crc).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == "COMPRA")
|
||||
@@ -87,7 +91,12 @@ def monthly_trend(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Monthly spending totals using billing cycle boundaries (18th-18th)."""
|
||||
"""Monthly spending totals using billing cycle boundaries (18th-18th).
|
||||
|
||||
total_crc includes all currencies converted to CRC at current rates.
|
||||
total_usd is the raw USD amount (unconverted) for display purposes.
|
||||
"""
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
now = datetime.now()
|
||||
results = []
|
||||
month_names = [
|
||||
@@ -102,18 +111,10 @@ def monthly_trend(
|
||||
row = session.exec(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.currency == "CRC", Transaction.amount),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
case(
|
||||
(Transaction.currency == "USD", Transaction.amount),
|
||||
else_=0,
|
||||
)
|
||||
@@ -162,10 +163,12 @@ def daily_spending(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
query = (
|
||||
select(
|
||||
func.date(Transaction.date).label("day"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
func.sum(amount_crc).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == "COMPRA")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi import Depends
|
||||
|
||||
from app.auth import create_access_token
|
||||
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
)
|
||||
token = create_access_token(form_data.username)
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||
return {"username": username}
|
||||
|
||||
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.db import get_session
|
||||
from app.api.v1.endpoints.notifications import send_push_to_all
|
||||
from app.models.models import (
|
||||
Category,
|
||||
Currency,
|
||||
Transaction,
|
||||
TransactionCreate,
|
||||
TransactionRead,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
TransactionUpdate,
|
||||
)
|
||||
|
||||
from app.services.budget_projection import get_cycle_range, get_previous_cycle
|
||||
from app.services.exchange_rate import get_converted_amount_expr
|
||||
|
||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
|
||||
start = datetime(year, month, 18)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 18)
|
||||
else:
|
||||
end = datetime(year, month + 1, 18)
|
||||
return start, end
|
||||
|
||||
|
||||
class BillingCycle(BaseModel):
|
||||
year: int
|
||||
month: int
|
||||
@@ -51,10 +47,13 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
|
||||
@router.get("/", response_model=list[TransactionRead])
|
||||
def list_transactions(
|
||||
source: Optional[TransactionSource] = None,
|
||||
exclude_source: Optional[TransactionSource] = None,
|
||||
search: Optional[str] = None,
|
||||
category_id: Optional[int] = None,
|
||||
cycle_year: Optional[int] = None,
|
||||
cycle_month: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = Query(default=50, le=500),
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session),
|
||||
@@ -63,13 +62,37 @@ def list_transactions(
|
||||
query = select(Transaction)
|
||||
if source:
|
||||
query = query.where(Transaction.source == source)
|
||||
if exclude_source:
|
||||
query = query.where(Transaction.source != exclude_source)
|
||||
if category_id:
|
||||
query = query.where(Transaction.category_id == category_id)
|
||||
if search:
|
||||
query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
|
||||
if cycle_year and cycle_month:
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||
prev_y, prev_m = get_previous_cycle(cycle_year, cycle_month)
|
||||
prev_start, prev_end = get_cycle_range(prev_y, prev_m)
|
||||
# Normal transactions in this cycle (not deferred) + deferred from previous cycle
|
||||
from sqlalchemy import or_, and_
|
||||
query = query.where(
|
||||
or_(
|
||||
and_(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
),
|
||||
and_(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
),
|
||||
)
|
||||
)
|
||||
elif start_date and end_date:
|
||||
query = query.where(
|
||||
Transaction.date >= datetime.fromisoformat(start_date),
|
||||
Transaction.date < datetime.fromisoformat(end_date),
|
||||
)
|
||||
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
|
||||
return session.exec(query).all()
|
||||
|
||||
@@ -88,6 +111,7 @@ def list_billing_cycles(
|
||||
return []
|
||||
|
||||
min_date, max_date = result
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
cycles = []
|
||||
# Determine which cycle the min_date falls into
|
||||
@@ -107,7 +131,7 @@ def list_billing_cycles(
|
||||
|
||||
# Count transactions in this cycle
|
||||
count_result = session.exec(
|
||||
select(func.count(), func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= start, Transaction.date < end
|
||||
)
|
||||
).first()
|
||||
@@ -170,6 +194,25 @@ def create_transaction(
|
||||
session.add(tx)
|
||||
session.commit()
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,16 @@ from app.api.v1.endpoints import (
|
||||
accounts,
|
||||
analytics,
|
||||
auth,
|
||||
budget,
|
||||
categories,
|
||||
exchange_rate,
|
||||
import_transactions,
|
||||
municipal_receipts,
|
||||
notifications,
|
||||
pensions,
|
||||
salarios,
|
||||
savings_accrual,
|
||||
settings,
|
||||
tokens,
|
||||
transactions,
|
||||
)
|
||||
@@ -20,3 +27,10 @@ api_router.include_router(import_transactions.router)
|
||||
api_router.include_router(exchange_rate.router)
|
||||
api_router.include_router(tokens.router)
|
||||
api_router.include_router(analytics.router)
|
||||
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 re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlmodel import Session, select
|
||||
@@ -22,8 +24,8 @@ def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
||||
# Try JWT first
|
||||
def _validate_token(token: str) -> str:
|
||||
"""Validate JWT and return subject, or raise 401."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
@@ -51,3 +53,26 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
||||
return f"api:{api_token.name}"
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
def get_current_user_cookie_or_bearer(
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
ws_token: Optional[str] = Cookie(default=None),
|
||||
) -> str:
|
||||
"""Accepts httpOnly cookie (SPA) or Bearer token (API clients / n8n)."""
|
||||
token: Optional[str] = None
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
elif ws_token:
|
||||
token = ws_token
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return _validate_token(token)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
ws_token: Optional[str] = Cookie(default=None),
|
||||
) -> str:
|
||||
"""SPA cookie or Bearer token. Single dependency for all v1 endpoints."""
|
||||
return get_current_user_cookie_or_bearer(authorization, ws_token)
|
||||
|
||||
@@ -9,6 +9,11 @@ class Settings(BaseSettings):
|
||||
ADMIN_PASSWORD: str = "admin"
|
||||
BCCR_API_EMAIL: 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:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
|
||||
from app.config import settings
|
||||
@@ -9,6 +10,72 @@ def init_db():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""Run idempotent schema migrations for columns added after initial create."""
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(text("ALTER TYPE currency ADD VALUE IF NOT EXISTS 'EUR'"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text("ALTER TYPE transactiontype ADD VALUE IF NOT EXISTS 'SALARY'")
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS savingsaccrual (
|
||||
id SERIAL PRIMARY KEY,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
memp_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
|
||||
mpat_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
|
||||
trigger_transaction_id INTEGER,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
notes TEXT,
|
||||
CONSTRAINT savingsaccrual_year_month_key UNIQUE (year, month)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO savingsaccrual (year, month, memp_amount, mpat_amount, notes)
|
||||
VALUES
|
||||
(2026, 2, 200000, 200000, 'Seeded: historical baseline'),
|
||||
(2026, 3, 200000, 200000, 'Seeded: historical baseline')
|
||||
ON CONFLICT (year, month) DO NOTHING
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
@@ -1,19 +1,77 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.agent import build_agent
|
||||
from app.agent.tools import reset_session, set_session
|
||||
from app.api.v1.router import api_router
|
||||
from app.auth import ALGORITHM, create_access_token
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.db import get_session, init_db, run_migrations
|
||||
from app.seed import seed_db
|
||||
from app.services.exchange_rate import refresh_rates_periodically
|
||||
|
||||
|
||||
AGENT_PATH = "/api/v1/agent/agui"
|
||||
|
||||
|
||||
def _pair_orphan_tool_calls(messages: list) -> list:
|
||||
"""Inject synthetic tool responses for any assistant tool_calls that have
|
||||
no matching tool message. OpenAI rejects histories where a tool_calls
|
||||
entry is not immediately followed by the corresponding tool response."""
|
||||
out: list = []
|
||||
pending: list[str] = []
|
||||
|
||||
def flush():
|
||||
for call_id in pending:
|
||||
out.append({"role": "tool", "tool_call_id": call_id, "content": ""})
|
||||
pending.clear()
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role == "tool":
|
||||
call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||
if call_id and call_id in pending:
|
||||
pending.remove(call_id)
|
||||
out.append(msg)
|
||||
continue
|
||||
if role == "assistant":
|
||||
flush()
|
||||
out.append(msg)
|
||||
for tc in msg.get("tool_calls") or msg.get("toolCalls") or []:
|
||||
tc_id = tc.get("id") if isinstance(tc, dict) else None
|
||||
if tc_id:
|
||||
pending.append(tc_id)
|
||||
continue
|
||||
flush()
|
||||
out.append(msg)
|
||||
|
||||
flush()
|
||||
return out
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
run_migrations()
|
||||
seed_db()
|
||||
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
rate_refresh_task.cancel()
|
||||
try:
|
||||
await rate_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
||||
@@ -26,9 +84,106 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def agent_auth_and_session(request: Request, call_next):
|
||||
"""For the AG-UI route, validate the JWT, repair message history, and
|
||||
bind a DB session to a ContextVar so agent tools can query without going
|
||||
through Depends."""
|
||||
if not request.url.path.startswith(AGENT_PATH):
|
||||
return await call_next(request)
|
||||
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
token: str | None = None
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
else:
|
||||
cookie_header = request.headers.get("cookie", "")
|
||||
m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header)
|
||||
if m:
|
||||
token = m.group(1)
|
||||
|
||||
if not token:
|
||||
return Response(status_code=401, content="Missing auth")
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
if not payload.get("sub"):
|
||||
return Response(status_code=401, content="Invalid token")
|
||||
except JWTError:
|
||||
return Response(status_code=401, content="Invalid token")
|
||||
|
||||
# Repair orphan tool_calls before the MAF agent sees the message history.
|
||||
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
||||
raw = await request.body()
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
if isinstance(body.get("messages"), list):
|
||||
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
||||
raw = json.dumps(body).encode()
|
||||
except Exception:
|
||||
pass
|
||||
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
||||
request._body = raw # type: ignore[attr-defined]
|
||||
|
||||
session_gen = get_session()
|
||||
session = next(session_gen)
|
||||
token_var = set_session(session)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_session(token_var)
|
||||
try:
|
||||
next(session_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
|
||||
# Register app routes
|
||||
app.include_router(api_router)
|
||||
|
||||
# Mount the AG-UI agent endpoint.
|
||||
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"app": "WealthySmart", "version": "0.1.0"}
|
||||
|
||||
|
||||
# ── Cookie-based auth endpoints (used by the Vite SPA) ──────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def cookie_login(body: LoginRequest, response: Response):
|
||||
if (
|
||||
body.username != settings.ADMIN_USERNAME
|
||||
or body.password != settings.ADMIN_PASSWORD
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token(body.username)
|
||||
response.set_cookie(
|
||||
key="ws_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
secure=False, # set True behind TLS in production via nginx
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout", status_code=204)
|
||||
def cookie_logout(response: Response):
|
||||
response.delete_cookie("ws_token")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, Column, UniqueConstraint
|
||||
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):
|
||||
COMPRA = "COMPRA"
|
||||
DEVOLUCION = "DEVOLUCION"
|
||||
DEPOSITO = "DEPOSITO"
|
||||
SALARY = "SALARY"
|
||||
|
||||
|
||||
class TransactionSource(str, enum.Enum):
|
||||
@@ -19,6 +36,7 @@ class TransactionSource(str, enum.Enum):
|
||||
class Currency(str, enum.Enum):
|
||||
CRC = "CRC"
|
||||
USD = "USD"
|
||||
EUR = "EUR"
|
||||
BTC = "BTC"
|
||||
XMR = "XMR"
|
||||
|
||||
@@ -124,6 +142,7 @@ class TransactionBase(SQLModel):
|
||||
bank: Bank = Bank.BAC
|
||||
notes: Optional[str] = None
|
||||
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
||||
deferred_to_next_cycle: bool = Field(default=False)
|
||||
|
||||
|
||||
class Transaction(TransactionBase, table=True):
|
||||
@@ -152,6 +171,7 @@ class TransactionUpdate(SQLModel):
|
||||
source: Optional[TransactionSource] = None
|
||||
notes: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
deferred_to_next_cycle: Optional[bool] = None
|
||||
|
||||
|
||||
# --- Exchange Rate ---
|
||||
@@ -195,3 +215,255 @@ class APITokenRead(SQLModel):
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
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 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 = [
|
||||
("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"),
|
||||
("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"),
|
||||
("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"),
|
||||
("Health", "heart-pulse", "farmacia,hospital,clinica,laboratorio,optica,medicina regenerativa,neumi,doer fitness,kettlebell,lacrosse"),
|
||||
("Education", "graduation-cap", "universidad,udemy,coursera,libro"),
|
||||
@@ -18,6 +27,7 @@ DEFAULT_CATEGORIES = [
|
||||
("Telecom", "phone", "liberty,tigo,kolbi"),
|
||||
("Parking & Fees", "circle-parking", "centro comercial curridabat,debito compass,cobro administr,compass"),
|
||||
("Auto", "car-front", "auto lavado,lavado"),
|
||||
("Electronics", "cpu", "extremetechcr,extreme tech,ticotek,ishop,gollo,radioshack"),
|
||||
("Lab & Medical", "microscope", "laboratorio echandi"),
|
||||
("Other", "tag", ""),
|
||||
]
|
||||
@@ -34,9 +44,6 @@ DEFAULT_ACCOUNTS = [
|
||||
(Bank.FCL, Currency.CRC, "FCL", AccountType.PENSION),
|
||||
(Bank.ROP, Currency.CRC, "ROP", AccountType.PENSION),
|
||||
(Bank.VOL, Currency.CRC, "VOL", AccountType.PENSION),
|
||||
# Savings (CRC)
|
||||
(Bank.MEMP, Currency.CRC, "MEMP", AccountType.SAVINGS),
|
||||
(Bank.MPAT, Currency.CRC, "MPAT", AccountType.SAVINGS),
|
||||
# Liabilities
|
||||
(Bank.MORTGAGE, Currency.USD, "Mortgage", AccountType.LIABILITY),
|
||||
# Crypto
|
||||
@@ -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():
|
||||
with Session(engine) as session:
|
||||
existing = session.exec(select(Category)).first()
|
||||
@@ -58,3 +187,9 @@ def seed_db():
|
||||
for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
|
||||
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
|
||||
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
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import case
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.models.models import ExchangeRate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Scheduled refresh interval — 4x/day
|
||||
REFRESH_INTERVAL_SECONDS = 6 * 3600
|
||||
|
||||
# BCCR indicators: 317 = buy, 318 = sell
|
||||
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
|
||||
|
||||
# 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]] = {}
|
||||
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
|
||||
CACHE_TTL = timedelta(hours=1)
|
||||
|
||||
# Generic X/CRC mid-market rate cache (by currency code)
|
||||
_xcrc_cache: dict[str, tuple[float, datetime]] = {}
|
||||
_last_known_xcrc: dict[str, float] = {}
|
||||
|
||||
# CoinGecko ids for supported crypto codes
|
||||
_COINGECKO_IDS = {"BTC": "bitcoin", "XMR": "monero"}
|
||||
COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price"
|
||||
|
||||
|
||||
def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
||||
"""Fetch a single indicator from BCCR API."""
|
||||
@@ -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.raise_for_status()
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(resp.text)
|
||||
# The value is in INGC011_DES_DATOS > NUM_VALOR
|
||||
ns = {"": "http://ws.sdde.bccr.fi.cr"}
|
||||
for datos in root.iter():
|
||||
if datos.tag.endswith("NUM_VALOR"):
|
||||
return float(datos.text.strip().replace(",", "."))
|
||||
@@ -41,14 +65,92 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | 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:
|
||||
"""Get current USD/CRC rate. Uses in-memory cache + DB fallback."""
|
||||
# Check memory cache
|
||||
"""Get current USD/CRC rate. Never returns None once a rate has been fetched."""
|
||||
global _last_known
|
||||
|
||||
# 1. Fresh memory cache (< 1 hour)
|
||||
cached = _cache.get("current")
|
||||
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
# Check DB for recent rate
|
||||
# 2. Fresh DB rate (< 1 hour)
|
||||
one_hour_ago = datetime.utcnow() - CACHE_TTL
|
||||
db_rate = session.exec(
|
||||
select(ExchangeRate)
|
||||
@@ -56,32 +158,213 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
|
||||
.order_by(col(ExchangeRate.fetched_at).desc())
|
||||
).first()
|
||||
if db_rate:
|
||||
_cache["current"] = (db_rate, datetime.utcnow())
|
||||
return db_rate
|
||||
return _remember(db_rate)
|
||||
|
||||
# Fetch from BCCR
|
||||
today = datetime.now().strftime("%d/%m/%Y")
|
||||
buy = _fetch_bccr_rate(317, today)
|
||||
sell = _fetch_bccr_rate(318, today)
|
||||
|
||||
if buy is None or sell is None:
|
||||
# Fallback: return most recent DB rate regardless of age
|
||||
fallback = session.exec(
|
||||
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
|
||||
).first()
|
||||
return fallback
|
||||
|
||||
rate = ExchangeRate(
|
||||
date=datetime.utcnow(),
|
||||
buy_rate=buy,
|
||||
sell_rate=sell,
|
||||
)
|
||||
# 3. Try all API sources
|
||||
result = _fetch_rate_from_apis()
|
||||
if result is not None:
|
||||
buy, sell = result
|
||||
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
|
||||
session.add(rate)
|
||||
session.commit()
|
||||
session.refresh(rate)
|
||||
_cache["current"] = (rate, datetime.utcnow())
|
||||
return _remember(rate)
|
||||
|
||||
# 4. Stale DB rate (any age)
|
||||
fallback = session.exec(
|
||||
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
|
||||
).first()
|
||||
if fallback:
|
||||
return _remember(fallback)
|
||||
|
||||
# 5. Last known in-memory rate (survives even if DB is empty)
|
||||
if _last_known:
|
||||
return _last_known
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_fiat_crc_mid(code: str) -> float | None:
|
||||
"""Derive {code}/CRC mid-market rate from ExchangeRate-API (USD-based).
|
||||
|
||||
X/CRC = CRC_per_USD / X_per_USD
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("result") == "success":
|
||||
crc = data["rates"].get("CRC")
|
||||
x = data["rates"].get(code)
|
||||
if crc and x:
|
||||
return float(crc) / float(x)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_crypto_crc(code: str) -> float | None:
|
||||
"""Fetch {code}/CRC spot from CoinGecko."""
|
||||
coin_id = _COINGECKO_IDS.get(code)
|
||||
if not coin_id:
|
||||
return None
|
||||
try:
|
||||
resp = httpx.get(
|
||||
COINGECKO_URL,
|
||||
params={"ids": coin_id, "vs_currencies": "crc"},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
price = data.get(coin_id, {}).get("crc")
|
||||
if price:
|
||||
return float(price)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_crc_rate(code: str) -> float | None:
|
||||
"""Get current {code}→CRC rate (cached 1 hour). Fiat via ExchangeRate-API, crypto via CoinGecko."""
|
||||
if code == "CRC":
|
||||
return 1.0
|
||||
|
||||
cached = _xcrc_cache.get(code)
|
||||
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
if code in _COINGECKO_IDS:
|
||||
rate = _fetch_crypto_crc(code)
|
||||
else:
|
||||
rate = _fetch_fiat_crc_mid(code)
|
||||
|
||||
if rate is not None:
|
||||
_xcrc_cache[code] = (rate, datetime.utcnow())
|
||||
_last_known_xcrc[code] = rate
|
||||
return rate
|
||||
|
||||
return _last_known_xcrc.get(code)
|
||||
|
||||
|
||||
def get_crc_multipliers(session: Session) -> dict[str, float]:
|
||||
"""Return {currency_code: CRC_multiplier} for every supported currency."""
|
||||
from app.models.models import Currency
|
||||
|
||||
multipliers: dict[str, float] = {"CRC": 1.0}
|
||||
|
||||
usd_rate = get_current_rate(session)
|
||||
if usd_rate:
|
||||
multipliers["USD"] = usd_rate.sell_rate
|
||||
|
||||
for code in (c.value for c in Currency):
|
||||
if code in multipliers:
|
||||
continue
|
||||
rate = get_crc_rate(code)
|
||||
if rate is not None:
|
||||
multipliers[code] = rate
|
||||
|
||||
return multipliers
|
||||
|
||||
|
||||
def get_converted_amount_expr(session: Session):
|
||||
"""Return a SQLAlchemy expression converting Transaction.amount to CRC.
|
||||
|
||||
Builds a CASE that multiplies by the per-currency CRC rate; CRC passes through.
|
||||
Missing rates fall back to 1.0 (treat as CRC) rather than 0.0 so a transient
|
||||
API outage does not silently zero out foreign-currency totals.
|
||||
"""
|
||||
from app.models.models import Transaction
|
||||
|
||||
multipliers = get_crc_multipliers(session)
|
||||
whens = [
|
||||
(Transaction.currency == code, Transaction.amount * mult)
|
||||
for code, mult in multipliers.items()
|
||||
if code != "CRC"
|
||||
]
|
||||
if not whens:
|
||||
return Transaction.amount
|
||||
return case(*whens, else_=Transaction.amount)
|
||||
|
||||
|
||||
def _refresh_usd_rate() -> bool:
|
||||
"""Force-fetch USD/CRC from APIs and persist to DB. Returns True on success."""
|
||||
fetched = _fetch_rate_from_apis()
|
||||
if fetched is None:
|
||||
return False
|
||||
buy, sell = fetched
|
||||
with Session(engine) as session:
|
||||
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
|
||||
session.add(rate)
|
||||
session.commit()
|
||||
session.refresh(rate)
|
||||
_remember(rate)
|
||||
return True
|
||||
|
||||
|
||||
def _refresh_other_rate(code: str) -> bool:
|
||||
"""Force-fetch {code}/CRC and update in-memory cache. Returns True on success."""
|
||||
if code in _COINGECKO_IDS:
|
||||
rate = _fetch_crypto_crc(code)
|
||||
else:
|
||||
rate = _fetch_fiat_crc_mid(code)
|
||||
if rate is None:
|
||||
return False
|
||||
_xcrc_cache[code] = (rate, datetime.utcnow())
|
||||
_last_known_xcrc[code] = rate
|
||||
return True
|
||||
|
||||
|
||||
def refresh_all_rates() -> dict[str, bool]:
|
||||
"""Force-refresh every supported currency.
|
||||
|
||||
Each currency is refreshed independently — one failure does not affect others.
|
||||
On success the DB (for USD) and in-memory caches are updated. On failure the
|
||||
previous value is retained via `_last_known_*` / stale-DB fallback, so callers
|
||||
always see the most recent working rate.
|
||||
"""
|
||||
from app.models.models import Currency
|
||||
|
||||
results: dict[str, bool] = {}
|
||||
|
||||
try:
|
||||
results["USD"] = _refresh_usd_rate()
|
||||
except Exception:
|
||||
logger.exception("USD rate refresh failed")
|
||||
results["USD"] = False
|
||||
|
||||
for currency in Currency:
|
||||
code = currency.value
|
||||
if code in ("CRC", "USD"):
|
||||
continue
|
||||
try:
|
||||
results[code] = _refresh_other_rate(code)
|
||||
except Exception:
|
||||
logger.exception("%s rate refresh failed", code)
|
||||
results[code] = False
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def refresh_rates_periodically(
|
||||
interval_seconds: int = REFRESH_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
"""Background loop that refreshes all currency rates every `interval_seconds`.
|
||||
|
||||
Never raises — failures are logged and the last-known rates are retained.
|
||||
Runs one refresh immediately on startup, then sleeps on the fixed interval.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
report = await asyncio.to_thread(refresh_all_rates)
|
||||
ok = sorted(k for k, v in report.items() if v)
|
||||
failed = sorted(k for k, v in report.items() if not v)
|
||||
logger.info(
|
||||
"Exchange rate refresh complete: ok=%s failed=%s", ok, failed
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Exchange rate refresh loop crashed")
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
|
||||
"""Get historical exchange rates."""
|
||||
|
||||
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
|
||||
alembic
|
||||
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}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
@@ -45,22 +49,32 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
dockerfile: Dockerfile
|
||||
target: runner
|
||||
args:
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
container_name: wealthysmart-frontend-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
BACKEND_URL: http://wealthysmart-backend-prod:8000
|
||||
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui
|
||||
JWT_SECRET: ${SECRET_KEY}
|
||||
COOKIE_DOMAIN: wealth.cescalante.dev
|
||||
COOKIE_SECURE: "true"
|
||||
VIRTUAL_HOST: wealth.cescalante.dev
|
||||
VIRTUAL_PORT: "3000"
|
||||
LETSENCRYPT_HOST: wealth.cescalante.dev
|
||||
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
|
||||
expose:
|
||||
- "80"
|
||||
- "3000"
|
||||
networks:
|
||||
- wealthysmart-network
|
||||
- nginx-prod-network
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -23,6 +23,10 @@ services:
|
||||
container_name: wealthysmart-backend-dev
|
||||
environment:
|
||||
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||
ports:
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
@@ -30,17 +34,52 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
develop:
|
||||
watch:
|
||||
- path: ./backend/app
|
||||
action: sync
|
||||
target: /app/app
|
||||
- path: ./backend/requirements.txt
|
||||
action: rebuild
|
||||
- path: ./backend/Dockerfile
|
||||
action: rebuild
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
container_name: wealthysmart-frontend-dev
|
||||
ports:
|
||||
- "5175:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- "5175:3000"
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||
depends_on:
|
||||
- backend
|
||||
develop:
|
||||
watch:
|
||||
- path: ./frontend/src
|
||||
action: sync
|
||||
target: /app/src
|
||||
- path: ./frontend/public
|
||||
action: sync
|
||||
target: /app/public
|
||||
- path: ./frontend/server.ts
|
||||
action: sync
|
||||
target: /app/server.ts
|
||||
- path: ./frontend/vite.config.ts
|
||||
action: sync+restart
|
||||
target: /app/vite.config.ts
|
||||
- path: ./frontend/tsconfig.json
|
||||
action: sync+restart
|
||||
target: /app/tsconfig.json
|
||||
- path: ./frontend/package.json
|
||||
action: rebuild
|
||||
- path: ./frontend/pnpm-lock.yaml
|
||||
action: rebuild
|
||||
- path: ./frontend/Dockerfile
|
||||
action: rebuild
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
97
docs/WealthySmart_ BAC Pensions Statements parser.json
Normal file
97
docs/WealthySmart_ BAC Pensions Statements parser.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "WealthySmart: BAC Pensions Statements parser",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"pollTimes": {
|
||||
"item": [
|
||||
{
|
||||
"hour": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"simple": false,
|
||||
"filters": {
|
||||
"q": "Estado de cuenta Pensiones BAC CREDOMATIC -{tarjeta} "
|
||||
},
|
||||
"options": {
|
||||
"downloadAttachments": true
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.gmailTrigger",
|
||||
"typeVersion": 1.3,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"id": "bc94143f-9980-4b94-9cd7-8e206dc1232e",
|
||||
"name": "Gmail Trigger",
|
||||
"credentials": {
|
||||
"gmailOAuth2": {
|
||||
"id": "LkAGMVgAqsiCkMFX",
|
||||
"name": "Gmail account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://wealth.cescalante.dev/api/v1/pensions/upload",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpBearerAuth",
|
||||
"sendBody": true,
|
||||
"contentType": "multipart-form-data",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"parameterType": "formBinaryData",
|
||||
"name": "files",
|
||||
"inputDataFieldName": "attachment_0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.4,
|
||||
"position": [
|
||||
208,
|
||||
0
|
||||
],
|
||||
"id": "b73c0182-8d56-48b3-b37e-c4dd9361a062",
|
||||
"name": "HTTP Request",
|
||||
"credentials": {
|
||||
"httpBearerAuth": {
|
||||
"id": "d4Le4M5bCuhEb2NN",
|
||||
"name": "BAC Parser - wealth.cescalante.dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"Gmail Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "HTTP Request",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": true,
|
||||
"settings": {
|
||||
"executionOrder": "v1",
|
||||
"binaryMode": "separate"
|
||||
},
|
||||
"versionId": "f0bbb681-1cf3-49f4-b06e-de0218b7ff3e",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "f446bf52a953467f756f2c188ff6b09473528d6316ecb3b9568cdbfbaa3258f3"
|
||||
},
|
||||
"id": "e88c3UhBeo9WCbcy",
|
||||
"tags": []
|
||||
}
|
||||
53
docs/a2ui-theming-findings.md
Normal file
53
docs/a2ui-theming-findings.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# A2UI Theming Findings
|
||||
|
||||
Captured 2026-04-28 while researching how to beautify the table the agent renders via `render_a2ui` without overriding component internals.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- A2UI v0.9 docs prescribe theming through a small set of **documented top-level CSS variables** declared on `:where(:root)` (zero specificity, so host CSS wins without `!important`).
|
||||
- Default theme uses `light-dark()` so tokens self-toggle. Recommended dark-mode coupling: add the documented `a2ui-dark` class on `<html>` whenever Tailwind's `.dark` is on; that flips `color-scheme: dark` and resolves every `light-dark()` to its dark branch.
|
||||
- **In our app, this approach has near-zero visible effect.** We render through `@copilotkit/a2ui-renderer` (React variant), not `@a2ui/lit`. Inspecting the compiled React renderer (`node_modules/.pnpm/@copilotkit+a2ui-renderer@1.56.4/.../catalog/basic/components/*.mjs`), every component uses **hardcoded inline styles**; the only CSS variable consumed across the whole basic catalog is `--a2ui-primary-color`.
|
||||
- This matches open issues `google/A2UI#977` and `#1285` ("React renderer parity is incomplete").
|
||||
|
||||
## Documented token surface (Lit / Angular)
|
||||
|
||||
From `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`:
|
||||
|
||||
- Color palette: `--a2ui-color-background`, `--a2ui-color-on-background`, `--a2ui-color-surface`, `--a2ui-color-on-surface`, `--a2ui-color-primary`, `--a2ui-color-primary-light/-dark/-hover`, `--a2ui-color-on-primary`, `--a2ui-color-secondary`, `--a2ui-color-secondary-light/-dark/-hover`, `--a2ui-color-on-secondary`, `--a2ui-color-input`, `--a2ui-color-on-input`, `--a2ui-color-border`.
|
||||
- Borders: `--a2ui-border`, `--a2ui-border-width`, `--a2ui-border-radius`.
|
||||
- Typography: `--a2ui-font-family-title`, `--a2ui-font-family-monospace`, `--a2ui-font-size`, `--a2ui-font-scale`, `--a2ui-font-size-xs..2xl`, `--a2ui-line-height-headings`, `--a2ui-line-height-body`.
|
||||
- Spacing: `--a2ui-grid-base`, `--a2ui-spacing-xs..xl`.
|
||||
|
||||
Per-component tokens like `--a2ui-card-background` exist but the renderer README and the official theming guide explicitly frame them as catalog-internal, not stable spec. Avoid relying on them.
|
||||
|
||||
## Dark-mode coupling
|
||||
|
||||
A2UI default uses `light-dark(...)` and supports forced modes via `a2ui-light` / `a2ui-dark` classes (see the `:where(.a2ui-dark)` block in `default.ts`). Best practice for a host that uses Tailwind `.dark` on `<html>`: toggle the matching `a2ui-dark` class in lockstep — one effect-driven `useEffect`, or add it directly to the root if the app is dark-only. No need to redeclare every token under `.dark`; the default rules already pick up the right branch through `light-dark()`.
|
||||
|
||||
## Footguns in v0.9
|
||||
|
||||
- The agent-supplied `theme` payload (e.g. `createSurface.theme.primaryColor`) is **not yet wired** in the basic catalog. Tracked in `google/A2UI#979` (Lit) and `#977` (React). Don't expect agent-side theming to recolor anything in v0.9; only the host CSS does.
|
||||
- `hintedStyles` (h1–h6 mappings) require all keys when overridden — see `google/A2UI#602`.
|
||||
- Tracking issues for the broader theming surface: `google/A2UI#1083`, `#1118`.
|
||||
|
||||
## Why this won't move the table in our app
|
||||
|
||||
`@copilotkit/a2ui-renderer@1.56.4` (React) is what `<CopilotKit a2ui={{}}>` plugs in. Its components apply colors and borders as inline `style` props (`backgroundColor: "#fff"`, `border: "1px solid #ccc"`, etc.). Inline styles can only be overridden with `!important` in author CSS, which contradicts the "don't take its freedom" goal. So the documented `--a2ui-*` token contract is currently a **Lit-only surface** for this catalog.
|
||||
|
||||
## Practical options for this codebase
|
||||
|
||||
1. **Quick win**: set `--a2ui-primary-color: var(--primary)` on `:root`. Real but small effect (primary buttons, links).
|
||||
2. **Prompt-side polish**: instruct the agent to compose richer A2UI structures — wrap the table in a `Card`, use `usageHint: "h3"` for the section title, insert a `Divider`, group columns with `Row` + `Column` weights — so the renderer's existing inline styles produce a cleaner result. No code change.
|
||||
3. **Switch renderer**: replace `@copilotkit/a2ui-renderer` with `@a2ui/lit` (or wait for `@a2ui/react` parity). Then every documented `--a2ui-*` token actually applies. Non-trivial migration.
|
||||
4. **Custom catalog overrides**: register replacement React components for `Row`/`List`/`Card` via `createA2UICatalog`; full control, most invasive, accepts ownership of those components forever.
|
||||
|
||||
## Source links
|
||||
|
||||
- `tech_docs/A2UI/docs/guides/theming.md`
|
||||
- `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`
|
||||
- `tech_docs/A2UI/renderers/lit/README.md` §"CSS-based Basic Catalog Theming"
|
||||
- `https://github.com/google/A2UI/issues/977` (React renderer theming gap)
|
||||
- `https://github.com/google/A2UI/issues/1285` (React renderer parity)
|
||||
- `https://github.com/google/A2UI/issues/1118` (theming surface roadmap)
|
||||
- `https://github.com/google/A2UI/issues/979` (Lit `theme` payload not wired)
|
||||
- `https://github.com/google/A2UI/pull/1079` (v0.9 CSS-variable rollout)
|
||||
15
frontend/.dockerignore
Normal file
15
frontend/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
coverage
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
.env*
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,7 +1,39 @@
|
||||
FROM node:20-slim
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
|
||||
# Dev: Vite HMR on port 3000 + Hono CK server on port 3001
|
||||
FROM node:22-alpine AS dev
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
CMD ["pnpm", "run", "dev", "--", "--host"]
|
||||
ENV NODE_ENV=development
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "-c", "corepack enable && pnpm dev"]
|
||||
|
||||
# Build Vite SPA
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Cap node heap so a build on a small VPS can't OOM-kill neighbours.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=1536
|
||||
RUN corepack enable && pnpm build
|
||||
|
||||
# Production: Hono serves dist/ + /api/copilotkit on port 3000
|
||||
FROM node:22-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY server.ts package.json ./
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||
CMD ["./node_modules/.bin/tsx", "server.ts"]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-slim AS build
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Serve
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
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="theme-color" content="#0f172a" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>WealthySmart</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API to backend (same docker network)
|
||||
location /api/ {
|
||||
proxy_pass http://wealthysmart-backend-prod:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# No cache for service worker
|
||||
location /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Cache immutable assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,46 @@
|
||||
{
|
||||
"name": "wealthysmart-frontend",
|
||||
"private": true,
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "tsx server.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.8.0"
|
||||
"@ag-ui/client": "0.0.52",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@copilotkit/react-core": "1.56.4",
|
||||
"@copilotkit/react-ui": "1.56.4",
|
||||
"@copilotkit/runtime": "1.56.4",
|
||||
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans": "^5.2.10",
|
||||
"@hono/node-server": "^1.14.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"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": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
const STATIC_ASSETS = ['/', '/index.html'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Network-first for API calls
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for navigation, fallback to cached index.html
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match('/index.html'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: network with cache fallback
|
||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", async () => {
|
||||
await self.registration.unregister();
|
||||
});
|
||||
|
||||
416
frontend/server.ts
Normal file
416
frontend/server.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
|
||||
import { Observable } from "rxjs";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const AGENT_URL =
|
||||
process.env.AGENT_URL ?? "http://backend:8000/api/v1/agent/agui";
|
||||
const BACKEND_URL =
|
||||
process.env.BACKEND_URL ?? "http://localhost:8001";
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
|
||||
|
||||
// ── pairOrphanToolCalls ──────────────────────────────────────────────────────
|
||||
// CopilotKit's browser-side store sometimes drops the tool-role message
|
||||
// between turns, producing assistant(toolCalls=[X]) → assistant(text) which
|
||||
// OpenAI rejects. Inject a synthetic empty tool response for each orphan id.
|
||||
|
||||
interface AGUIMessage {
|
||||
id?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
toolCalls?: Array<{ id?: string }>;
|
||||
tool_calls?: Array<{ id?: string }>;
|
||||
toolCallId?: string;
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
function pairOrphanToolCalls(messages: AGUIMessage[]): AGUIMessage[] {
|
||||
const out: AGUIMessage[] = [];
|
||||
let pending: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
for (const callId of pending) {
|
||||
out.push({ id: `synth-${Math.random().toString(36).slice(2)}`, role: "tool", toolCallId: callId, content: "" });
|
||||
}
|
||||
pending = [];
|
||||
};
|
||||
|
||||
for (const msg of messages) {
|
||||
const role = String(msg?.role ?? "");
|
||||
if (role === "tool") {
|
||||
const callId = msg.toolCallId ?? msg.tool_call_id;
|
||||
if (callId) pending = pending.filter((p) => p !== callId);
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
if (role === "assistant") {
|
||||
flush();
|
||||
const toolCalls = msg.toolCalls ?? msg.tool_calls ?? [];
|
||||
out.push(msg);
|
||||
for (const tc of toolCalls) {
|
||||
if (tc?.id) pending.push(String(tc.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
out.push(msg);
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── DeduplicateToolCallMiddleware ─────────────────────────────────────────────
|
||||
// MAF re-emits TOOL_CALL_START for declaration-only calls when they are invoked
|
||||
// in parallel with other tools, causing verifyEvents to throw an AGUIError.
|
||||
// This middleware tracks completed tool calls and silently drops any duplicate
|
||||
// START/ARGS/END events for a call ID that has already been closed.
|
||||
//
|
||||
// NOTE: runNextWithState emits { event, messages, state } wrappers; always
|
||||
// extract `.event` and forward the raw BaseEvent — never the wrapper.
|
||||
|
||||
// ── DebugLogMiddleware ──────────────────────────────────────────────────────
|
||||
// Temporary: logs every event type flowing out to diagnose double-response bug.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class DebugLogMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event;
|
||||
const type: string = event?.type ?? "";
|
||||
const extra = event?.toolCallName ? ` [${event.toolCallName}]`
|
||||
: event?.activityType ? ` [${event.activityType}]`
|
||||
: event?.messageId ? ` [msg:${event.messageId.slice(0, 8)}]`
|
||||
: "";
|
||||
console.log(`[DBG] ${type}${extra}`);
|
||||
if (type === EventType.MESSAGES_SNAPSHOT) {
|
||||
const msgs = (event as any)?.messages ?? [];
|
||||
console.log(`[DBG] SNAPSHOT msgs: ${msgs.map((m: any) => `${m.role}:${(m.id ?? "").slice(0,8)}:${typeof m.content === "string" ? m.content.length : "?"}c`).join(" | ")}`);
|
||||
}
|
||||
observer.next(event);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class DeduplicateToolCallMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
const open = new Set<string>(); // tool call IDs currently in progress
|
||||
const closed = new Set<string>(); // tool call IDs that already received END
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event; // raw BaseEvent
|
||||
const type: string = event?.type ?? "";
|
||||
const id: string | undefined = event?.toolCallId;
|
||||
|
||||
if (type === EventType.TOOL_CALL_START) {
|
||||
if (!id || closed.has(id) || open.has(id)) return; // duplicate
|
||||
open.add(id);
|
||||
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
|
||||
if (id && closed.has(id)) return; // already completed, drop
|
||||
if (type === EventType.TOOL_CALL_END && id) {
|
||||
open.delete(id);
|
||||
closed.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
observer.next(event); // emit raw BaseEvent
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── StripModelArtifactsMiddleware ────────────────────────────────────────────
|
||||
// Some OpenAI models occasionally leak training-data special tokens such as
|
||||
// `<|ipynb_marker|>` into completion text. Filter them out of streamed deltas
|
||||
// before they reach the chat UI.
|
||||
|
||||
const ARTIFACT_RE = /<\|[^|>]+\|>/g;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class StripModelArtifactsMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event;
|
||||
if (
|
||||
event?.type === EventType.TEXT_MESSAGE_CONTENT &&
|
||||
typeof event?.delta === "string" &&
|
||||
ARTIFACT_RE.test(event.delta)
|
||||
) {
|
||||
const cleaned = event.delta.replace(ARTIFACT_RE, "");
|
||||
if (cleaned.length === 0) return;
|
||||
observer.next({ ...event, delta: cleaned });
|
||||
return;
|
||||
}
|
||||
observer.next(event);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
|
||||
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
|
||||
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
|
||||
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
|
||||
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
|
||||
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
|
||||
// by id then APPENDS unknown ids, so the browser ends up with two assistant
|
||||
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
|
||||
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
|
||||
// the assistant-with-toolCalls message in state past the run). The right fix
|
||||
// is to drop just the orphan text-only assistant message that has no streamed
|
||||
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
const streamedTextIds = new Set<string>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event;
|
||||
const type: string = event?.type ?? "";
|
||||
|
||||
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
|
||||
streamedTextIds.add(String(event.messageId));
|
||||
}
|
||||
|
||||
if (type === EventType.MESSAGES_SNAPSHOT) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
||||
const filtered = msgs.filter((m) => {
|
||||
if (m?.role !== "assistant") return true;
|
||||
const id = String(m?.id ?? "");
|
||||
const hasText = typeof m?.content === "string" && m.content.length > 0;
|
||||
const hasToolCalls =
|
||||
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
|
||||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
|
||||
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) {
|
||||
return false; // drop orphan text-only assistant duplicate
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (filtered.length !== msgs.length) {
|
||||
observer.next({ ...event, messages: filtered });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
observer.next(event);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => observer.complete(),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── SuppressRenderToolTextMiddleware ──────────────────────────────────────────
|
||||
// When the LLM emits text content in the same response as a render tool call
|
||||
// (render_a2ui or render_spending_summary), the text appears as a duplicate
|
||||
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
|
||||
// them if any render tool call is detected in the same turn.
|
||||
|
||||
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
let renderToolSeen = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const textBuffer: any[] = [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event; // raw BaseEvent
|
||||
const type: string = event?.type ?? "";
|
||||
|
||||
if (type === EventType.TOOL_CALL_START) {
|
||||
const toolName: string = event?.toolCallName ?? "";
|
||||
if (RENDER_TOOLS.has(toolName)) {
|
||||
renderToolSeen = true;
|
||||
textBuffer.length = 0; // discard text buffered before we saw the tool call
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
type === EventType.TEXT_MESSAGE_START ||
|
||||
type === EventType.TEXT_MESSAGE_CONTENT ||
|
||||
type === EventType.TEXT_MESSAGE_END
|
||||
) {
|
||||
if (!renderToolSeen) textBuffer.push(event); // buffer raw event
|
||||
return; // always hold — flush at turn end
|
||||
}
|
||||
|
||||
if (type === EventType.RUN_FINISHED || type === EventType.RUN_ERROR) {
|
||||
if (!renderToolSeen) {
|
||||
for (const e of textBuffer) observer.next(e); // flush raw events
|
||||
}
|
||||
textBuffer.length = 0;
|
||||
observer.next(event); // emit raw event
|
||||
return;
|
||||
}
|
||||
|
||||
observer.next(event); // emit raw event
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: (err: any) => observer.error(err),
|
||||
complete: () => {
|
||||
if (!renderToolSeen) {
|
||||
for (const e of textBuffer) observer.next(e);
|
||||
}
|
||||
observer.complete();
|
||||
},
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hono app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.all("/api/copilotkit/*", async (c) => {
|
||||
const cookieHeader = c.req.header("cookie") ?? "";
|
||||
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
|
||||
const token = match?.[1];
|
||||
const agentHeaders: Record<string, string> = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
|
||||
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new StripModelArtifactsMiddleware() as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new ReconcileSnapshotMiddleware() as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new DeduplicateToolCallMiddleware() as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent.use(new DebugLogMiddleware() as any);
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { wealthysmart: agent },
|
||||
a2ui: { injectA2UITool: true },
|
||||
beforeRequestMiddleware: async ({ request: outbound }) => {
|
||||
if (outbound.method !== "POST") return;
|
||||
const ct = outbound.headers.get("content-type") ?? "";
|
||||
if (!ct.includes("application/json")) return;
|
||||
try {
|
||||
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
|
||||
if (!Array.isArray(body.messages)) return;
|
||||
const paired = pairOrphanToolCalls(body.messages);
|
||||
if (paired.length === body.messages.length) return;
|
||||
return new Request(outbound.url, {
|
||||
method: outbound.method,
|
||||
headers: outbound.headers,
|
||||
body: JSON.stringify({ ...body, messages: paired }),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
|
||||
});
|
||||
|
||||
app.get("/api/health", (c) => c.json({ ok: true }));
|
||||
|
||||
// Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly,
|
||||
// but in prod the browser talks to this Hono server, which must forward
|
||||
// `/api/v1/*` and `/api/auth/*` to the FastAPI container — otherwise the SPA
|
||||
// fallback below swallows them and returns index.html.
|
||||
const proxyToBackend = async (c: import("hono").Context) => {
|
||||
const url = new URL(c.req.url);
|
||||
const target = `${BACKEND_URL}${url.pathname}${url.search}`;
|
||||
const method = c.req.method;
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.delete("host");
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
};
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
init.body = c.req.raw.body;
|
||||
// @ts-expect-error undici requires duplex for streamed bodies
|
||||
init.duplex = "half";
|
||||
}
|
||||
return fetch(target, init);
|
||||
};
|
||||
|
||||
app.all("/api/v1/*", proxyToBackend);
|
||||
app.all("/api/auth/*", proxyToBackend);
|
||||
|
||||
// In production, serve the Vite build output.
|
||||
if (isProd) {
|
||||
app.use("/*", serveStatic({ root: "./dist" }));
|
||||
// SPA fallback: any non-asset path returns index.html
|
||||
app.get("/*", serveStatic({ path: "./dist/index.html" }));
|
||||
}
|
||||
|
||||
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
||||
console.log(`CopilotKit server on port ${info.port} [${isProd ? "prod" : "dev"}]`);
|
||||
});
|
||||
@@ -1,26 +1,34 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import { ThemeProvider } from './ThemeContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Transactions from './pages/Transactions';
|
||||
import Transfers from './pages/Transfers';
|
||||
import Analytics from './pages/Analytics';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { AuthProvider, useAuth } from "./AuthContext";
|
||||
import { ThemeProvider } from "./contexts/theme-context";
|
||||
import { PrivacyProvider } from "./contexts/privacy-context";
|
||||
import Layout from "./components/Layout";
|
||||
import LoginPage from "./pages/Login";
|
||||
import Asistente from "./pages/Asistente";
|
||||
import Analytics from "./pages/Analytics";
|
||||
import Budget from "./pages/Budget";
|
||||
import Salarios from "./pages/Salarios";
|
||||
import Pensions from "./pages/Pensions";
|
||||
import Proyecciones from "./pages/Proyecciones";
|
||||
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
if (isLoading) return null;
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
||||
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
@@ -29,10 +37,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/transactions" element={<Transactions />} />
|
||||
<Route index element={<Navigate to="/asistente" replace />} />
|
||||
<Route path="/asistente" element={<Asistente />} />
|
||||
<Route path="/budget" element={<Budget />} />
|
||||
<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>
|
||||
</Routes>
|
||||
);
|
||||
@@ -42,9 +56,13 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<PrivacyProvider>
|
||||
<AuthProvider>
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
|
||||
<AppRoutes />
|
||||
</CopilotKit>
|
||||
</AuthProvider>
|
||||
</PrivacyProvider>
|
||||
</ThemeProvider>
|
||||
</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 {
|
||||
isAuthenticated: boolean;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
logout: () => Promise<void>;
|
||||
setAuthenticated: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthCtx>({
|
||||
isAuthenticated: false,
|
||||
logout: () => {},
|
||||
isLoading: true,
|
||||
logout: async () => {},
|
||||
setAuthenticated: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token'));
|
||||
const [isAuthenticated, setAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setAuthenticated(!!localStorage.getItem('token'));
|
||||
window.addEventListener('storage', check);
|
||||
return () => window.removeEventListener('storage', check);
|
||||
// Probe auth state by hitting a protected endpoint.
|
||||
// If the ws_token cookie is valid, the server returns 200; else 401.
|
||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||
.then((r) => setAuthenticated(r.ok))
|
||||
.catch(() => setAuthenticated(false))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
const logout = async () => {
|
||||
await apiLogout();
|
||||
setAuthenticated(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
|
||||
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,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 { Calendar, ChevronDown } from 'lucide-react';
|
||||
import api from '../api';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface CycleOption {
|
||||
year: number;
|
||||
@@ -22,33 +29,34 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-text-muted" />
|
||||
<div className="relative">
|
||||
<select
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={selectedKey}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
onValueChange={(val) => {
|
||||
if (val === 'all') {
|
||||
onChange(null);
|
||||
} else {
|
||||
const [y, m] = e.target.value.split('-').map(Number);
|
||||
const [y, m] = val.split('-').map(Number);
|
||||
onChange({ year: y, month: m });
|
||||
}
|
||||
}}
|
||||
className="appearance-none bg-input-bg border border-border rounded-lg pl-3 pr-9 py-2 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
|
||||
>
|
||||
<option value="">All time</option>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All time</SelectItem>
|
||||
{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})
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 {
|
||||
title: string;
|
||||
@@ -11,45 +22,26 @@ interface Props {
|
||||
|
||||
export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-surface border border-border rounded-xl w-full max-w-sm animate-fade-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<button onClick={onCancel} className="text-text-muted hover:text-text-primary transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-5">
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 dark:text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary pt-2">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 px-5 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10">
|
||||
<AlertTriangle className="text-destructive" />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{message}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-red-500 hover:bg-red-600 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Deleting...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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 {
|
||||
LayoutDashboard,
|
||||
CreditCard,
|
||||
Sparkles,
|
||||
Calculator,
|
||||
BarChart3,
|
||||
ArrowLeftRight,
|
||||
Landmark,
|
||||
PiggyBank,
|
||||
Droplets,
|
||||
LogOut,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
Menu,
|
||||
X,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
Eye,
|
||||
EyeOff,
|
||||
type LucideIcon,
|
||||
} 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 = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/transactions', icon: CreditCard, label: 'Transactions' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
|
||||
interface NavSection {
|
||||
label: string;
|
||||
items: { to: string; icon: LucideIcon; label: string }[];
|
||||
}
|
||||
|
||||
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() {
|
||||
const { logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-text-primary">
|
||||
{/* Top bar */}
|
||||
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-surface/90">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
|
||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-[#FEFAE0]" strokeWidth={2.5} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
title="Open menu"
|
||||
aria-label="Open menu"
|
||||
className="md:hidden"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight hidden sm:inline">
|
||||
Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span>
|
||||
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
|
||||
Wealthy<span className="text-primary">Smart</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 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>
|
||||
))}
|
||||
</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
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
|
||||
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
|
||||
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
</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>
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
||||
<div className="flex">
|
||||
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
|
||||
<div className="flex-1">
|
||||
<SidebarNav />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { X, ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import api, { type ImportResult } from '../api';
|
||||
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
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 {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardPaste className="w-4 h-4 text-[#606C38] dark:text-[#7a8a4a]" />
|
||||
<h3 className="font-semibold">Import Bank Statement</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<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" />
|
||||
Import Bank Statement
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{!result ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Bank</label>
|
||||
<select className={inputClass} value={bank} onChange={(e) => setBank(e.target.value)}>
|
||||
<option value="BAC">BAC</option>
|
||||
<option value="BCR">BCR</option>
|
||||
<option value="DAVIVIENDA">Davivienda</option>
|
||||
</select>
|
||||
<div 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>
|
||||
<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 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>
|
||||
<label className={labelClass}>Statement Text</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-48 font-mono text-xs resize-y`}
|
||||
<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-text-faint mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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"
|
||||
>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
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"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={importing || !text.trim()}>
|
||||
{importing ? 'Importing...' : 'Import'}
|
||||
</button>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</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">
|
||||
<Alert>
|
||||
<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`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{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">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{result.errors.length} errors</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="text-xs font-mono max-h-32 overflow-y-auto space-y-1 mt-1">
|
||||
{result.errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors"
|
||||
>
|
||||
<Button onClick={onClose} className="w-full">
|
||||
Done
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 { X } from 'lucide-react';
|
||||
import api, { type Category, type Transaction } from '../api';
|
||||
import api, { type Category, type Transaction } from '@/lib/api';
|
||||
import { formatLocalDatetime } from '@/lib/format';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 {
|
||||
transaction?: Transaction | null;
|
||||
@@ -15,7 +34,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
merchant: '',
|
||||
amount: '',
|
||||
currency: 'CRC',
|
||||
date: new Date().toISOString().slice(0, 16),
|
||||
date: formatLocalDatetime(new Date()),
|
||||
transaction_type: 'COMPRA',
|
||||
source,
|
||||
bank: 'BAC',
|
||||
@@ -29,7 +48,10 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
const [error, setError] = useState('');
|
||||
|
||||
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(() => {
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface border border-border rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold">
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{transaction ? 'Edit Transaction' : 'New Transaction'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-500 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Merchant</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label>Merchant</Label>
|
||||
<Input
|
||||
value={form.merchant}
|
||||
onChange={(e) => setForm({ ...form, merchant: e.target.value })}
|
||||
placeholder="e.g. AUTO MERCADO ON LINE"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Amount</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
<div className="space-y-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.amount}
|
||||
@@ -128,69 +142,79 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Currency</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm({ ...form, currency: e.target.value })}
|
||||
>
|
||||
<option value="CRC">CRC (₡)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Select value={form.currency} onValueChange={(v) => setForm({ ...form, currency: v })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CRC">CRC (₡)</SelectItem>
|
||||
<SelectItem value="USD">USD ($)</SelectItem>
|
||||
<SelectItem value="EUR">EUR (€)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Date</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Type</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={form.transaction_type}
|
||||
onChange={(e) => setForm({ ...form, transaction_type: e.target.value })}
|
||||
>
|
||||
<option value="COMPRA">Compra</option>
|
||||
<option value="DEVOLUCION">Devolución</option>
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={form.transaction_type} onValueChange={(v) => setForm({ ...form, transaction_type: v })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="COMPRA">Compra</SelectItem>
|
||||
<SelectItem value="DEVOLUCION">Devolución</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Category</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={form.category_id}
|
||||
onChange={(e) => setForm({ ...form, category_id: e.target.value })}
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select
|
||||
value={form.category_id ? String(form.category_id) : 'auto'}
|
||||
onValueChange={(v) => setForm({ ...form, category_id: v === 'auto' ? '' : v })}
|
||||
>
|
||||
<option value="">Auto-detect</option>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{form.category_id
|
||||
? categories.find((c) => c.id === Number(form.category_id))?.name ?? form.category_id
|
||||
: 'Auto-detect'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Bank</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={form.bank}
|
||||
onChange={(e) => setForm({ ...form, bank: e.target.value })}
|
||||
>
|
||||
<option value="BAC">BAC</option>
|
||||
<option value="BCR">BCR</option>
|
||||
<option value="DAVIVIENDA">Davivienda</option>
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
<Label>Bank</Label>
|
||||
<Select value={form.bank} onValueChange={(v) => setForm({ ...form, bank: v })}>
|
||||
<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>
|
||||
<label className={labelClass}>City</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
<div className="space-y-2">
|
||||
<Label>City</Label>
|
||||
<Input
|
||||
value={form.city}
|
||||
onChange={(e) => setForm({ ...form, city: e.target.value })}
|
||||
placeholder="SAN JOSE, Costa Rica"
|
||||
@@ -198,19 +222,17 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
</div>
|
||||
{source === 'CREDIT_CARD' && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Card Type</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
<div className="space-y-2">
|
||||
<Label>Card Type</Label>
|
||||
<Input
|
||||
value={form.card_type}
|
||||
onChange={(e) => setForm({ ...form, card_type: e.target.value })}
|
||||
placeholder="MASTER"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Card Last 4</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
<div className="space-y-2">
|
||||
<Label>Card Last 4</Label>
|
||||
<Input
|
||||
value={form.card_last4}
|
||||
onChange={(e) => setForm({ ...form, card_last4: e.target.value })}
|
||||
placeholder="6585"
|
||||
@@ -219,10 +241,9 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Notes</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Input
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Optional notes"
|
||||
@@ -230,24 +251,16 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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 {
|
||||
--color-surface: #FFFDF5;
|
||||
--color-surface-secondary: #fefae0;
|
||||
--color-surface-card: rgba(96, 108, 56, 0.08);
|
||||
--color-surface-hover: rgba(96, 108, 56, 0.12);
|
||||
--color-border: rgba(96, 108, 56, 0.25);
|
||||
--color-border-subtle: rgba(96, 108, 56, 0.15);
|
||||
--color-text-primary: #283618;
|
||||
--color-text-secondary: #606C38;
|
||||
--color-text-muted: #8a9462;
|
||||
--color-text-faint: #c2c9a7;
|
||||
--color-input-bg: #f5f1d0;
|
||||
:root {
|
||||
--font-sans: "Noto Sans Variable", sans-serif;
|
||||
--font-heading: "IBM Plex Sans Variable", sans-serif;
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.511 0.096 186.391);
|
||||
--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 {
|
||||
--color-surface: #020617;
|
||||
--color-surface-secondary: #0f172a;
|
||||
--color-surface-card: rgba(15, 23, 42, 0.6);
|
||||
--color-surface-hover: rgba(30, 41, 59, 0.3);
|
||||
--color-border: rgba(30, 41, 59, 0.6);
|
||||
--color-border-subtle: rgba(30, 41, 59, 0.4);
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-faint: #334155;
|
||||
--color-input-bg: rgba(15, 23, 42, 0.8);
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
/* Wire CopilotKit v2 CSS variables to WealthySmart's dark palette.
|
||||
The v2 CSS sets --background/--muted/etc directly on [data-copilotkit]
|
||||
elements (unlayered), overriding inherited values from .dark on <html>.
|
||||
Using html.dark [data-copilotkit] (specificity 0,2,1) beats the v2's
|
||||
own .dark [data-copilotkit] (specificity 0,2,0) and restores dark mode. */
|
||||
html.dark [data-copilotkit] {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.437 0.078 188.216);
|
||||
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-sans);
|
||||
--font-heading: var(--font-heading);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--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);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out both;
|
||||
/* 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 { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
|
||||
@@ -7,16 +7,20 @@ import {
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
} from 'recharts';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
import api from '../api';
|
||||
import BillingCycleSelector from '../components/BillingCycleSelector';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
import api from '@/lib/api';
|
||||
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
|
||||
interface CategorySpending {
|
||||
category_id: number | null;
|
||||
@@ -42,18 +46,29 @@ interface DailySpending {
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#606C38', '#BC6C25', '#8b5cf6', '#DDA15E', '#ef4444',
|
||||
'#ec4899', '#283618', '#f97316', '#6366f1', '#8a9462',
|
||||
'#e879f9', '#c2c9a7', '#fb923c', '#a78bfa', '#7a8a4a',
|
||||
'#fbbf24',
|
||||
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
|
||||
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
|
||||
];
|
||||
|
||||
function formatCRC(value: number) {
|
||||
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() {
|
||||
const { theme } = useTheme();
|
||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
||||
const [byCategory, setByCategory] = useState<CategorySpending[]>([]);
|
||||
const [trend, setTrend] = useState<MonthlyTrend[]>([]);
|
||||
@@ -79,42 +94,44 @@ export default function Analytics() {
|
||||
.catch(console.error);
|
||||
}, [cycle]);
|
||||
|
||||
const tooltipStyle = {
|
||||
background: theme === 'dark' ? '#1e293b' : '#FEFAE0',
|
||||
border: `1px solid ${theme === 'dark' ? '#334155' : 'rgba(96,108,56,0.25)'}`,
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
color: theme === 'dark' ? '#e2e8f0' : '#283618',
|
||||
// Build dynamic chart config for pie chart
|
||||
const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => {
|
||||
acc[cat.category_name] = {
|
||||
label: cat.category_name,
|
||||
color: COLORS[i % COLORS.length],
|
||||
};
|
||||
|
||||
const tickColor = theme === 'dark' ? '#64748b' : '#8a9462';
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a]" />
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<BarChart3 className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-2xl font-bold font-heading">Analytics</h1>
|
||||
</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>
|
||||
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Spending by Category - Donut */}
|
||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||
Spending by Category
|
||||
</h2>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{byCategory.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
|
||||
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for this period
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={byCategory}
|
||||
@@ -131,12 +148,15 @@ export default function Analytics() {
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: any) => formatCRC(Number(value))}
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => formatCRC(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
|
||||
@@ -146,64 +166,72 @@ export default function Analytics() {
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Monthly Trend - Bar */}
|
||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||
Monthly Spending (CRC)
|
||||
</h2>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trend.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
|
||||
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
|
||||
<BarChart data={trend}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: tickColor, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: tickColor, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `₡${(v / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: any) => formatCRC(Number(value))}
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => formatCRC(Number(value))}
|
||||
/>
|
||||
<Bar dataKey="total_crc" fill="#606C38" radius={[4, 4, 0, 0]} />
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="total_crc" fill="var(--color-total_crc)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily Spending - Line */}
|
||||
<div className="bg-surface-card border border-border rounded-xl p-5 lg:col-span-2">
|
||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||
Daily Spending
|
||||
</h2>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{daily.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-text-faint text-sm">
|
||||
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for this period
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
|
||||
<LineChart data={daily}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: tickColor, fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => {
|
||||
@@ -212,36 +240,44 @@ export default function Analytics() {
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: tickColor, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `₡${(v / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: any) => formatCRC(Number(value))}
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => formatCRC(Number(value))}
|
||||
labelFormatter={(label) =>
|
||||
new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="#BC6C25"
|
||||
stroke="var(--color-total)"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#BC6C25', r: 3 }}
|
||||
dot={{ fill: 'var(--color-total)', r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top categories summary */}
|
||||
{byCategory.length > 0 && (
|
||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||
Top Categories
|
||||
</h2>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{byCategory.slice(0, 8).map((cat, i) => (
|
||||
<div key={cat.category_name} className="flex items-center gap-3">
|
||||
@@ -250,11 +286,11 @@ export default function Analytics() {
|
||||
style={{ background: COLORS[i % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-sm flex-1">{cat.category_name}</span>
|
||||
<span className="text-xs text-text-muted">{cat.count} txns</span>
|
||||
<span className="text-sm font-mono font-medium w-32 text-right">
|
||||
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
|
||||
<span data-sensitive className="text-sm font-mono font-medium w-32 text-right">
|
||||
{formatCRC(cat.total)}
|
||||
</span>
|
||||
<div className="w-24 bg-surface-hover rounded-full h-1.5">
|
||||
<div data-sensitive className="w-24 bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="h-1.5 rounded-full"
|
||||
style={{
|
||||
@@ -266,7 +302,8 @@ export default function Analytics() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</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