mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +02:00
Compare commits
17 Commits
d4d0f65759
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20b4ad102d | ||
|
|
ec716e698f | ||
|
|
f556c392fb | ||
|
|
aa4bb6512f | ||
|
|
6b3069eef4 | ||
|
|
ead8fb8684 | ||
|
|
097fe9c4cf | ||
|
|
c92bfc66fe | ||
|
|
cf8b7be778 | ||
|
|
8b3a19b552 | ||
|
|
5d5727ec4e | ||
|
|
140a75f706 | ||
|
|
7f602a67af | ||
|
|
5f2a4105f3 | ||
|
|
c4768e6912 | ||
|
|
9fe17c0607 | ||
|
|
98d32df763 |
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -23,6 +23,8 @@ jobs:
|
|||||||
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
|
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
|
||||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||||
|
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||||
|
AGENT_MODEL=${{ secrets.AGENT_MODEL }}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
sed -i 's/^[[:space:]]*//' .env.prod
|
sed -i 's/^[[:space:]]*//' .env.prod
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,3 +8,9 @@ __pycache__/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
docs/legacy_budget_analysis.md
|
docs/legacy_budget_analysis.md
|
||||||
|
|
||||||
|
# Reference clones of framework repos (read-only, not tracked)
|
||||||
|
tech_docs/
|
||||||
|
|
||||||
|
# Claude Code local state
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ FROM python:3.11-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir --pre -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|||||||
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,
|
||||||
|
]
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
from app.auth import create_access_token
|
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
|||||||
)
|
)
|
||||||
token = create_access_token(form_data.username)
|
token = create_access_token(form_data.username)
|
||||||
return {"access_token": token, "token_type": "bearer"}
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||||
|
return {"username": username}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Cookie, Depends, Header, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -22,8 +24,8 @@ def hash_token(token: str) -> str:
|
|||||||
return hashlib.sha256(token.encode()).hexdigest()
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
def _validate_token(token: str) -> str:
|
||||||
# Try JWT first
|
"""Validate JWT and return subject, or raise 401."""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
@@ -51,3 +53,26 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
|||||||
return f"api:{api_token.name}"
|
return f"api:{api_token.name}"
|
||||||
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_cookie_or_bearer(
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
ws_token: Optional[str] = Cookie(default=None),
|
||||||
|
) -> str:
|
||||||
|
"""Accepts httpOnly cookie (SPA) or Bearer token (API clients / n8n)."""
|
||||||
|
token: Optional[str] = None
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
token = authorization.split(" ", 1)[1].strip()
|
||||||
|
elif ws_token:
|
||||||
|
token = ws_token
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
return _validate_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
ws_token: Optional[str] = Cookie(default=None),
|
||||||
|
) -> str:
|
||||||
|
"""SPA cookie or Bearer token. Single dependency for all v1 endpoints."""
|
||||||
|
return get_current_user_cookie_or_bearer(authorization, ws_token)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class Settings(BaseSettings):
|
|||||||
VAPID_PRIVATE_KEY: str = ""
|
VAPID_PRIVATE_KEY: str = ""
|
||||||
VAPID_PUBLIC_KEY: str = ""
|
VAPID_PUBLIC_KEY: str = ""
|
||||||
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
||||||
|
OPENAI_API_KEY: str = ""
|
||||||
|
AGENT_MODEL: str = "gpt-5.4-mini"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -1,16 +1,63 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.agent.agent import build_agent
|
||||||
|
from app.agent.tools import reset_session, set_session
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
|
from app.auth import ALGORITHM, create_access_token
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import init_db, run_migrations
|
from app.db import get_session, init_db, run_migrations
|
||||||
from app.seed import seed_db
|
from app.seed import seed_db
|
||||||
from app.services.exchange_rate import refresh_rates_periodically
|
from app.services.exchange_rate import refresh_rates_periodically
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_PATH = "/api/v1/agent/agui"
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_orphan_tool_calls(messages: list) -> list:
|
||||||
|
"""Inject synthetic tool responses for any assistant tool_calls that have
|
||||||
|
no matching tool message. OpenAI rejects histories where a tool_calls
|
||||||
|
entry is not immediately followed by the corresponding tool response."""
|
||||||
|
out: list = []
|
||||||
|
pending: list[str] = []
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
for call_id in pending:
|
||||||
|
out.append({"role": "tool", "tool_call_id": call_id, "content": ""})
|
||||||
|
pending.clear()
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role", "")
|
||||||
|
if role == "tool":
|
||||||
|
call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||||
|
if call_id and call_id in pending:
|
||||||
|
pending.remove(call_id)
|
||||||
|
out.append(msg)
|
||||||
|
continue
|
||||||
|
if role == "assistant":
|
||||||
|
flush()
|
||||||
|
out.append(msg)
|
||||||
|
for tc in msg.get("tool_calls") or msg.get("toolCalls") or []:
|
||||||
|
tc_id = tc.get("id") if isinstance(tc, dict) else None
|
||||||
|
if tc_id:
|
||||||
|
pending.append(tc_id)
|
||||||
|
continue
|
||||||
|
flush()
|
||||||
|
out.append(msg)
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
@@ -37,9 +84,106 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def agent_auth_and_session(request: Request, call_next):
|
||||||
|
"""For the AG-UI route, validate the JWT, repair message history, and
|
||||||
|
bind a DB session to a ContextVar so agent tools can query without going
|
||||||
|
through Depends."""
|
||||||
|
if not request.url.path.startswith(AGENT_PATH):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
token: str | None = None
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
else:
|
||||||
|
cookie_header = request.headers.get("cookie", "")
|
||||||
|
m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header)
|
||||||
|
if m:
|
||||||
|
token = m.group(1)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return Response(status_code=401, content="Missing auth")
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
if not payload.get("sub"):
|
||||||
|
return Response(status_code=401, content="Invalid token")
|
||||||
|
except JWTError:
|
||||||
|
return Response(status_code=401, content="Invalid token")
|
||||||
|
|
||||||
|
# Repair orphan tool_calls before the MAF agent sees the message history.
|
||||||
|
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
||||||
|
raw = await request.body()
|
||||||
|
try:
|
||||||
|
body = json.loads(raw)
|
||||||
|
if isinstance(body.get("messages"), list):
|
||||||
|
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
||||||
|
raw = json.dumps(body).encode()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
||||||
|
request._body = raw # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
session_gen = get_session()
|
||||||
|
session = next(session_gen)
|
||||||
|
token_var = set_session(session)
|
||||||
|
try:
|
||||||
|
return await call_next(request)
|
||||||
|
finally:
|
||||||
|
reset_session(token_var)
|
||||||
|
try:
|
||||||
|
next(session_gen)
|
||||||
|
except StopIteration:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Register app routes
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
# Mount the AG-UI agent endpoint.
|
||||||
|
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return {"app": "WealthySmart", "version": "0.1.0"}
|
return {"app": "WealthySmart", "version": "0.1.0"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cookie-based auth endpoints (used by the Vite SPA) ──────────────────────
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
def cookie_login(body: LoginRequest, response: Response):
|
||||||
|
if (
|
||||||
|
body.username != settings.ADMIN_USERNAME
|
||||||
|
or body.password != settings.ADMIN_PASSWORD
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
token = create_access_token(body.username)
|
||||||
|
response.set_cookie(
|
||||||
|
key="ws_token",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
secure=False, # set True behind TLS in production via nginx
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout", status_code=204)
|
||||||
|
def cookie_logout(response: Response):
|
||||||
|
response.delete_cookie("ws_token")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -11,3 +11,6 @@ httpx
|
|||||||
pywebpush
|
pywebpush
|
||||||
py-vapid
|
py-vapid
|
||||||
python-dateutil
|
python-dateutil
|
||||||
|
agent-framework==1.2.1
|
||||||
|
agent-framework-ag-ui==1.0.0b260428
|
||||||
|
agent-framework-openai==1.2.1
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
networks:
|
networks:
|
||||||
@@ -47,22 +49,32 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.prod
|
dockerfile: Dockerfile
|
||||||
|
target: runner
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
container_name: wealthysmart-frontend-prod
|
container_name: wealthysmart-frontend-prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
BACKEND_URL: http://wealthysmart-backend-prod:8000
|
||||||
|
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui
|
||||||
|
JWT_SECRET: ${SECRET_KEY}
|
||||||
|
COOKIE_DOMAIN: wealth.cescalante.dev
|
||||||
|
COOKIE_SECURE: "true"
|
||||||
VIRTUAL_HOST: wealth.cescalante.dev
|
VIRTUAL_HOST: wealth.cescalante.dev
|
||||||
|
VIRTUAL_PORT: "3000"
|
||||||
LETSENCRYPT_HOST: wealth.cescalante.dev
|
LETSENCRYPT_HOST: wealth.cescalante.dev
|
||||||
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
|
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "3000"
|
||||||
networks:
|
networks:
|
||||||
- wealthysmart-network
|
- wealthysmart-network
|
||||||
- nginx-prod-network
|
- nginx-prod-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ services:
|
|||||||
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -32,17 +34,52 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- path: ./backend/app
|
||||||
|
action: sync
|
||||||
|
target: /app/app
|
||||||
|
- path: ./backend/requirements.txt
|
||||||
|
action: rebuild
|
||||||
|
- path: ./backend/Dockerfile
|
||||||
|
action: rebuild
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: dev
|
||||||
container_name: wealthysmart-frontend-dev
|
container_name: wealthysmart-frontend-dev
|
||||||
ports:
|
ports:
|
||||||
- "5175:5173"
|
- "5175:3000"
|
||||||
volumes:
|
environment:
|
||||||
- ./frontend:/app
|
NODE_ENV: development
|
||||||
- /app/node_modules
|
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- path: ./frontend/src
|
||||||
|
action: sync
|
||||||
|
target: /app/src
|
||||||
|
- path: ./frontend/public
|
||||||
|
action: sync
|
||||||
|
target: /app/public
|
||||||
|
- path: ./frontend/server.ts
|
||||||
|
action: sync
|
||||||
|
target: /app/server.ts
|
||||||
|
- path: ./frontend/vite.config.ts
|
||||||
|
action: sync+restart
|
||||||
|
target: /app/vite.config.ts
|
||||||
|
- path: ./frontend/tsconfig.json
|
||||||
|
action: sync+restart
|
||||||
|
target: /app/tsconfig.json
|
||||||
|
- path: ./frontend/package.json
|
||||||
|
action: rebuild
|
||||||
|
- path: ./frontend/pnpm-lock.yaml
|
||||||
|
action: rebuild
|
||||||
|
- path: ./frontend/Dockerfile
|
||||||
|
action: rebuild
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
53
docs/a2ui-theming-findings.md
Normal file
53
docs/a2ui-theming-findings.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# A2UI Theming Findings
|
||||||
|
|
||||||
|
Captured 2026-04-28 while researching how to beautify the table the agent renders via `render_a2ui` without overriding component internals.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
- A2UI v0.9 docs prescribe theming through a small set of **documented top-level CSS variables** declared on `:where(:root)` (zero specificity, so host CSS wins without `!important`).
|
||||||
|
- Default theme uses `light-dark()` so tokens self-toggle. Recommended dark-mode coupling: add the documented `a2ui-dark` class on `<html>` whenever Tailwind's `.dark` is on; that flips `color-scheme: dark` and resolves every `light-dark()` to its dark branch.
|
||||||
|
- **In our app, this approach has near-zero visible effect.** We render through `@copilotkit/a2ui-renderer` (React variant), not `@a2ui/lit`. Inspecting the compiled React renderer (`node_modules/.pnpm/@copilotkit+a2ui-renderer@1.56.4/.../catalog/basic/components/*.mjs`), every component uses **hardcoded inline styles**; the only CSS variable consumed across the whole basic catalog is `--a2ui-primary-color`.
|
||||||
|
- This matches open issues `google/A2UI#977` and `#1285` ("React renderer parity is incomplete").
|
||||||
|
|
||||||
|
## Documented token surface (Lit / Angular)
|
||||||
|
|
||||||
|
From `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`:
|
||||||
|
|
||||||
|
- Color palette: `--a2ui-color-background`, `--a2ui-color-on-background`, `--a2ui-color-surface`, `--a2ui-color-on-surface`, `--a2ui-color-primary`, `--a2ui-color-primary-light/-dark/-hover`, `--a2ui-color-on-primary`, `--a2ui-color-secondary`, `--a2ui-color-secondary-light/-dark/-hover`, `--a2ui-color-on-secondary`, `--a2ui-color-input`, `--a2ui-color-on-input`, `--a2ui-color-border`.
|
||||||
|
- Borders: `--a2ui-border`, `--a2ui-border-width`, `--a2ui-border-radius`.
|
||||||
|
- Typography: `--a2ui-font-family-title`, `--a2ui-font-family-monospace`, `--a2ui-font-size`, `--a2ui-font-scale`, `--a2ui-font-size-xs..2xl`, `--a2ui-line-height-headings`, `--a2ui-line-height-body`.
|
||||||
|
- Spacing: `--a2ui-grid-base`, `--a2ui-spacing-xs..xl`.
|
||||||
|
|
||||||
|
Per-component tokens like `--a2ui-card-background` exist but the renderer README and the official theming guide explicitly frame them as catalog-internal, not stable spec. Avoid relying on them.
|
||||||
|
|
||||||
|
## Dark-mode coupling
|
||||||
|
|
||||||
|
A2UI default uses `light-dark(...)` and supports forced modes via `a2ui-light` / `a2ui-dark` classes (see the `:where(.a2ui-dark)` block in `default.ts`). Best practice for a host that uses Tailwind `.dark` on `<html>`: toggle the matching `a2ui-dark` class in lockstep — one effect-driven `useEffect`, or add it directly to the root if the app is dark-only. No need to redeclare every token under `.dark`; the default rules already pick up the right branch through `light-dark()`.
|
||||||
|
|
||||||
|
## Footguns in v0.9
|
||||||
|
|
||||||
|
- The agent-supplied `theme` payload (e.g. `createSurface.theme.primaryColor`) is **not yet wired** in the basic catalog. Tracked in `google/A2UI#979` (Lit) and `#977` (React). Don't expect agent-side theming to recolor anything in v0.9; only the host CSS does.
|
||||||
|
- `hintedStyles` (h1–h6 mappings) require all keys when overridden — see `google/A2UI#602`.
|
||||||
|
- Tracking issues for the broader theming surface: `google/A2UI#1083`, `#1118`.
|
||||||
|
|
||||||
|
## Why this won't move the table in our app
|
||||||
|
|
||||||
|
`@copilotkit/a2ui-renderer@1.56.4` (React) is what `<CopilotKit a2ui={{}}>` plugs in. Its components apply colors and borders as inline `style` props (`backgroundColor: "#fff"`, `border: "1px solid #ccc"`, etc.). Inline styles can only be overridden with `!important` in author CSS, which contradicts the "don't take its freedom" goal. So the documented `--a2ui-*` token contract is currently a **Lit-only surface** for this catalog.
|
||||||
|
|
||||||
|
## Practical options for this codebase
|
||||||
|
|
||||||
|
1. **Quick win**: set `--a2ui-primary-color: var(--primary)` on `:root`. Real but small effect (primary buttons, links).
|
||||||
|
2. **Prompt-side polish**: instruct the agent to compose richer A2UI structures — wrap the table in a `Card`, use `usageHint: "h3"` for the section title, insert a `Divider`, group columns with `Row` + `Column` weights — so the renderer's existing inline styles produce a cleaner result. No code change.
|
||||||
|
3. **Switch renderer**: replace `@copilotkit/a2ui-renderer` with `@a2ui/lit` (or wait for `@a2ui/react` parity). Then every documented `--a2ui-*` token actually applies. Non-trivial migration.
|
||||||
|
4. **Custom catalog overrides**: register replacement React components for `Row`/`List`/`Card` via `createA2UICatalog`; full control, most invasive, accepts ownership of those components forever.
|
||||||
|
|
||||||
|
## Source links
|
||||||
|
|
||||||
|
- `tech_docs/A2UI/docs/guides/theming.md`
|
||||||
|
- `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`
|
||||||
|
- `tech_docs/A2UI/renderers/lit/README.md` §"CSS-based Basic Catalog Theming"
|
||||||
|
- `https://github.com/google/A2UI/issues/977` (React renderer theming gap)
|
||||||
|
- `https://github.com/google/A2UI/issues/1285` (React renderer parity)
|
||||||
|
- `https://github.com/google/A2UI/issues/1118` (theming surface roadmap)
|
||||||
|
- `https://github.com/google/A2UI/issues/979` (Lit `theme` payload not wired)
|
||||||
|
- `https://github.com/google/A2UI/pull/1079` (v0.9 CSS-variable rollout)
|
||||||
15
frontend/.dockerignore
Normal file
15
frontend/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
.env*
|
||||||
|
.vercel
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -1,7 +1,39 @@
|
|||||||
FROM node:20-slim
|
# syntax=docker/dockerfile:1.7
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN corepack enable && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Dev: Vite HMR on port 3000 + Hono CK server on port 3001
|
||||||
|
FROM node:22-alpine AS dev
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
CMD ["pnpm", "run", "dev", "--", "--host"]
|
ENV NODE_ENV=development
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["sh", "-c", "corepack enable && pnpm dev"]
|
||||||
|
|
||||||
|
# Build Vite SPA
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# Cap node heap so a build on a small VPS can't OOM-kill neighbours.
|
||||||
|
ENV NODE_OPTIONS=--max-old-space-size=1536
|
||||||
|
RUN corepack enable && pnpm build
|
||||||
|
|
||||||
|
# Production: Hono serves dist/ + /api/copilotkit on port 3000
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY server.ts package.json ./
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
CMD ["./node_modules/.bin/tsx", "server.ts"]
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Stage 1: Build
|
|
||||||
FROM node:20-slim AS build
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
# Stage 2: Serve
|
|
||||||
FROM nginx:alpine
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
EXPOSE 80
|
|
||||||
@@ -12,8 +12,6 @@
|
|||||||
<meta name="description" content="WealthySmart — Smart personal finance management" />
|
<meta name="description" content="WealthySmart — Smart personal finance management" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
||||||
<title>WealthySmart</title>
|
<title>WealthySmart</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# SPA fallback
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy API to backend (same docker network)
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://wealthysmart-backend-prod:8000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 120s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# No cache for service worker
|
|
||||||
location /sw.js {
|
|
||||||
add_header Cache-Control "no-cache";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache immutable assets
|
|
||||||
location /assets/ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "wealthysmart-frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "tsx server.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@ag-ui/client": "0.0.52",
|
||||||
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"@copilotkit/react-core": "1.56.4",
|
||||||
|
"@copilotkit/react-ui": "1.56.4",
|
||||||
|
"@copilotkit/runtime": "1.56.4",
|
||||||
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
||||||
"@fontsource-variable/noto-sans": "^5.2.10",
|
"@fontsource-variable/noto-sans": "^5.2.10",
|
||||||
|
"@hono/node-server": "^1.14.4",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"concurrently": "^9.1.2",
|
||||||
"react": "^19.2.0",
|
"hono": "^4.12.15",
|
||||||
"react-dom": "^19.2.0",
|
"lucide-react": "^1.12.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react": "19.2.5",
|
||||||
"recharts": "^2.15.4",
|
"react-dom": "19.2.5",
|
||||||
"shadcn": "^4.1.0",
|
"react-router-dom": "^7.6.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tsx": "^4.19.4",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4",
|
||||||
"@types/react": "^19.2.8",
|
"@types/node": "^20",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react": "^19",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
"@types/react-dom": "^19",
|
||||||
"tailwindcss": "^4.1.18",
|
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||||
"typescript": "^5.9.3",
|
"tailwindcss": "^4",
|
||||||
"vite": "^7.2.4"
|
"typescript": "^5",
|
||||||
|
"vite": "^6.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8476
frontend/pnpm-lock.yaml
generated
8476
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +1,3 @@
|
|||||||
onlyBuiltDependencies: '["@swc/core", "esbuild"]'
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
||||||
<rect width="32" height="32" rx="6" fill="#10b981"/>
|
|
||||||
<text x="16" y="23" text-anchor="middle" font-size="20" font-weight="bold" fill="#0f172a" font-family="system-ui">W</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 248 B |
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "WealthySmart",
|
|
||||||
"short_name": "WealthySmart",
|
|
||||||
"description": "Smart personal finance management",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#0f172a",
|
|
||||||
"theme_color": "#0f172a",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,98 +1,4 @@
|
|||||||
const CACHE_NAME = 'wealthysmart-v1';
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
const STATIC_ASSETS = ['/', '/index.html'];
|
self.addEventListener("activate", async () => {
|
||||||
|
await self.registration.unregister();
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
|
||||||
);
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.keys().then((keys) =>
|
|
||||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const { request } = event;
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
// Network-first for API calls
|
|
||||||
if (url.pathname.startsWith('/api/')) {
|
|
||||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only handle http(s) requests — skip chrome-extension:// etc.
|
|
||||||
if (!url.protocol.startsWith('http')) return;
|
|
||||||
|
|
||||||
// Cache-first for static assets
|
|
||||||
if (url.pathname.startsWith('/assets/')) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
|
|
||||||
const clone = res.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
|
||||||
return res;
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network-first for navigation, fallback to cached index.html
|
|
||||||
if (request.mode === 'navigate') {
|
|
||||||
event.respondWith(
|
|
||||||
fetch(request).catch(() => caches.match('/index.html'))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: network with cache fallback
|
|
||||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Push Notifications ---
|
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
|
||||||
if (!event.data) return;
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = event.data.json();
|
|
||||||
} catch {
|
|
||||||
// Fallback for plain-text pushes (e.g. browser test pushes)
|
|
||||||
data = { title: 'WealthySmart', body: event.data.text() };
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
body: data.body,
|
|
||||||
icon: '/icons/icon-192.png',
|
|
||||||
badge: '/icons/icon-192.png',
|
|
||||||
data: { url: data.url || '/' },
|
|
||||||
vibrate: [200, 100, 200],
|
|
||||||
tag: 'transaction',
|
|
||||||
renotify: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(data.title, options));
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
|
|
||||||
const url = event.notification.data?.url || '/';
|
|
||||||
event.waitUntil(
|
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
|
||||||
for (const client of windowClients) {
|
|
||||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
|
||||||
client.navigate(url);
|
|
||||||
return client.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clients.openWindow(url);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
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,30 +1,34 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { AuthProvider, useAuth } from './AuthContext';
|
import { CopilotKit } from "@copilotkit/react-core";
|
||||||
import { ThemeProvider } from './ThemeContext';
|
import { AuthProvider, useAuth } from "./AuthContext";
|
||||||
import { PrivacyProvider } from './PrivacyContext';
|
import { ThemeProvider } from "./contexts/theme-context";
|
||||||
import Layout from './components/Layout';
|
import { PrivacyProvider } from "./contexts/privacy-context";
|
||||||
import Login from './pages/Login';
|
import Layout from "./components/Layout";
|
||||||
import Dashboard from './pages/Dashboard';
|
import LoginPage from "./pages/Login";
|
||||||
import Budget from './pages/Budget';
|
import Asistente from "./pages/Asistente";
|
||||||
import Analytics from './pages/Analytics';
|
import Analytics from "./pages/Analytics";
|
||||||
import Salarios from './pages/Salarios';
|
import Budget from "./pages/Budget";
|
||||||
import Pensions from './pages/Pensions';
|
import Salarios from "./pages/Salarios";
|
||||||
import Proyecciones from './pages/Proyecciones';
|
import Pensions from "./pages/Pensions";
|
||||||
import ServiciosMunicipales from './pages/ServiciosMunicipales';
|
import Proyecciones from "./pages/Proyecciones";
|
||||||
|
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
if (isLoading) return null;
|
||||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@@ -33,14 +37,14 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route index element={<Navigate to="/asistente" replace />} />
|
||||||
|
<Route path="/asistente" element={<Asistente />} />
|
||||||
<Route path="/budget" element={<Budget />} />
|
<Route path="/budget" element={<Budget />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/proyecciones" element={<Proyecciones />} />
|
<Route path="/proyecciones" element={<Proyecciones />} />
|
||||||
<Route path="/salarios" element={<Salarios />} />
|
<Route path="/salarios" element={<Salarios />} />
|
||||||
<Route path="/pensions" element={<Pensions />} />
|
<Route path="/pensions" element={<Pensions />} />
|
||||||
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||||
{/* Redirect old routes */}
|
|
||||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
@@ -54,7 +58,9 @@ export default function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<PrivacyProvider>
|
<PrivacyProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
|
||||||
|
<AppRoutes />
|
||||||
|
</CopilotKit>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</PrivacyProvider>
|
</PrivacyProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,33 +1,40 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
|
||||||
|
import { logout as apiLogout } from "@/lib/api";
|
||||||
|
|
||||||
interface AuthCtx {
|
interface AuthCtx {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
logout: () => void;
|
isLoading: boolean;
|
||||||
|
logout: () => Promise<void>;
|
||||||
setAuthenticated: (v: boolean) => void;
|
setAuthenticated: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthCtx>({
|
const AuthContext = createContext<AuthCtx>({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
logout: () => {},
|
isLoading: true,
|
||||||
|
logout: async () => {},
|
||||||
setAuthenticated: () => {},
|
setAuthenticated: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token'));
|
const [isAuthenticated, setAuthenticated] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setAuthenticated(!!localStorage.getItem('token'));
|
// Probe auth state by hitting a protected endpoint.
|
||||||
window.addEventListener('storage', check);
|
// If the ws_token cookie is valid, the server returns 200; else 401.
|
||||||
return () => window.removeEventListener('storage', check);
|
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||||
|
.then((r) => setAuthenticated(r.ok))
|
||||||
|
.catch(() => setAuthenticated(false))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
localStorage.removeItem('token');
|
await apiLogout();
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
|
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
const PrivacyContext = createContext<{
|
|
||||||
privacyMode: boolean;
|
|
||||||
togglePrivacy: () => void;
|
|
||||||
}>({ privacyMode: false, togglePrivacy: () => {} });
|
|
||||||
|
|
||||||
export function PrivacyProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [privacyMode, setPrivacyMode] = useState<boolean>(() => {
|
|
||||||
return localStorage.getItem('privacyMode') === 'true';
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.classList.toggle('privacy', privacyMode);
|
|
||||||
localStorage.setItem('privacyMode', String(privacyMode));
|
|
||||||
}, [privacyMode]);
|
|
||||||
|
|
||||||
const togglePrivacy = () => setPrivacyMode((p) => !p);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PrivacyContext.Provider value={{ privacyMode, togglePrivacy }}>
|
|
||||||
{children}
|
|
||||||
</PrivacyContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePrivacy = () => useContext(PrivacyContext);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
|
||||||
|
|
||||||
const ThemeContext = createContext<{
|
|
||||||
theme: Theme;
|
|
||||||
toggleTheme: () => void;
|
|
||||||
}>({ theme: 'dark', toggleTheme: () => {} });
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
|
||||||
const saved = localStorage.getItem('theme') as Theme;
|
|
||||||
if (saved) return saved;
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTheme = () => useContext(ThemeContext);
|
|
||||||
39
frontend/src/components/AgentHomeClient.tsx
Normal file
39
frontend/src/components/AgentHomeClient.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CopilotChat } from "@copilotkit/react-ui";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
const SUGGESTIONS = [
|
||||||
|
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
|
||||||
|
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
|
||||||
|
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
|
||||||
|
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AgentHomeClient() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-105px)]">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight font-heading flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-primary" />
|
||||||
|
Asistente
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pregúntale a WealthySmart sobre tus finanzas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
||||||
|
<CopilotChat
|
||||||
|
className="h-full"
|
||||||
|
labels={{
|
||||||
|
title: "WealthySmart",
|
||||||
|
initial: "¿Qué quieres saber sobre tus finanzas?",
|
||||||
|
placeholder: "Escribe tu pregunta…",
|
||||||
|
}}
|
||||||
|
suggestions={SUGGESTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Calendar } from 'lucide-react';
|
import { Calendar } from 'lucide-react';
|
||||||
import api from '../api';
|
import api from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Settings } from 'lucide-react';
|
|
||||||
import type { SectionSettings } from '../api';
|
|
||||||
import { formatAmount } from '@/lib/format';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
AccordionContent,
|
|
||||||
} from '@/components/ui/accordion';
|
|
||||||
import { getColorClasses } from '@/lib/colors';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
sectionId: string;
|
|
||||||
settings: SectionSettings;
|
|
||||||
total?: number;
|
|
||||||
totalCurrency?: string;
|
|
||||||
onToggleExpanded: (expanded: boolean) => void;
|
|
||||||
onOpenConfig: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardSection({
|
|
||||||
sectionId,
|
|
||||||
settings,
|
|
||||||
total,
|
|
||||||
totalCurrency,
|
|
||||||
onToggleExpanded,
|
|
||||||
onOpenConfig,
|
|
||||||
children,
|
|
||||||
}: Props) {
|
|
||||||
const colors = getColorClasses(settings.color);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={cn('relative overflow-hidden border-l-4', colors.borderLeft)}>
|
|
||||||
{/* Settings icon — outside accordion trigger to avoid button-in-button */}
|
|
||||||
<button
|
|
||||||
onClick={onOpenConfig}
|
|
||||||
className="absolute top-2.5 right-3 z-10 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors cursor-pointer"
|
|
||||||
title="Section settings"
|
|
||||||
aria-label="Section settings"
|
|
||||||
>
|
|
||||||
<Settings className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Accordion
|
|
||||||
value={settings.expanded ? [sectionId] : []}
|
|
||||||
onValueChange={(value: string[]) => onToggleExpanded(value.includes(sectionId))}
|
|
||||||
>
|
|
||||||
<AccordionItem value={sectionId} className="border-none">
|
|
||||||
<AccordionTrigger
|
|
||||||
className="px-4 py-3 hover:no-underline cursor-pointer"
|
|
||||||
aria-label={`Expand ${settings.label}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between w-full pr-8">
|
|
||||||
<span className="text-sm font-semibold text-foreground">
|
|
||||||
{settings.label}
|
|
||||||
</span>
|
|
||||||
{total != null && totalCurrency && (
|
|
||||||
<span data-sensitive className="text-sm font-bold font-mono text-foreground">
|
|
||||||
{formatAmount(total, totalCurrency)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent>
|
|
||||||
<div className="divide-y divide-border mx-4 mb-4 rounded-lg overflow-hidden">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
Sparkles,
|
||||||
Calculator,
|
Calculator,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Landmark,
|
Landmark,
|
||||||
@@ -15,24 +16,20 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from 'react';
|
import { useTheme } from "@/contexts/theme-context";
|
||||||
import { useAuth } from '../AuthContext';
|
import { usePrivacy } from "@/contexts/privacy-context";
|
||||||
import { useTheme } from '../ThemeContext';
|
import { useAuth } from "@/AuthContext";
|
||||||
import { usePrivacy } from '../PrivacyContext';
|
import { Button } from "@/components/ui/button";
|
||||||
import { subscribeToPush } from '../pushNotifications';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
} from '@/components/ui/sheet';
|
} from "@/components/ui/sheet";
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ─── Navigation Structure ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface NavSection {
|
interface NavSection {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -41,32 +38,32 @@ interface NavSection {
|
|||||||
|
|
||||||
const navSections: NavSection[] = [
|
const navSections: NavSection[] = [
|
||||||
{
|
{
|
||||||
label: 'General',
|
label: "General",
|
||||||
|
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Finanzas",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
||||||
|
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
||||||
|
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
||||||
|
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
||||||
|
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Finanzas',
|
label: "Servicios",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
|
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
|
||||||
{ 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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Shared Nav Renderer ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const isActive = (to: string) =>
|
||||||
|
pathname === to || pathname.startsWith(`${to}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex flex-col gap-0.5 px-3">
|
<nav className="flex flex-col gap-0.5 px-3">
|
||||||
{navSections.map((section) => (
|
{navSections.map((section) => (
|
||||||
@@ -75,23 +72,20 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
{section.label}
|
{section.label}
|
||||||
</p>
|
</p>
|
||||||
{section.items.map(({ to, icon: Icon, label }) => (
|
{section.items.map(({ to, icon: Icon, label }) => (
|
||||||
<NavLink
|
<Link
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
end={to === '/'}
|
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className={({ isActive }) =>
|
className={cn(
|
||||||
cn(
|
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
isActive(to)
|
||||||
isActive
|
? "bg-primary/10 text-primary"
|
||||||
? 'bg-primary/10 text-primary'
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -99,27 +93,20 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Layout ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { logout } = useAuth();
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { privacyMode, togglePrivacy } = usePrivacy();
|
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||||
|
const { logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleLogout = async () => {
|
||||||
subscribeToPush();
|
await logout();
|
||||||
}, []);
|
navigate("/login", { replace: true });
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout();
|
|
||||||
navigate('/login');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
{/* ── Top bar ───────────────────────────────────────────────────── */}
|
|
||||||
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
|
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
|
||||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -136,7 +123,7 @@ export default function Layout() {
|
|||||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold tracking-tight hidden sm:inline font-heading">
|
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
|
||||||
Wealthy<span className="text-primary">Smart</span>
|
Wealthy<span className="text-primary">Smart</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +133,7 @@ export default function Layout() {
|
|||||||
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
|
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
|
||||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -163,7 +150,6 @@ export default function Layout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* ── Desktop sidebar ───────────────────────────────────────── */}
|
|
||||||
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
|
<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">
|
<div className="flex-1">
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
@@ -180,7 +166,6 @@ export default function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── Mobile nav sheet ──────────────────────────────────────── */}
|
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
<SheetContent side="left" className="p-0 w-64">
|
<SheetContent side="left" className="p-0 w-64">
|
||||||
<SheetHeader className="p-4">
|
<SheetHeader className="p-4">
|
||||||
@@ -188,7 +173,7 @@ export default function Layout() {
|
|||||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-heading">
|
<span style={{ fontFamily: "var(--font-heading)" }}>
|
||||||
Wealthy<span className="text-primary">Smart</span>
|
Wealthy<span className="text-primary">Smart</span>
|
||||||
</span>
|
</span>
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
@@ -202,7 +187,10 @@ export default function Layout() {
|
|||||||
<Separator className="mb-2" />
|
<Separator className="mb-2" />
|
||||||
<SheetClose render={<span />}>
|
<SheetClose render={<span />}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMobileOpen(false); handleLogout(); }}
|
onClick={() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
void handleLogout();
|
||||||
|
}}
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
@@ -214,7 +202,6 @@ export default function Layout() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
{/* ── Main content ──────────────────────────────────────────── */}
|
|
||||||
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
|
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||||
import api, { type ImportResult } from '../api';
|
import api, { type ImportResult } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||||
import { type PensionUploadResult, submitPensionManualEntries } from '../api';
|
import { type PensionUploadResult, submitPensionManualEntries } from '@/lib/api';
|
||||||
import { parsePensionPaste, type PensionParsedEntry } from '@/lib/parsePensionPaste';
|
import { parsePensionPaste, type PensionParsedEntry } from '@/lib/parsePensionPaste';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { SectionSettings } from '../api';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { COLOR_OPTIONS, getColorClasses } from '@/lib/colors';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
sectionId: string;
|
|
||||||
settings: SectionSettings;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSave: (sectionId: string, updated: Partial<SectionSettings>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorSwatch({ color }: { color: string }) {
|
|
||||||
const classes = getColorClasses(color);
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className={cn('w-3 h-3 rounded-full', classes.bg, classes.ring, 'ring-1')} />
|
|
||||||
{color}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SectionConfigDialog({ sectionId, settings, open, onOpenChange, onSave }: Props) {
|
|
||||||
const [label, setLabel] = useState(settings.label);
|
|
||||||
const [color, setColor] = useState(settings.color);
|
|
||||||
const [cardColor, setCardColor] = useState(settings.cardColor);
|
|
||||||
const [visible, setVisible] = useState(settings.visible);
|
|
||||||
const [order, setOrder] = useState(String(settings.order));
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onSave(sectionId, {
|
|
||||||
label,
|
|
||||||
color,
|
|
||||||
cardColor,
|
|
||||||
visible,
|
|
||||||
order: parseInt(order) || 0,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Configure Section</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Label</Label>
|
|
||||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Section Color</Label>
|
|
||||||
<Select value={color} onValueChange={setColor}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{COLOR_OPTIONS.map((c) => (
|
|
||||||
<SelectItem key={c} value={c}>
|
|
||||||
<ColorSwatch color={c} />
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Card Color</Label>
|
|
||||||
<Select value={cardColor} onValueChange={setCardColor}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{COLOR_OPTIONS.map((c) => (
|
|
||||||
<SelectItem key={c} value={c}>
|
|
||||||
<ColorSwatch color={c} />
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label htmlFor={`visible-${sectionId}`}>Visible</Label>
|
|
||||||
<input
|
|
||||||
id={`visible-${sectionId}`}
|
|
||||||
type="checkbox"
|
|
||||||
checked={visible}
|
|
||||||
onChange={(e) => setVisible(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-input accent-primary cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Order</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="10"
|
|
||||||
value={order}
|
|
||||||
onChange={(e) => setOrder(e.target.value)}
|
|
||||||
className="w-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave}>Save</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Banknote,
|
Banknote,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import api, { type Transaction } from '../api';
|
import api, { type Transaction } from '@/lib/api';
|
||||||
import TransactionModal from './TransactionModal';
|
import TransactionModal from './TransactionModal';
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import api, { type Category, type Transaction } from '../api';
|
import api, { type Category, type Transaction } from '@/lib/api';
|
||||||
import { formatLocalDatetime } from '@/lib/format';
|
import { formatLocalDatetime } from '@/lib/format';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PieChart, Pie, Cell } from 'recharts';
|
import { PieChart, Pie, Cell } from 'recharts';
|
||||||
|
|
||||||
import { type MonthlyDetail as MonthlyDetailType } from '@/api';
|
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
@@ -51,22 +52,122 @@ const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> =
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface MonthlyDetailProps {
|
interface MonthlyDetailProps {
|
||||||
detail: MonthlyDetailType;
|
detail: MonthlyDetailType | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onNavigateToTransactions?: () => void;
|
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) {
|
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
|
||||||
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
|
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
|
||||||
|
|
||||||
if (loading) {
|
if (loading || !detail) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Card key={i} className="animate-pulse">
|
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
|
||||||
<CardContent className="h-48" />
|
<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>
|
||||||
))}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type RecurringItemUpdate,
|
type RecurringItemUpdate,
|
||||||
type RecurringItemType,
|
type RecurringItemType,
|
||||||
type RecurringFrequency,
|
type RecurringFrequency,
|
||||||
} from '@/api';
|
} from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type RecurringItem,
|
type RecurringItem,
|
||||||
type RecurringItemCreate,
|
type RecurringItemCreate,
|
||||||
type RecurringItemUpdate,
|
type RecurringItemUpdate,
|
||||||
} from '@/api';
|
} from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
|
|
||||||
import { type MonthlyProjection } from '@/api';
|
import { type MonthlyProjection } from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|||||||
456
frontend/src/components/chat/ChatCards.tsx
Normal file
456
frontend/src/components/chat/ChatCards.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import { TrendingDown, TrendingUp, Wallet, ArrowRightLeft } from "lucide-react";
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtCRC(n: number | undefined | null) {
|
||||||
|
if (n == null) return "₡0";
|
||||||
|
return `₡${Math.round(n).toLocaleString("es-CR")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCurrency(amount: number | undefined | null, currency: string | undefined | null) {
|
||||||
|
if (amount == null) return "₡0";
|
||||||
|
if (currency === "USD") return `$${amount.toFixed(2)}`;
|
||||||
|
if (currency === "EUR") return `€${amount.toFixed(2)}`;
|
||||||
|
return fmtCRC(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source: string | undefined | null) {
|
||||||
|
if (!source) return "Otro";
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
CREDIT_CARD: "Tarjeta",
|
||||||
|
CASH: "Efectivo",
|
||||||
|
TRANSFER: "Transferencia",
|
||||||
|
SINPE: "SINPE",
|
||||||
|
OTHER: "Otro",
|
||||||
|
};
|
||||||
|
return map[source] ?? source.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spinner ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<div className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin inline-block" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SpendingSummaryCard ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SpendingBySource {
|
||||||
|
source: string;
|
||||||
|
total_crc: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpendingByCategory {
|
||||||
|
category: string;
|
||||||
|
amount_crc: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpendingSummaryArgs {
|
||||||
|
title?: string;
|
||||||
|
period?: string;
|
||||||
|
total_crc?: number;
|
||||||
|
by_source?: SpendingBySource[];
|
||||||
|
by_category?: SpendingByCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpendingSummaryCard({
|
||||||
|
args,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
args: SpendingSummaryArgs;
|
||||||
|
status: string;
|
||||||
|
}) {
|
||||||
|
const { title, period, total_crc, by_source = [], by_category = [] } = args;
|
||||||
|
const max = Math.max(...by_category.map((c) => c.amount_crc), 1);
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
"bg-primary",
|
||||||
|
"bg-chart-1",
|
||||||
|
"bg-chart-2",
|
||||||
|
"bg-chart-3",
|
||||||
|
"bg-chart-4",
|
||||||
|
"bg-chart-5",
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||||
|
{title ?? "Resumen de gastos"}
|
||||||
|
</p>
|
||||||
|
{period && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{period}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{fmtCRC(total_crc)}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">total gastado</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By source */}
|
||||||
|
{by_source.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{by_source.filter((s) => s?.source != null).map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.source}
|
||||||
|
className="rounded-lg bg-secondary/40 border border-border/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{sourceLabel(s.source)}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-sm tabular-nums">
|
||||||
|
{fmtCRC(s.total_crc)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{s.count} mov.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* By category */}
|
||||||
|
{by_category.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-2">
|
||||||
|
Por categoría
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{by_category.filter((c) => c?.category != null).slice(0, 7).map((c, i) => (
|
||||||
|
<div key={c.category}>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-card-foreground">{c.category}</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{fmtCRC(c.amount_crc)}
|
||||||
|
<span className="text-[10px] ml-1">({c.count})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${PALETTE[i % PALETTE.length]}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.round(((c.amount_crc ?? 0) / max) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "inProgress" && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Spinner /> Obteniendo datos…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TransactionListCard ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TransactionRow {
|
||||||
|
date: string;
|
||||||
|
merchant: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
category: string | null;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionListArgs {
|
||||||
|
title?: string;
|
||||||
|
transactions?: TransactionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_ICON: Record<string, React.ReactNode> = {
|
||||||
|
CREDIT_CARD: <Wallet className="w-3 h-3" />,
|
||||||
|
TRANSFER: <ArrowRightLeft className="w-3 h-3" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionListCard({
|
||||||
|
args,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
args: TransactionListArgs;
|
||||||
|
status: string;
|
||||||
|
}) {
|
||||||
|
const { title, transactions = [] } = args;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 my-2 shadow-sm">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-3">
|
||||||
|
{title ?? "Transacciones"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{transactions.length === 0 && status !== "inProgress" && (
|
||||||
|
<p className="text-sm text-muted-foreground">Sin transacciones.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{transactions.map((t, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between py-2 gap-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="shrink-0 w-6 h-6 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||||
|
{SOURCE_ICON[t.source] ?? <Wallet className="w-3 h-3" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{t.merchant}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t.date}
|
||||||
|
{t.category && ` · ${t.category}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold tabular-nums shrink-0 text-destructive">
|
||||||
|
{fmtCurrency(t.amount, t.currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "inProgress" && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-2">
|
||||||
|
<Spinner /> Cargando…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NetWorthCard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AccountRow {
|
||||||
|
bank: string;
|
||||||
|
label: string;
|
||||||
|
balance_crc: number;
|
||||||
|
account_type: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetWorthArgs {
|
||||||
|
total_assets_crc?: number;
|
||||||
|
total_liabilities_crc?: number;
|
||||||
|
net_worth_crc?: number;
|
||||||
|
accounts?: AccountRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetWorthCard({
|
||||||
|
args,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
args: NetWorthArgs;
|
||||||
|
status: string;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
total_assets_crc = 0,
|
||||||
|
total_liabilities_crc = 0,
|
||||||
|
net_worth_crc = 0,
|
||||||
|
accounts = [],
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
const isPositive = net_worth_crc >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
||||||
|
{/* Net worth headline */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||||
|
Patrimonio neto
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-4 h-4 text-destructive" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`text-2xl font-bold tabular-nums ${isPositive ? "text-green-500" : "text-destructive"}`}
|
||||||
|
>
|
||||||
|
{fmtCRC(net_worth_crc)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs space-y-1">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Activos </span>
|
||||||
|
<span className="font-semibold text-green-500">
|
||||||
|
{fmtCRC(total_assets_crc)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Pasivos </span>
|
||||||
|
<span className="font-semibold text-destructive">
|
||||||
|
{fmtCRC(total_liabilities_crc)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Asset bar */}
|
||||||
|
{total_assets_crc + total_liabilities_crc > 0 && (
|
||||||
|
<div className="h-2 bg-secondary rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500 transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.round(
|
||||||
|
(total_assets_crc /
|
||||||
|
(total_assets_crc + total_liabilities_crc)) *
|
||||||
|
100,
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="h-full bg-destructive flex-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Accounts */}
|
||||||
|
{accounts.length > 0 && (
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{accounts.map((a, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{a.label || a.bank}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{a.bank} · {a.account_type} · {a.currency}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-sm font-semibold tabular-nums ${a.balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
|
||||||
|
>
|
||||||
|
{fmtCRC(a.balance_crc)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "inProgress" && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Spinner /> Calculando…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BudgetMonthCard ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
"", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
|
||||||
|
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface BudgetMonthArgs {
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
projected_income_crc?: number;
|
||||||
|
projected_expenses_crc?: number;
|
||||||
|
actual_total_crc?: number;
|
||||||
|
net_balance_crc?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BudgetMonthCard({
|
||||||
|
args,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
args: BudgetMonthArgs;
|
||||||
|
status: string;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
projected_income_crc = 0,
|
||||||
|
projected_expenses_crc = 0,
|
||||||
|
actual_total_crc = 0,
|
||||||
|
net_balance_crc = 0,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
const usedPct =
|
||||||
|
projected_expenses_crc > 0
|
||||||
|
? Math.min(Math.round((actual_total_crc / projected_expenses_crc) * 100), 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const isOver = actual_total_crc > projected_expenses_crc;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||||
|
Presupuesto
|
||||||
|
</p>
|
||||||
|
{month != null && year != null && (
|
||||||
|
<p className="text-sm font-semibold mt-0.5">
|
||||||
|
{MONTH_NAMES[month]} {year}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-right text-2xl font-bold tabular-nums ${net_balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
|
||||||
|
>
|
||||||
|
{fmtCRC(net_balance_crc)}
|
||||||
|
<p className="text-[11px] font-normal text-muted-foreground">
|
||||||
|
balance neto
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Ingresos proyectados</span>
|
||||||
|
<span className="font-medium tabular-nums text-green-500">
|
||||||
|
{fmtCRC(projected_income_crc)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Gastos proyectados</span>
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{fmtCRC(projected_expenses_crc)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t border-border/50 pt-1.5">
|
||||||
|
<span className="text-muted-foreground">Gastado real</span>
|
||||||
|
<span
|
||||||
|
className={`font-semibold tabular-nums ${isOver ? "text-destructive" : ""}`}
|
||||||
|
>
|
||||||
|
{fmtCRC(actual_total_crc)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{projected_expenses_crc > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-[11px] text-muted-foreground mb-1">
|
||||||
|
<span>Ejecución presupuestaria</span>
|
||||||
|
<span className={isOver ? "text-destructive font-semibold" : ""}>
|
||||||
|
{usedPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${isOver ? "bg-destructive" : "bg-primary"}`}
|
||||||
|
style={{ width: `${usedPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "inProgress" && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Spinner /> Cargando…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
import { type Transaction } from '@/api';
|
import { type Transaction } from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
14
frontend/src/components/ui/skeleton.tsx
Normal file
14
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted/60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ function Tabs({
|
|||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:flex-row data-[orientation=vertical]:items-start",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -22,7 +22,7 @@ function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
const tabsListVariants = cva(
|
||||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -56,10 +56,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|||||||
<TabsPrimitive.Tab
|
<TabsPrimitive.Tab
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
31
frontend/src/contexts/privacy-context.tsx
Normal file
31
frontend/src/contexts/privacy-context.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
const PrivacyContext = createContext<{
|
||||||
|
privacyMode: boolean;
|
||||||
|
togglePrivacy: () => void;
|
||||||
|
}>({ privacyMode: false, togglePrivacy: () => {} });
|
||||||
|
|
||||||
|
export function PrivacyProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [privacyMode, setPrivacyMode] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPrivacyMode(localStorage.getItem("privacyMode") === "true");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("privacy", privacyMode);
|
||||||
|
localStorage.setItem("privacyMode", String(privacyMode));
|
||||||
|
}, [privacyMode]);
|
||||||
|
|
||||||
|
const togglePrivacy = () => setPrivacyMode((p) => !p);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrivacyContext.Provider value={{ privacyMode, togglePrivacy }}>
|
||||||
|
{children}
|
||||||
|
</PrivacyContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePrivacy = () => useContext(PrivacyContext);
|
||||||
40
frontend/src/contexts/theme-context.tsx
Normal file
40
frontend/src/contexts/theme-context.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
const ThemeContext = createContext<{
|
||||||
|
theme: Theme;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
}>({ theme: "dark", toggleTheme: () => {} });
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>("dark");
|
||||||
|
|
||||||
|
// Initialize once on mount (localStorage + prefers-color-scheme).
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("theme") as Theme | null;
|
||||||
|
const initial: Theme = saved
|
||||||
|
? saved
|
||||||
|
: window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
setTheme(initial);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
deleteRecurringItem as apiDeleteItem,
|
deleteRecurringItem as apiDeleteItem,
|
||||||
upsertBalanceOverride,
|
upsertBalanceOverride,
|
||||||
deleteBalanceOverride,
|
deleteBalanceOverride,
|
||||||
} from '@/api';
|
} from '@/lib/api';
|
||||||
|
|
||||||
export function useBudget(initialYear: number) {
|
export function useBudget(initialYear: number) {
|
||||||
const [year, setYear] = useState(initialYear);
|
const [year, setYear] = useState(initialYear);
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
getSettings,
|
|
||||||
updateSettings,
|
|
||||||
type UserSettingsData,
|
|
||||||
type SectionSettings,
|
|
||||||
} from '../api';
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettingsData = {
|
|
||||||
dashboard: {
|
|
||||||
sections: {
|
|
||||||
crc_accounts: { label: 'CRC Accounts', color: 'primary', cardColor: 'primary', visible: true, order: 0, expanded: false },
|
|
||||||
usd_accounts: { label: 'USD Accounts', color: 'chart-1', cardColor: 'chart-1', visible: true, order: 1, expanded: false },
|
|
||||||
pension: { label: 'Pension', color: 'chart-2', cardColor: 'chart-2', visible: true, order: 2, expanded: false },
|
|
||||||
savings: { label: 'Savings', color: 'chart-3', cardColor: 'chart-3', visible: true, order: 3, expanded: false },
|
|
||||||
liabilities: { label: 'Liabilities', color: 'destructive', cardColor: 'destructive', visible: true, order: 4, expanded: false },
|
|
||||||
crypto: { label: 'Crypto', color: 'chart-4', cardColor: 'chart-4', visible: true, order: 5, expanded: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSettings() {
|
|
||||||
const [settings, setSettings] = useState<UserSettingsData>(DEFAULT_SETTINGS);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getSettings()
|
|
||||||
.then((r) => setSettings(r.data.data))
|
|
||||||
.catch(() => {}) // use defaults on error
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const patchSection = useCallback(
|
|
||||||
async (sectionId: string, partial: Partial<SectionSettings>) => {
|
|
||||||
setSettings((prev) => {
|
|
||||||
const updated = {
|
|
||||||
...prev,
|
|
||||||
dashboard: {
|
|
||||||
...prev.dashboard,
|
|
||||||
sections: {
|
|
||||||
...prev.dashboard.sections,
|
|
||||||
[sectionId]: { ...prev.dashboard.sections[sectionId], ...partial },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// Fire-and-forget save
|
|
||||||
updateSettings(updated).catch(console.error);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { settings, loading, patchSection };
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@import "tw-animate-css";
|
|
||||||
@import "shadcn/tailwind.css";
|
|
||||||
@import "@fontsource-variable/noto-sans";
|
@import "@fontsource-variable/noto-sans";
|
||||||
@import "@fontsource-variable/ibm-plex-sans";
|
@import "@fontsource-variable/ibm-plex-sans";
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "@copilotkit/react-core/v2/styles.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--font-sans: "Noto Sans Variable", sans-serif;
|
||||||
|
--font-heading: "IBM Plex Sans Variable", sans-serif;
|
||||||
|
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@@ -39,6 +42,8 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
|
||||||
|
--copilot-kit-primary-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -73,11 +78,39 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
|
||||||
|
--copilot-kit-primary-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wire CopilotKit v2 CSS variables to WealthySmart's dark palette.
|
||||||
|
The v2 CSS sets --background/--muted/etc directly on [data-copilotkit]
|
||||||
|
elements (unlayered), overriding inherited values from .dark on <html>.
|
||||||
|
Using html.dark [data-copilotkit] (specificity 0,2,1) beats the v2's
|
||||||
|
own .dark [data-copilotkit] (specificity 0,2,0) and restores dark mode. */
|
||||||
|
html.dark [data-copilotkit] {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.437 0.078 188.216);
|
||||||
|
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: 'Noto Sans Variable', sans-serif;
|
--font-sans: var(--font-sans);
|
||||||
--font-heading: 'IBM Plex Sans Variable', sans-serif;
|
--font-heading: var(--font-heading);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -121,13 +154,11 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
font-family: var(--font-sans);
|
||||||
html {
|
}
|
||||||
@apply font-sans;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Privacy mode: blur sensitive financial data */
|
/* Privacy mode: blur sensitive financial data */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL || '/api/v1';
|
const BASE_URL = "/api/v1";
|
||||||
|
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
response: { status: number; data: unknown };
|
response: { status: number; data: unknown };
|
||||||
@@ -12,7 +12,12 @@ interface RequestConfig {
|
|||||||
params?: Record<string, string | number | boolean | undefined>;
|
params?: Record<string, string | number | boolean | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(method: string, url: string, body?: unknown, config?: RequestConfig): Promise<{ data: T }> {
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
body?: unknown,
|
||||||
|
config?: RequestConfig,
|
||||||
|
): Promise<{ data: T }> {
|
||||||
let fullUrl = `${BASE_URL}${url}`;
|
let fullUrl = `${BASE_URL}${url}`;
|
||||||
|
|
||||||
if (config?.params) {
|
if (config?.params) {
|
||||||
@@ -25,28 +30,32 @@ async function request<T>(method: string, url: string, body?: unknown, config?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
|
|
||||||
let fetchBody: BodyInit | undefined;
|
let fetchBody: BodyInit | undefined;
|
||||||
if (body instanceof FormData || body instanceof URLSearchParams) {
|
if (body instanceof FormData || body instanceof URLSearchParams) {
|
||||||
fetchBody = body;
|
fetchBody = body;
|
||||||
} else if (body !== undefined) {
|
} else if (body !== undefined) {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers["Content-Type"] = "application/json";
|
||||||
fetchBody = JSON.stringify(body);
|
fetchBody = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(fullUrl, { method, headers, body: fetchBody });
|
const res = await fetch(fullUrl, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: fetchBody,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
localStorage.removeItem('token');
|
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||||||
window.location.href = '/login';
|
if (typeof window !== "undefined") window.location.replace("/login");
|
||||||
throw new ApiError(401, null);
|
throw new ApiError(401, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let data: unknown = null;
|
let data: unknown = null;
|
||||||
try { data = await res.json(); } catch {}
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {}
|
||||||
throw new ApiError(res.status, data);
|
throw new ApiError(res.status, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,34 +66,48 @@ async function request<T>(method: string, url: string, body?: unknown, config?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
get<T = any>(url: string, config?: RequestConfig) {
|
get<T = unknown>(url: string, config?: RequestConfig) {
|
||||||
return request<T>('GET', url, undefined, config);
|
return request<T>("GET", url, undefined, config);
|
||||||
},
|
},
|
||||||
post<T = any>(url: string, body?: unknown, config?: RequestConfig) {
|
post<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
|
||||||
return request<T>('POST', url, body, config);
|
return request<T>("POST", url, body, config);
|
||||||
},
|
},
|
||||||
patch<T = any>(url: string, body?: unknown, config?: RequestConfig) {
|
patch<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
|
||||||
return request<T>('PATCH', url, body, config);
|
return request<T>("PATCH", url, body, config);
|
||||||
},
|
},
|
||||||
put<T = any>(url: string, body?: unknown, config?: RequestConfig) {
|
put<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
|
||||||
return request<T>('PUT', url, body, config);
|
return request<T>("PUT", url, body, config);
|
||||||
},
|
},
|
||||||
delete<T = any>(url: string, config?: RequestConfig) {
|
delete<T = unknown>(url: string, config?: RequestConfig) {
|
||||||
return request<T>('DELETE', url, undefined, config);
|
return request<T>("DELETE", url, undefined, config);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|
||||||
export async function login(username: string, password: string) {
|
export async function login(username: string, password: string) {
|
||||||
const form = new URLSearchParams();
|
const res = await fetch("/api/auth/login", {
|
||||||
form.append('username', username);
|
method: "POST",
|
||||||
form.append('password', password);
|
headers: { "Content-Type": "application/json" },
|
||||||
const { data } = await api.post('/auth/login', form);
|
body: JSON.stringify({ username, password }),
|
||||||
localStorage.setItem('token', data.access_token);
|
credentials: "same-origin",
|
||||||
return data;
|
});
|
||||||
|
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 {
|
export interface Account {
|
||||||
id: number;
|
id: number;
|
||||||
bank: string;
|
bank: string;
|
||||||
@@ -109,34 +132,6 @@ export interface ImportResult {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- User Settings ---
|
|
||||||
|
|
||||||
export interface SectionSettings {
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
cardColor: string;
|
|
||||||
visible: boolean;
|
|
||||||
order: number;
|
|
||||||
expanded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardSettings {
|
|
||||||
sections: Record<string, SectionSettings>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSettingsData {
|
|
||||||
dashboard: DashboardSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSettingsResponse {
|
|
||||||
key: string;
|
|
||||||
data: UserSettingsData;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getSettings = () => api.get<UserSettingsResponse>('/settings/');
|
|
||||||
export const updateSettings = (data: UserSettingsData) => api.patch<UserSettingsResponse>('/settings/', { data });
|
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: number;
|
id: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -160,8 +155,13 @@ export interface Transaction {
|
|||||||
|
|
||||||
// --- Budget / Recurring Items ---
|
// --- Budget / Recurring Items ---
|
||||||
|
|
||||||
export type RecurringItemType = 'INCOME' | 'EXPENSE';
|
export type RecurringItemType = "INCOME" | "EXPENSE";
|
||||||
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY';
|
export type RecurringFrequency =
|
||||||
|
| "WEEKLY"
|
||||||
|
| "MONTHLY"
|
||||||
|
| "QUARTERLY"
|
||||||
|
| "BIANNUAL"
|
||||||
|
| "YEARLY";
|
||||||
|
|
||||||
export interface RecurringItem {
|
export interface RecurringItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -295,19 +295,22 @@ export interface SavingsAccrualUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getSavingsAccruals = () =>
|
export const getSavingsAccruals = () =>
|
||||||
api.get<SavingsAccrual[]>('/savings-accrual/');
|
api.get<SavingsAccrual[]>("/savings-accrual/");
|
||||||
export const createSavingsAccrual = (data: SavingsAccrualCreate) =>
|
export const createSavingsAccrual = (data: SavingsAccrualCreate) =>
|
||||||
api.post<SavingsAccrual>('/savings-accrual/', data);
|
api.post<SavingsAccrual>("/savings-accrual/", data);
|
||||||
export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) =>
|
export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) =>
|
||||||
api.patch<SavingsAccrual>(`/savings-accrual/${id}`, data);
|
api.patch<SavingsAccrual>(`/savings-accrual/${id}`, data);
|
||||||
export const deleteSavingsAccrual = (id: number) =>
|
export const deleteSavingsAccrual = (id: number) =>
|
||||||
api.delete(`/savings-accrual/${id}`);
|
api.delete(`/savings-accrual/${id}`);
|
||||||
|
|
||||||
// Budget API functions
|
// --- Budget ---
|
||||||
export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) =>
|
|
||||||
api.get<RecurringItem[]>('/budget/recurring', { params });
|
export const getRecurringItems = (params?: {
|
||||||
|
item_type?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}) => api.get<RecurringItem[]>("/budget/recurring", { params });
|
||||||
export const createRecurringItem = (data: RecurringItemCreate) =>
|
export const createRecurringItem = (data: RecurringItemCreate) =>
|
||||||
api.post<RecurringItem>('/budget/recurring', data);
|
api.post<RecurringItem>("/budget/recurring", data);
|
||||||
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
|
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
|
||||||
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
|
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
|
||||||
export const deleteRecurringItem = (id: number) =>
|
export const deleteRecurringItem = (id: number) =>
|
||||||
@@ -316,7 +319,11 @@ export const getYearlyProjection = (year: number) =>
|
|||||||
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
||||||
export const getMonthlyDetail = (year: number, month: number) =>
|
export const getMonthlyDetail = (year: number, month: number) =>
|
||||||
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
|
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
|
||||||
export const upsertBalanceOverride = (year: number, month: number, override_balance: number) =>
|
export const upsertBalanceOverride = (
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
override_balance: number,
|
||||||
|
) =>
|
||||||
api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
|
api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
|
||||||
export const deleteBalanceOverride = (year: number, month: number) =>
|
export const deleteBalanceOverride = (year: number, month: number) =>
|
||||||
api.delete(`/budget/balance-override/${year}/${month}`);
|
api.delete(`/budget/balance-override/${year}/${month}`);
|
||||||
@@ -330,9 +337,9 @@ export interface SalariosSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
|
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
|
||||||
api.get<Transaction[]>('/salarios/', { params });
|
api.get<Transaction[]>("/salarios/", { params });
|
||||||
export const getSalariosSummary = () =>
|
export const getSalariosSummary = () =>
|
||||||
api.get<SalariosSummary>('/salarios/summary');
|
api.get<SalariosSummary>("/salarios/summary");
|
||||||
|
|
||||||
// --- Pensions ---
|
// --- Pensions ---
|
||||||
|
|
||||||
@@ -380,18 +387,16 @@ export interface PensionManualEntry {
|
|||||||
|
|
||||||
export const uploadPensionPDFs = (files: File[]) => {
|
export const uploadPensionPDFs = (files: File[]) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
files.forEach((f) => form.append('files', f));
|
files.forEach((f) => form.append("files", f));
|
||||||
return api.post<PensionUploadResult>('/pensions/upload', form);
|
return api.post<PensionUploadResult>("/pensions/upload", form);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPensionSnapshots = () =>
|
export const getPensionSnapshots = () =>
|
||||||
api.get<PensionSnapshot[]>('/pensions/snapshots');
|
api.get<PensionSnapshot[]>("/pensions/snapshots");
|
||||||
|
|
||||||
export const getPensionFundSummary = () =>
|
export const getPensionFundSummary = () =>
|
||||||
api.get<PensionSnapshot[]>('/pensions/fund-summary');
|
api.get<PensionSnapshot[]>("/pensions/fund-summary");
|
||||||
|
|
||||||
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
|
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
|
||||||
api.post<PensionUploadResult>('/pensions/manual', { entries });
|
api.post<PensionUploadResult>("/pensions/manual", { entries });
|
||||||
|
|
||||||
// --- Municipal Receipts ---
|
// --- Municipal Receipts ---
|
||||||
|
|
||||||
@@ -448,17 +453,18 @@ export interface MunicipalReceiptUploadResult {
|
|||||||
|
|
||||||
export const uploadMunicipalReceipt = (file: File) => {
|
export const uploadMunicipalReceipt = (file: File) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append("file", file);
|
||||||
return api.post<MunicipalReceiptUploadResult>('/municipal-receipts/upload', form);
|
return api.post<MunicipalReceiptUploadResult>(
|
||||||
|
"/municipal-receipts/upload",
|
||||||
|
form,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMunicipalReceipts = () =>
|
export const getMunicipalReceipts = () =>
|
||||||
api.get<MunicipalReceipt[]>('/municipal-receipts/');
|
api.get<MunicipalReceipt[]>("/municipal-receipts/");
|
||||||
|
|
||||||
export const getMunicipalReceiptDetail = (id: number) =>
|
export const getMunicipalReceiptDetail = (id: number) =>
|
||||||
api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`);
|
api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`);
|
||||||
|
|
||||||
export const getWaterConsumption = (months?: number) =>
|
export const getWaterConsumption = (months?: number) =>
|
||||||
api.get<WaterMeterReading[]>('/municipal-receipts/water-consumption', {
|
api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", {
|
||||||
params: months ? { months } : undefined,
|
params: months ? { months } : undefined,
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from "react-dom/client";
|
||||||
import App from './App';
|
import App from "./App";
|
||||||
import './index.css';
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.register('/sw.js');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { BarChart3 } from 'lucide-react';
|
import { BarChart3 } from 'lucide-react';
|
||||||
|
|
||||||
import api from '../api';
|
import api from '@/lib/api';
|
||||||
import BillingCycleSelector from '../components/BillingCycleSelector';
|
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
|
|||||||
80
frontend/src/pages/Asistente.tsx
Normal file
80
frontend/src/pages/Asistente.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { CopilotChat, useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||||
|
import { useCopilotAction } from "@copilotkit/react-core";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
||||||
|
|
||||||
|
const STATIC_SUGGESTIONS = {
|
||||||
|
available: "before-first-message" as const,
|
||||||
|
suggestions: [
|
||||||
|
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
|
||||||
|
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
|
||||||
|
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
|
||||||
|
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Asistente() {
|
||||||
|
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
||||||
|
|
||||||
|
useCopilotAction({
|
||||||
|
name: "render_spending_summary",
|
||||||
|
description:
|
||||||
|
"Render a visual spending summary card with source breakdown and category progress bars. " +
|
||||||
|
"Call this for any cycle summary, spending totals, or category breakdown.",
|
||||||
|
parameters: [
|
||||||
|
{ name: "title", type: "string", description: "Card title (e.g. 'Ciclo actual')" },
|
||||||
|
{ name: "period", type: "string", description: "Human-readable period (e.g. '18 mar → 18 abr 2026')" },
|
||||||
|
{ name: "total_crc", type: "number", description: "Total spend in CRC" },
|
||||||
|
{
|
||||||
|
name: "by_source",
|
||||||
|
type: "object[]",
|
||||||
|
description: "Breakdown by payment source",
|
||||||
|
attributes: [
|
||||||
|
{ name: "source", type: "string" },
|
||||||
|
{ name: "total_crc", type: "number" },
|
||||||
|
{ name: "count", type: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "by_category",
|
||||||
|
type: "object[]",
|
||||||
|
required: false,
|
||||||
|
description: "Top spending categories with CRC amounts",
|
||||||
|
attributes: [
|
||||||
|
{ name: "category", type: "string" },
|
||||||
|
{ name: "amount_crc", type: "number" },
|
||||||
|
{ name: "count", type: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
handler: async () => "ok",
|
||||||
|
render: (props) => (
|
||||||
|
<SpendingSummaryCard args={props.args as SpendingSummaryArgs} status={props.status} />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-105px)]">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
|
||||||
|
<Sparkles className="w-5 h-5 text-primary" />
|
||||||
|
Asistente
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pregúntale a WealthySmart sobre tus finanzas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
||||||
|
<CopilotChat
|
||||||
|
className="h-full"
|
||||||
|
labels={{
|
||||||
|
modalHeaderTitle: "WealthySmart",
|
||||||
|
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
|
||||||
|
chatInputPlaceholder: "Escribe tu pregunta…",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react';
|
||||||
|
|
||||||
import api, { type Transaction } from '@/api';
|
import api, { type Transaction } from '@/lib/api';
|
||||||
import { useBudget } from '@/hooks/useBudget';
|
import { useBudget } from '@/hooks/useBudget';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -154,13 +154,11 @@ export default function Budget() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="detail" className="space-y-6 mt-4">
|
<TabsContent value="detail" className="space-y-6 mt-4">
|
||||||
{monthDetail && (
|
<MonthlyDetail
|
||||||
<MonthlyDetail
|
detail={monthDetail}
|
||||||
detail={monthDetail}
|
loading={monthLoading || !monthDetail}
|
||||||
loading={monthLoading}
|
onNavigateToTransactions={handleNavigateToTransactions}
|
||||||
onNavigateToTransactions={handleNavigateToTransactions}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="transactions" className="space-y-3 mt-4">
|
<TabsContent value="transactions" className="space-y-3 mt-4">
|
||||||
|
|||||||
@@ -1,376 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
RefreshCw,
|
|
||||||
CreditCard,
|
|
||||||
Pencil,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
BellRing,
|
|
||||||
Landmark,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import api, { type Account, type Transaction } from '../api';
|
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
|
||||||
import { formatAmount, formatDate, formatLocalDatetime } from '@/lib/format';
|
|
||||||
import DashboardSection from '@/components/DashboardSection';
|
|
||||||
import SectionConfigDialog from '@/components/SectionConfigDialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
// --- Section definitions ---
|
|
||||||
|
|
||||||
interface SectionDef {
|
|
||||||
filterFn: (a: Account) => boolean;
|
|
||||||
totalCurrency: string; // empty string = no total
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECTION_DEFS: Record<string, SectionDef> = {
|
|
||||||
crc_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'CRC', totalCurrency: 'CRC' },
|
|
||||||
usd_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'USD', totalCurrency: 'USD' },
|
|
||||||
pension: { filterFn: (a) => a.account_type === 'PENSION', totalCurrency: 'CRC' },
|
|
||||||
savings: { filterFn: (a) => a.account_type === 'SAVINGS', totalCurrency: 'CRC' },
|
|
||||||
liabilities: { filterFn: (a) => a.account_type === 'LIABILITY', totalCurrency: '' },
|
|
||||||
crypto: { filterFn: (a) => a.account_type === 'CRYPTO', totalCurrency: '' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const BANK_ORDER = ['BAC', 'BCR', 'DAVIVIENDA'];
|
|
||||||
|
|
||||||
// --- AccountRow ---
|
|
||||||
|
|
||||||
interface AccountRowProps {
|
|
||||||
account: Account;
|
|
||||||
editingId: number | null;
|
|
||||||
editValue: string;
|
|
||||||
setEditValue: (v: string) => void;
|
|
||||||
startEditing: (a: Account) => void;
|
|
||||||
saveBalance: (id: number) => void;
|
|
||||||
cancelEditing: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccountRow({
|
|
||||||
account,
|
|
||||||
editingId,
|
|
||||||
editValue,
|
|
||||||
setEditValue,
|
|
||||||
startEditing,
|
|
||||||
saveBalance,
|
|
||||||
cancelEditing,
|
|
||||||
}: AccountRowProps) {
|
|
||||||
const isLiability = account.account_type === 'LIABILITY';
|
|
||||||
const isCrypto = account.account_type === 'CRYPTO';
|
|
||||||
const label = isCrypto ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank);
|
|
||||||
const isEditing = editingId === account.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/30 transition-colors group">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
|
||||||
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') saveBalance(account.id);
|
|
||||||
if (e.key === 'Escape') cancelEditing();
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
className="text-sm font-bold font-mono tracking-tight h-auto py-1 w-40"
|
|
||||||
/>
|
|
||||||
<Button variant="ghost" size="icon-xs" onClick={() => saveBalance(account.id)} title="Save" aria-label="Save balance">
|
|
||||||
<Check className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon-xs" onClick={cancelEditing} title="Cancel" aria-label="Cancel editing">
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span data-sensitive className={cn('text-lg font-bold font-mono tracking-tight', isLiability && 'text-destructive')}>
|
|
||||||
{formatAmount(account.balance, account.currency)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => startEditing(account)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
|
|
||||||
title="Edit balance"
|
|
||||||
aria-label="Edit balance"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
{isLiability && account.next_payment != null && (
|
|
||||||
<span data-sensitive className="text-xs font-mono text-destructive/60 ml-2">
|
|
||||||
Next: {formatAmount(account.next_payment, account.currency)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Dashboard ---
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
|
||||||
const [recent, setRecent] = useState<Transaction[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
|
||||||
const [editValue, setEditValue] = useState('');
|
|
||||||
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
|
|
||||||
const [configSection, setConfigSection] = useState<string | null>(null);
|
|
||||||
const [testingPush, setTestingPush] = useState(false);
|
|
||||||
|
|
||||||
const { settings, patchSection } = useSettings();
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [accRes, txRes] = await Promise.all([
|
|
||||||
api.get('/accounts/'),
|
|
||||||
api.get('/transactions/recent?limit=5'),
|
|
||||||
]);
|
|
||||||
setAccounts(accRes.data);
|
|
||||||
setRecent(txRes.data);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
api.get('/exchange-rate/').then((r) => setExchangeRate(r.data)).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, []);
|
|
||||||
|
|
||||||
const startEditing = (account: Account) => {
|
|
||||||
setEditingId(account.id);
|
|
||||||
setEditValue(String(account.balance));
|
|
||||||
};
|
|
||||||
const cancelEditing = () => { setEditingId(null); setEditValue(''); };
|
|
||||||
const saveBalance = async (accountId: number) => {
|
|
||||||
const parsed = parseFloat(editValue);
|
|
||||||
if (isNaN(parsed)) return cancelEditing();
|
|
||||||
try {
|
|
||||||
await api.patch(`/accounts/${accountId}`, { balance: parsed });
|
|
||||||
setEditingId(null);
|
|
||||||
setEditValue('');
|
|
||||||
fetchData();
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const rowProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing };
|
|
||||||
|
|
||||||
// Sort sections by order, filter by visible
|
|
||||||
const sortedSections = useMemo(() => {
|
|
||||||
const sections = settings.dashboard.sections;
|
|
||||||
return Object.entries(sections)
|
|
||||||
.filter(([, s]) => s.visible)
|
|
||||||
.sort(([, a], [, b]) => a.order - b.order);
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
// Net worth calculation
|
|
||||||
const netWorthBreakdown = useMemo(() => {
|
|
||||||
if (accounts.length === 0) return null;
|
|
||||||
let assets = 0;
|
|
||||||
let liabilities = 0;
|
|
||||||
for (const a of accounts) {
|
|
||||||
const isLiability = a.account_type === 'LIABILITY';
|
|
||||||
let crcValue = 0;
|
|
||||||
if (a.currency === 'USD') {
|
|
||||||
crcValue = Math.abs(a.balance) * (exchangeRate?.sell_rate ?? 0);
|
|
||||||
} else if (a.currency === 'CRC') {
|
|
||||||
crcValue = Math.abs(a.balance);
|
|
||||||
}
|
|
||||||
if (isLiability) {
|
|
||||||
liabilities += crcValue;
|
|
||||||
} else {
|
|
||||||
assets += crcValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { assets, liabilities, net: assets - liabilities };
|
|
||||||
}, [accounts, exchangeRate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold font-heading">Dashboard</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Financial overview</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
|
|
||||||
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Net Worth */}
|
|
||||||
{netWorthBreakdown != null && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between text-sm font-mono text-muted-foreground">
|
|
||||||
<span>Net <span data-sensitive className="text-foreground font-bold">{netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')}</span></span>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<span>Assets <span data-sensitive className="text-foreground">{formatAmount(netWorthBreakdown.assets, 'CRC')}</span></span>
|
|
||||||
<span>Liabilities <span data-sensitive className="text-foreground">{formatAmount(netWorthBreakdown.liabilities, 'CRC')}</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Account sections */}
|
|
||||||
{sortedSections.map(([sectionId, sectionSettings]) => {
|
|
||||||
const def = SECTION_DEFS[sectionId];
|
|
||||||
if (!def) return null;
|
|
||||||
let accts = accounts.filter(def.filterFn);
|
|
||||||
if (accts.length === 0) return null;
|
|
||||||
|
|
||||||
// Sort bank accounts by bank order
|
|
||||||
if (sectionId === 'crc_accounts' || sectionId === 'usd_accounts') {
|
|
||||||
accts = accts.sort((a, b) => BANK_ORDER.indexOf(a.bank) - BANK_ORDER.indexOf(b.bank));
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = accts.reduce((s, a) => s + a.balance, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardSection
|
|
||||||
key={sectionId}
|
|
||||||
sectionId={sectionId}
|
|
||||||
settings={sectionSettings}
|
|
||||||
total={def.totalCurrency ? total : undefined}
|
|
||||||
totalCurrency={def.totalCurrency || undefined}
|
|
||||||
onToggleExpanded={(expanded) => patchSection(sectionId, { expanded })}
|
|
||||||
onOpenConfig={() => setConfigSection(sectionId)}
|
|
||||||
>
|
|
||||||
{accts.map((a) => (
|
|
||||||
<AccountRow key={a.id} account={a} {...rowProps} />
|
|
||||||
))}
|
|
||||||
</DashboardSection>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Exchange rate */}
|
|
||||||
{exchangeRate && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">USD/CRC Exchange Rate</span>
|
|
||||||
<div className="flex items-baseline gap-3 mt-1">
|
|
||||||
<span data-sensitive className="text-lg font-bold font-mono">Buy: ₡{exchangeRate.buy_rate.toFixed(2)}</span>
|
|
||||||
<span data-sensitive className="text-lg font-bold font-mono text-muted-foreground">Sell: ₡{exchangeRate.sell_rate.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent transactions */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="border-b flex-row items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CreditCard className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<CardTitle className="text-sm">Recent Charges</CardTitle>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/transactions"
|
|
||||||
className="flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
View all
|
|
||||||
<ArrowRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{recent.length === 0 && !loading ? (
|
|
||||||
<div className="px-5 py-12 text-center text-muted-foreground text-sm">No transactions yet. Add your first one!</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{recent.map((tx) => (
|
|
||||||
<div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-muted/50 transition-colors">
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<div className={cn(
|
|
||||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
|
||||||
tx.transaction_type === 'COMPRA' ? 'bg-destructive/10 text-destructive' : 'bg-primary/10 text-primary'
|
|
||||||
)}>
|
|
||||||
{tx.transaction_type === 'DEPOSITO' ? <Landmark className="w-4 h-4" /> : tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(tx.date)}
|
|
||||||
{tx.category && <span className="ml-2 text-muted-foreground/60">{tx.category.name}</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span data-sensitive className={cn(
|
|
||||||
'font-mono text-sm font-medium shrink-0 ml-4',
|
|
||||||
tx.transaction_type !== 'COMPRA' && 'text-primary'
|
|
||||||
)}>
|
|
||||||
{tx.transaction_type === 'COMPRA' ? '-' : '+'}{formatAmount(tx.amount, tx.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Test push notification */}
|
|
||||||
<Card className="border-dashed border-yellow-500/50">
|
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Test Push Notification</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Creates a mock transaction to trigger a push notification</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={testingPush}
|
|
||||||
onClick={async () => {
|
|
||||||
setTestingPush(true);
|
|
||||||
try {
|
|
||||||
const merchants = ['Walmart', 'AutoMercado', 'Uber Eats', 'Amazon', 'PriceSmart'];
|
|
||||||
const amounts = [4500, 12350, 8900, 25000, 67800];
|
|
||||||
const i = Math.floor(Math.random() * merchants.length);
|
|
||||||
await api.post('/transactions/', {
|
|
||||||
merchant: merchants[i],
|
|
||||||
amount: amounts[i],
|
|
||||||
currency: 'CRC',
|
|
||||||
date: formatLocalDatetime(new Date()),
|
|
||||||
bank: 'BAC',
|
|
||||||
source: 'CREDIT_CARD',
|
|
||||||
transaction_type: 'COMPRA',
|
|
||||||
reference: `test-push-${Date.now()}`,
|
|
||||||
notes: '[TEST] Push notification test — safe to delete',
|
|
||||||
});
|
|
||||||
fetchData();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Test push failed:', e);
|
|
||||||
} finally {
|
|
||||||
setTestingPush(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BellRing className="w-4 h-4 mr-2" />
|
|
||||||
{testingPush ? 'Sending...' : 'Send test'}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Section config dialog */}
|
|
||||||
{configSection && settings.dashboard.sections[configSection] && (
|
|
||||||
<SectionConfigDialog
|
|
||||||
sectionId={configSection}
|
|
||||||
settings={settings.dashboard.sections[configSection]}
|
|
||||||
open={!!configSection}
|
|
||||||
onOpenChange={(open) => { if (!open) setConfigSection(null); }}
|
|
||||||
onSave={(id, partial) => patchSection(id, partial)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,31 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type FormEvent } from "react";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
|
import { Wallet, ArrowRight, AlertCircle } from "lucide-react";
|
||||||
|
import { login } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/AuthContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
import { login } from '../api';
|
export default function LoginPage() {
|
||||||
import { useAuth } from '../AuthContext';
|
const [username, setUsername] = useState("");
|
||||||
import { subscribeToPush } from '../pushNotifications';
|
const [password, setPassword] = useState("");
|
||||||
import { Button } from '@/components/ui/button';
|
const [error, setError] = useState("");
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setAuthenticated } = useAuth();
|
const { setAuthenticated } = useAuth();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
subscribeToPush();
|
navigate("/asistente", { replace: true });
|
||||||
navigate('/');
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Invalid credentials');
|
setError("Invalid credentials");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -41,7 +38,7 @@ export default function Login() {
|
|||||||
<div className="w-10 h-10 rounded-lg bg-primary flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-primary flex items-center justify-center">
|
||||||
<Wallet className="w-6 h-6 text-primary-foreground" strokeWidth={2.5} />
|
<Wallet className="w-6 h-6 text-primary-foreground" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold tracking-tight font-heading">
|
<span className="text-2xl font-bold tracking-tight" style={{ fontFamily: "var(--font-heading)" }}>
|
||||||
Wealthy<span className="text-primary">Smart</span>
|
Wealthy<span className="text-primary">Smart</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +81,7 @@ export default function Login() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" disabled={loading} className="w-full h-10">
|
<Button type="submit" disabled={loading} className="w-full h-10">
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
{!loading && <ArrowRight className="w-4 h-4" />}
|
{!loading && <ArrowRight className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
getPensionSnapshots,
|
getPensionSnapshots,
|
||||||
type PensionSnapshot,
|
type PensionSnapshot,
|
||||||
type PensionUploadResult,
|
type PensionUploadResult,
|
||||||
} from '@/api';
|
} from '@/lib/api';
|
||||||
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
|
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
|
||||||
import { ClipboardPaste } from 'lucide-react';
|
import { ClipboardPaste } from 'lucide-react';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react';
|
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react';
|
||||||
|
|
||||||
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '../api';
|
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
|
||||||
import { formatAmount, formatDate } from '@/lib/format';
|
import { formatAmount, formatDate } from '@/lib/format';
|
||||||
import { DataTable } from '@/components/ui/data-table';
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
type MunicipalReceipt,
|
type MunicipalReceipt,
|
||||||
type MunicipalReceiptUploadResult,
|
type MunicipalReceiptUploadResult,
|
||||||
type WaterMeterReading,
|
type WaterMeterReading,
|
||||||
} from '@/api';
|
} from '@/lib/api';
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { Plus, ClipboardPaste } from 'lucide-react';
|
|
||||||
|
|
||||||
import api, { type Transaction, type Category } from '../api';
|
|
||||||
import PasteImportModal from '../components/PasteImportModal';
|
|
||||||
import BillingCycleSelector from '../components/BillingCycleSelector';
|
|
||||||
import TransactionList from '../components/TransactionList';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
|
|
||||||
function formatAmount(amount: number, currency: string) {
|
|
||||||
const abs = Math.abs(amount);
|
|
||||||
if (currency === 'USD') {
|
|
||||||
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
}
|
|
||||||
return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Transactions() {
|
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
|
||||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
|
||||||
|
|
||||||
const fetchTransactions = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' };
|
|
||||||
if (search) params.search = search;
|
|
||||||
if (categoryFilter) params.category_id = categoryFilter;
|
|
||||||
if (cycle) {
|
|
||||||
params.cycle_year = String(cycle.year);
|
|
||||||
params.cycle_month = String(cycle.month);
|
|
||||||
}
|
|
||||||
const { data } = await api.get('/transactions/', { params });
|
|
||||||
setTransactions(data);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [search, categoryFilter, cycle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get('/categories/').then((r) => setCategories(r.data));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(fetchTransactions, 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [fetchTransactions]);
|
|
||||||
|
|
||||||
const totalCRC = transactions
|
|
||||||
.filter((tx) => tx.currency === 'CRC')
|
|
||||||
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
|
|
||||||
const totalUSD = transactions
|
|
||||||
.filter((tx) => tx.currency === 'USD')
|
|
||||||
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold font-heading">Credit Card Transactions</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{transactions.length} transactions
|
|
||||||
{totalCRC !== 0 && (
|
|
||||||
<> · <span data-sensitive className="font-mono text-foreground">{formatAmount(totalCRC, 'CRC')}</span></>
|
|
||||||
)}
|
|
||||||
{totalUSD !== 0 && (
|
|
||||||
<> · <span data-sensitive className="font-mono text-foreground">{formatAmount(totalUSD, 'USD')}</span></>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
|
||||||
<ClipboardPaste className="w-4 h-4" />
|
|
||||||
Import
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Billing cycle */}
|
|
||||||
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
|
||||||
|
|
||||||
{/* Category filter */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Select
|
|
||||||
value={categoryFilter || 'all'}
|
|
||||||
onValueChange={(v) => setCategoryFilter(v === 'all' ? '' : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[180px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Categories</SelectItem>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={String(c.id)}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TransactionList
|
|
||||||
transactions={transactions}
|
|
||||||
loading={loading}
|
|
||||||
source="CREDIT_CARD"
|
|
||||||
search={search}
|
|
||||||
onSearchChange={setSearch}
|
|
||||||
onRefresh={fetchTransactions}
|
|
||||||
showCategory
|
|
||||||
/>
|
|
||||||
|
|
||||||
{importOpen && (
|
|
||||||
<PasteImportModal
|
|
||||||
onClose={() => setImportOpen(false)}
|
|
||||||
onImported={fetchTransactions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { ArrowLeftRight } from 'lucide-react';
|
|
||||||
|
|
||||||
import api, { type Transaction } from '../api';
|
|
||||||
import TransactionList from '../components/TransactionList';
|
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
||||||
|
|
||||||
type SourceTab = 'CASH' | 'TRANSFER';
|
|
||||||
|
|
||||||
export default function Transfers() {
|
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [sourceTab, setSourceTab] = useState<SourceTab>('CASH');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const fetchTransactions = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params: Record<string, string> = { source: sourceTab, limit: '200' };
|
|
||||||
if (search) params.search = search;
|
|
||||||
const { data } = await api.get('/transactions/', { params });
|
|
||||||
setTransactions(data);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [search, sourceTab]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(fetchTransactions, 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [fetchTransactions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold font-heading">Cash & Transfers</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Track non-credit-card expenses
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs value={sourceTab} onValueChange={(v) => setSourceTab(v as SourceTab)}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="CASH">Cash</TabsTrigger>
|
|
||||||
<TabsTrigger value="TRANSFER">Transfers</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value={sourceTab} className="mt-5 space-y-5">
|
|
||||||
<TransactionList
|
|
||||||
transactions={transactions}
|
|
||||||
loading={loading}
|
|
||||||
source={sourceTab}
|
|
||||||
search={search}
|
|
||||||
onSearchChange={setSearch}
|
|
||||||
onRefresh={fetchTransactions}
|
|
||||||
showCategory={false}
|
|
||||||
addLabel={sourceTab === 'CASH' ? 'Add Cash Expense' : 'Add Transfer'}
|
|
||||||
emptyIcon={<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
|
|
||||||
emptyMessage={`No ${sourceTab.toLowerCase()} transactions yet`}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -7,17 +7,15 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "server.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react-swc';
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
// CopilotKit runtime (Hono server, dev only)
|
||||||
target: 'http://localhost:8001',
|
"/api/copilotkit": {
|
||||||
|
target: "http://localhost:3001",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
// All other API calls → Python backend
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8001",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ── Configuration ────────────────────────────────────────────────
|
# ── Configuration ────────────────────────────────────────────────
|
||||||
PROD_SSH_ALIAS="production"
|
PROD_SSH_ALIAS="old-vps"
|
||||||
PROD_CONTAINER="wealthysmart-db-prod"
|
PROD_CONTAINER="wealthysmart-db-prod"
|
||||||
PROD_DB="wealthysmart"
|
PROD_DB="wealthysmart"
|
||||||
PROD_USER="wealthy_user"
|
PROD_USER="wealthy_user"
|
||||||
|
|||||||
Reference in New Issue
Block a user