mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 14:48:48 +02:00
Compare commits
62 Commits
248417dbab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d0547743f | ||
|
|
8ceed0ab2e | ||
|
|
ca3115e99c | ||
|
|
3518ff58c4 | ||
|
|
352b77ea07 | ||
|
|
fbc0816be6 | ||
|
|
301a0b687a | ||
|
|
b57a899634 | ||
|
|
2d6a3f83eb | ||
|
|
00e841ad3f | ||
|
|
c527b3d6d0 | ||
|
|
1e6ebab2b2 | ||
|
|
17a0c63abe | ||
|
|
47cb1826a8 | ||
|
|
8595e74566 | ||
|
|
0d0d60600d | ||
|
|
700e1edbb4 | ||
|
|
15fa18411a | ||
|
|
3f4df6f16b | ||
|
|
0f34a64e51 | ||
|
|
7b7c741ba2 | ||
|
|
8f51e33fe0 | ||
|
|
965e30a2d2 | ||
|
|
334d41bd9a | ||
|
|
eb400ab1e9 | ||
|
|
bbcfaa7808 | ||
|
|
6bce5539f7 | ||
|
|
0e03284c95 | ||
|
|
4687fa9669 | ||
|
|
bbae121fec | ||
|
|
033a920746 | ||
|
|
0d24c7f9be | ||
|
|
00f059ee91 | ||
|
|
254b4c751e | ||
|
|
f2cacedc20 | ||
|
|
a9adb410d0 | ||
|
|
37c2b18300 | ||
|
|
ce7073cf24 | ||
|
|
5033348320 | ||
|
|
061a49f3b1 | ||
|
|
3a258537fc | ||
|
|
41d4306e86 | ||
|
|
9907ff265e | ||
|
|
2cf19f1880 | ||
|
|
a95486481b | ||
|
|
10d9bd209f | ||
|
|
b1c387c415 | ||
|
|
7ba70ae524 | ||
|
|
d9039c76b9 | ||
|
|
d117fb79ea | ||
|
|
d2610b2eef | ||
|
|
3d71234a61 | ||
|
|
72a5840752 | ||
|
|
f832138b41 | ||
|
|
5296230416 | ||
|
|
f2bcce702a | ||
|
|
d3b5188b67 | ||
|
|
c563618957 | ||
|
|
3cd2927b5f | ||
|
|
3b147bf8be | ||
|
|
8b4961d257 | ||
|
|
088062f757 |
@@ -23,4 +23,4 @@ VAPID_PUBLIC_KEY=
|
|||||||
|
|
||||||
# ── AI agent (optional in dev; Asistente won't work without it) ──────────────
|
# ── AI agent (optional in dev; Asistente won't work without it) ──────────────
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
AGENT_MODEL=gpt-5.4-mini
|
AGENT_MODEL=gpt-5.6-luna
|
||||||
|
|||||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
python3 -m venv /tmp/test-venv
|
python3 -m venv /tmp/test-venv
|
||||||
/tmp/test-venv/bin/pip install --quiet -r requirements.txt -r requirements-dev.txt
|
/tmp/test-venv/bin/pip install --quiet -r requirements.txt -r requirements-dev.txt -c constraints.txt
|
||||||
/tmp/test-venv/bin/python -m pytest tests/ -q
|
/tmp/test-venv/bin/python -m pytest tests/ -q
|
||||||
|
|
||||||
- name: Frontend typecheck + API type drift gate
|
- name: Frontend typecheck + API type drift gate
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -16,3 +16,8 @@ tech_docs/
|
|||||||
.claude/
|
.claude/
|
||||||
.venv/
|
.venv/
|
||||||
frontend/openapi.json
|
frontend/openapi.json
|
||||||
|
|
||||||
|
# playwright (demo artifacts are generated, not committed)
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-report/
|
||||||
|
frontend/e2e-docs/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
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 constraints.txt ./
|
||||||
RUN pip install --no-cache-dir --pre -r requirements.txt
|
RUN pip install --no-cache-dir --pre -r requirements.txt -c constraints.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
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 constraints.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt -c constraints.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""track agui run id in chat logs
|
||||||
|
|
||||||
|
Revision ID: 10f4a4f71964
|
||||||
|
Revises: ecb99a158fbf
|
||||||
|
Create Date: 2026-07-14 22:32:16.771655
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '10f4a4f71964'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'ecb99a158fbf'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('chatrunlog', sa.Column('agui_run_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
op.create_index(op.f('ix_chatrunlog_agui_run_id'), 'chatrunlog', ['agui_run_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_agui_run_id'), table_name='chatrunlog')
|
||||||
|
op.drop_column('chatrunlog', 'agui_run_id')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""chat thread snapshot
|
||||||
|
|
||||||
|
Revision ID: 794baf50635b
|
||||||
|
Revises: 961802a2f50d
|
||||||
|
Create Date: 2026-07-04 17:49:14.914103
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '794baf50635b'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '961802a2f50d'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('chatthreadsnapshot',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('scope', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('messages', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
|
||||||
|
sa.Column('state', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||||
|
sa.Column('interrupt', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('scope', 'thread_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chatthreadsnapshot_scope'), 'chatthreadsnapshot', ['scope'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_chatthreadsnapshot_scope'), table_name='chatthreadsnapshot')
|
||||||
|
op.drop_table('chatthreadsnapshot')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""record chat client details
|
||||||
|
|
||||||
|
Revision ID: 86104b4ff6ae
|
||||||
|
Revises: 10f4a4f71964
|
||||||
|
Create Date: 2026-07-14 22:54:55.519380
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '86104b4ff6ae'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '10f4a4f71964'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('chatrunlog', sa.Column('client_ip', sa.Text(), nullable=True))
|
||||||
|
op.add_column('chatrunlog', sa.Column('user_agent', sa.Text(), nullable=True))
|
||||||
|
op.add_column('chatrunlog', sa.Column('browser', sa.Text(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('chatrunlog', 'browser')
|
||||||
|
op.drop_column('chatrunlog', 'user_agent')
|
||||||
|
op.drop_column('chatrunlog', 'client_ip')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""installment plans (tasa cero)
|
||||||
|
|
||||||
|
Revision ID: 961802a2f50d
|
||||||
|
Revises: 7884505b16b3
|
||||||
|
Create Date: 2026-07-03 16:00:55.026771
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '961802a2f50d'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '7884505b16b3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('installmentplan',
|
||||||
|
sa.Column('num_installments', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('first_installment_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('total_amount', sa.Numeric(precision=15, scale=2), nullable=False),
|
||||||
|
sa.Column('installment_amount', sa.Numeric(precision=15, scale=2), nullable=False),
|
||||||
|
# Reuse the existing 'currency' enum type — do NOT re-create it.
|
||||||
|
sa.Column('currency', postgresql.ENUM('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency', create_type=False), nullable=False),
|
||||||
|
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('anchor_transaction_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['anchor_transaction_id'], ['transaction.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_installmentplan_anchor_transaction_id'), 'installmentplan', ['anchor_transaction_id'], unique=True)
|
||||||
|
op.add_column('transaction', sa.Column('installment_plan_id', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('transaction', sa.Column('is_installment_anchor', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
op.create_index(op.f('ix_transaction_installment_plan_id'), 'transaction', ['installment_plan_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_transaction_installment_plan_id'), table_name='transaction')
|
||||||
|
op.drop_column('transaction', 'is_installment_anchor')
|
||||||
|
op.drop_column('transaction', 'installment_plan_id')
|
||||||
|
op.drop_index(op.f('ix_installmentplan_anchor_transaction_id'), table_name='installmentplan')
|
||||||
|
op.drop_table('installmentplan')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""add chat run audit logs
|
||||||
|
|
||||||
|
Revision ID: ecb99a158fbf
|
||||||
|
Revises: 794baf50635b
|
||||||
|
Create Date: 2026-07-14 22:22:06.035643
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'ecb99a158fbf'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '794baf50635b'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('chatrunlog',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('request_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('requested_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('response_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('model', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('user_prompt', sa.Text(), nullable=True),
|
||||||
|
sa.Column('tool_calls', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
|
||||||
|
sa.Column('assistant_response', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_expires_at'), 'chatrunlog', ['expires_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_request_id'), 'chatrunlog', ['request_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_requested_thread_id'), 'chatrunlog', ['requested_thread_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_response_thread_id'), 'chatrunlog', ['response_thread_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chatrunlog_status'), 'chatrunlog', ['status'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_status'), table_name='chatrunlog')
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_response_thread_id'), table_name='chatrunlog')
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_requested_thread_id'), table_name='chatrunlog')
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_request_id'), table_name='chatrunlog')
|
||||||
|
op.drop_index(op.f('ix_chatrunlog_expires_at'), table_name='chatrunlog')
|
||||||
|
op.drop_table('chatrunlog')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.openai import OpenAIChatCompletionClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.agent.tools import TOOLS
|
from app.agent.tools import TOOLS
|
||||||
@@ -22,19 +20,46 @@ Context you can rely on:
|
|||||||
following month. When the user says "this month" or "last month" without
|
following month. When the user says "this month" or "last month" without
|
||||||
qualifiers, assume they mean the calendar month unless they mention
|
qualifiers, assume they mean the calendar month unless they mention
|
||||||
"cycle", "corte", or their credit card.
|
"cycle", "corte", or their credit card.
|
||||||
- Today's date is {today}. Use it when the user says "this month", "last
|
- You do NOT have a reliable clock, and the server runs in UTC while the
|
||||||
month", "last year", etc.
|
user may be anywhere (their browser reports their timezone per request).
|
||||||
|
Whenever the question involves a relative date — "hoy", "ayer", "este
|
||||||
|
mes", "este ciclo", "el año pasado" — call get_current_date FIRST and
|
||||||
|
derive ranges from its answer.
|
||||||
- Amounts are stored as raw numbers in their native currency (see `currency`
|
- Amounts are stored as raw numbers in their native currency (see `currency`
|
||||||
field on transactions/accounts). Tools that return `total_crc` are already
|
field on transactions/accounts). Tools that return `total_crc` are already
|
||||||
converted; tools that return per-transaction amounts are NOT.
|
converted; tools that return per-transaction amounts are NOT.
|
||||||
|
|
||||||
How to answer:
|
How to answer:
|
||||||
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
|
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
|
||||||
|
- Match the tool to the question's time grain: day-scoped questions ("hoy",
|
||||||
|
"ayer", "el martes", a specific date) need get_daily_spending with that
|
||||||
|
day's bounds — cycle or category summaries have NO per-day data, so never
|
||||||
|
answer a day question from them.
|
||||||
|
- For a question about a specific salary deposit or its amount (including
|
||||||
|
"cuánto recibí hoy"), call get_salary_deposits with the relevant date range;
|
||||||
|
get_salary_summary is aggregate-only and cannot answer it.
|
||||||
|
- Answer like an analyst, not a calculator. An aggregate number on its own
|
||||||
|
is not an answer — show what it is made of, right away, without waiting
|
||||||
|
to be asked:
|
||||||
|
· saldo neto / net worth → headline, then the assets-vs-liabilities
|
||||||
|
split and the accounts behind each side (get_accounts + get_net_worth).
|
||||||
|
· "cuánto gasté hoy/ayer/el martes" → the total, then the purchases
|
||||||
|
behind it (get_recent_transactions with the same date bounds:
|
||||||
|
merchant, amount, source).
|
||||||
|
· cycle or category totals → the top components and how much each
|
||||||
|
contributes.
|
||||||
|
- Structure: one headline sentence with the number, a compact markdown
|
||||||
|
table with the breakdown, and — only when the data genuinely supports
|
||||||
|
it — one closing observation (an unusually large item, a comparison to
|
||||||
|
the previous day/cycle). Never pad with filler or generic offers like
|
||||||
|
"si quieres te puedo mostrar…" when you could just show it.
|
||||||
- Call multiple tools in parallel when the question spans domains
|
- Call multiple tools in parallel when the question spans domains
|
||||||
(e.g. net worth + recent transactions).
|
(e.g. net worth + recent transactions).
|
||||||
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
|
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
|
||||||
USD (two decimals), € for EUR (two decimals).
|
USD (two decimals), € for EUR (two decimals).
|
||||||
- When showing lists, prefer markdown tables over prose.
|
- When showing lists or structured data (transactions, breakdowns, funds),
|
||||||
|
format them as clean markdown tables: right-align amounts, include a total
|
||||||
|
row when summing, keep merchant names as-is.
|
||||||
- If a tool returns no data, say so explicitly — do not fill in zeros.
|
- 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
|
- 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
|
anything, explain that write actions aren't available yet and offer to
|
||||||
@@ -47,28 +72,32 @@ Generative UI — render tools:
|
|||||||
- When showing spending totals, cycle summaries, or category breakdowns →
|
- When showing spending totals, cycle summaries, or category breakdowns →
|
||||||
call render_spending_summary. Source data: get_cycle_summary (by_source,
|
call render_spending_summary. Source data: get_cycle_summary (by_source,
|
||||||
grand_total_crc) + get_analytics_by_category (by_category).
|
grand_total_crc) + get_analytics_by_category (by_category).
|
||||||
- When showing transaction lists or other structured data →
|
- Do NOT use markdown tables for data render_spending_summary can display.
|
||||||
call render_a2ui in a SEPARATE tool-call step, only after all data-fetching
|
- CRITICAL RULE: When you call render_spending_summary, that tool call MUST
|
||||||
calls have returned. NEVER call render_a2ui in the same batch as any other
|
be the ONLY content in your message. Do NOT include any text content
|
||||||
tool.
|
alongside the tool call — no introduction, no list, no explanation,
|
||||||
- Do NOT use markdown tables for data a render tool can display.
|
nothing. The rendered card IS the complete response. Any text you write in
|
||||||
- CRITICAL RULE: When you call a render tool (render_spending_summary or
|
the same message as a render call will appear as a duplicate below the
|
||||||
render_a2ui), that tool call MUST be the ONLY content in your message.
|
card, which is wrong.
|
||||||
Do NOT include any text content alongside the tool call — no introduction,
|
- When a render_spending_summary tool result ("ok") is already present for
|
||||||
no list, no explanation, nothing. The rendered card IS the complete
|
the current question, the card is already on screen. Do NOT call the tool
|
||||||
response. Any text you write in the same message as a render call will
|
again and do NOT add text — end your response with no further output.
|
||||||
appear as a duplicate below the card, which is wrong.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def build_agent() -> Agent:
|
def build_agent() -> Agent:
|
||||||
client = OpenAIChatCompletionClient(
|
# Use the Responses API for every configured model. It preserves one tool
|
||||||
|
# transport across model changes and supports Luna's tool-result turns.
|
||||||
|
client = OpenAIChatClient(
|
||||||
api_key=settings.OPENAI_API_KEY,
|
api_key=settings.OPENAI_API_KEY,
|
||||||
model=settings.AGENT_MODEL,
|
model=settings.AGENT_MODEL,
|
||||||
)
|
)
|
||||||
return Agent(
|
return Agent(
|
||||||
name="wealthysmart",
|
name="wealthysmart",
|
||||||
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()),
|
# No date baked in: build_agent() runs once at startup, so anything
|
||||||
|
# substituted here freezes at boot (and in server-UTC). The model
|
||||||
|
# gets "today" from the get_current_date tool instead.
|
||||||
|
instructions=SYSTEM_PROMPT,
|
||||||
client=client,
|
client=client,
|
||||||
tools=TOOLS,
|
tools=TOOLS,
|
||||||
)
|
)
|
||||||
|
|||||||
153
backend/app/agent/chat_audit.py
Normal file
153
backend/app/agent/chat_audit.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Durable diagnostics for individual AG-UI agent runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextvars
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import Session, delete, select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import engine
|
||||||
|
from app.models.models import ChatRunLog
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
CHAT_LOG_RETENTION_DAYS = 90
|
||||||
|
_chat_run_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
|
||||||
|
"chat_run_id", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_chat_run(run_id: int) -> contextvars.Token:
|
||||||
|
return _chat_run_id.set(run_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_current_chat_run(token: contextvars.Token) -> None:
|
||||||
|
_chat_run_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def current_chat_run_id() -> int | None:
|
||||||
|
return _chat_run_id.get()
|
||||||
|
|
||||||
|
|
||||||
|
def _last_user_prompt(messages: list[dict[str, Any]]) -> str | None:
|
||||||
|
for message in reversed(messages):
|
||||||
|
if message.get("role") == "user" and isinstance(message.get("content"), str):
|
||||||
|
return message["content"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_chat_run(
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
agui_run_id: str | None,
|
||||||
|
thread_id: str | None,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
client_ip: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
browser: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
now = utcnow()
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = ChatRunLog(
|
||||||
|
request_id=request_id,
|
||||||
|
agui_run_id=agui_run_id,
|
||||||
|
requested_thread_id=thread_id,
|
||||||
|
model=settings.AGENT_MODEL,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
browser=browser,
|
||||||
|
user_prompt=_last_user_prompt(messages),
|
||||||
|
expires_at=now + timedelta(days=CHAT_LOG_RETENTION_DAYS),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
assert row.id is not None
|
||||||
|
return row.id
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_activity(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
calls: dict[str, dict[str, Any]] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for message in messages:
|
||||||
|
if message.get("role") == "assistant":
|
||||||
|
for call in message.get("toolCalls") or message.get("tool_calls") or []:
|
||||||
|
call_id = str(call.get("id") or "")
|
||||||
|
if not call_id or call_id in calls:
|
||||||
|
continue
|
||||||
|
function = call.get("function") or {}
|
||||||
|
calls[call_id] = {
|
||||||
|
"id": call_id,
|
||||||
|
"name": function.get("name"),
|
||||||
|
"arguments": function.get("arguments"),
|
||||||
|
}
|
||||||
|
order.append(call_id)
|
||||||
|
elif message.get("role") == "tool":
|
||||||
|
call_id = str(message.get("toolCallId") or message.get("tool_call_id") or "")
|
||||||
|
if call_id in calls:
|
||||||
|
calls[call_id]["result"] = message.get("content")
|
||||||
|
if message.get("error") is not None:
|
||||||
|
calls[call_id]["error"] = message["error"]
|
||||||
|
return [calls[call_id] for call_id in order]
|
||||||
|
|
||||||
|
|
||||||
|
def _last_assistant_response(messages: list[dict[str, Any]]) -> str | None:
|
||||||
|
for message in reversed(messages):
|
||||||
|
if message.get("role") == "assistant" and isinstance(message.get("content"), str):
|
||||||
|
return message["content"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_chat_run_in_session(
|
||||||
|
session: Session, *, run_id: int, response_thread_id: str, messages: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
row = session.get(ChatRunLog, run_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.response_thread_id = response_thread_id
|
||||||
|
row.tool_calls = _tool_activity(messages)
|
||||||
|
row.assistant_response = _last_assistant_response(messages)
|
||||||
|
row.status = "completed"
|
||||||
|
row.completed_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_chat_run_failed(run_id: int, error: str) -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.get(ChatRunLog, run_id)
|
||||||
|
if row is None or row.status == "completed":
|
||||||
|
return
|
||||||
|
row.status = "failed"
|
||||||
|
row.error = error[:4000]
|
||||||
|
row.completed_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def finish_pending_chat_run(run_id: int) -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.get(ChatRunLog, run_id)
|
||||||
|
if row is None or row.status != "started":
|
||||||
|
return
|
||||||
|
row.status = "completed"
|
||||||
|
row.completed_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired_chat_runs() -> int:
|
||||||
|
with Session(engine) as session:
|
||||||
|
result = session.exec(delete(ChatRunLog).where(ChatRunLog.expires_at < utcnow()))
|
||||||
|
session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_expired_chat_runs_periodically() -> None:
|
||||||
|
"""Purge at startup and then once per day for a bounded audit footprint."""
|
||||||
|
while True:
|
||||||
|
await asyncio.to_thread(purge_expired_chat_runs)
|
||||||
|
await asyncio.sleep(24 * 60 * 60)
|
||||||
194
backend/app/agent/snapshot_store.py
Normal file
194
backend/app/agent/snapshot_store.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""Postgres-backed AG-UI thread snapshot store.
|
||||||
|
|
||||||
|
Implements the AGUIThreadSnapshotStore protocol from agent-framework-ag-ui so
|
||||||
|
the assistant conversation survives page reloads: MAF calls `save` with the
|
||||||
|
full cumulative message list at each run end, and `get` on the next request
|
||||||
|
(server-side context rebuild, or a client hydration run with empty messages).
|
||||||
|
|
||||||
|
Two deliberate choices:
|
||||||
|
- Own sessions per call: MAF saves during SSE streaming, after the
|
||||||
|
agent_auth_and_session middleware has already closed the request-bound
|
||||||
|
ContextVar session. Sync SQLModel work runs in a thread so the event loop
|
||||||
|
keeps streaming.
|
||||||
|
- `save` normalizes assistant `tool_calls` -> `toolCalls`: @ag-ui/client's
|
||||||
|
Zod schema silently strips the snake_case field, so a replayed snapshot
|
||||||
|
would lose tool calls (and the spending-summary card) on the way to the
|
||||||
|
browser. MAF reads both casings back, so storing camelCase is safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
from app.agent.chat_audit import current_chat_run_id, finalize_chat_run_in_session
|
||||||
|
from app.models.models import ChatThreadSnapshot
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_message(msg: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(msg)
|
||||||
|
tool_calls = out.pop("tool_calls", None)
|
||||||
|
if tool_calls is None:
|
||||||
|
tool_calls = out.pop("toolCalls", None)
|
||||||
|
else:
|
||||||
|
out.pop("toolCalls", None)
|
||||||
|
# MAF writes tool_calls: null on plain-text assistant messages; only a
|
||||||
|
# non-empty list is meaningful downstream.
|
||||||
|
if isinstance(tool_calls, list) and tool_calls:
|
||||||
|
out["toolCalls"] = tool_calls
|
||||||
|
if "tool_call_id" in out:
|
||||||
|
out.setdefault("toolCallId", out["tool_call_id"])
|
||||||
|
del out["tool_call_id"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Sanitize the cumulative snapshot before storing it.
|
||||||
|
|
||||||
|
MAF's history merge re-records the resent suffix of multi-run turns
|
||||||
|
(frontend-tool roundtrips) without deduplicating, and the model
|
||||||
|
occasionally re-issues an identical render tool call on the follow-up
|
||||||
|
run. Left as-is, the snapshot grows duplicates that replay as repeated
|
||||||
|
bubbles/cards after a reload.
|
||||||
|
"""
|
||||||
|
# Pass 1: drop BFF-injected ctx-* system messages, duplicate message
|
||||||
|
# ids, and repeated identical tool calls (same function + arguments)
|
||||||
|
# within one user turn.
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
turn_calls: set[tuple[Any, Any]] = set()
|
||||||
|
kept_call_ids: set[str] = set()
|
||||||
|
first_pass: list[dict[str, Any]] = []
|
||||||
|
for raw in messages:
|
||||||
|
m = _normalize_message(raw)
|
||||||
|
mid = str(m.get("id", ""))
|
||||||
|
if mid.startswith("ctx-"):
|
||||||
|
continue
|
||||||
|
if mid and mid in seen_ids:
|
||||||
|
continue
|
||||||
|
role = m.get("role")
|
||||||
|
if role == "user":
|
||||||
|
turn_calls = set()
|
||||||
|
if role == "assistant" and isinstance(m.get("toolCalls"), list):
|
||||||
|
kept: list[dict[str, Any]] = []
|
||||||
|
for tc in m["toolCalls"]:
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
key = (fn.get("name"), fn.get("arguments"))
|
||||||
|
if key in turn_calls:
|
||||||
|
continue
|
||||||
|
turn_calls.add(key)
|
||||||
|
kept.append(tc)
|
||||||
|
if tc.get("id"):
|
||||||
|
kept_call_ids.add(str(tc["id"]))
|
||||||
|
if kept:
|
||||||
|
m["toolCalls"] = kept
|
||||||
|
else:
|
||||||
|
m.pop("toolCalls", None)
|
||||||
|
if not m.get("content"):
|
||||||
|
continue # nothing left worth replaying
|
||||||
|
if mid:
|
||||||
|
seen_ids.add(mid)
|
||||||
|
first_pass.append(m)
|
||||||
|
|
||||||
|
# Pass 2: one tool result per surviving call id. Results whose call was
|
||||||
|
# deduplicated away must go too — an orphan tool message in the stored
|
||||||
|
# history would 400 the next OpenAI request when MAF rebuilds context.
|
||||||
|
answered: set[str] = set()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for m in first_pass:
|
||||||
|
if m.get("role") == "tool":
|
||||||
|
tcid = str(m.get("toolCallId") or "")
|
||||||
|
if tcid and (tcid not in kept_call_ids or tcid in answered):
|
||||||
|
continue
|
||||||
|
if tcid:
|
||||||
|
answered.add(tcid)
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresSnapshotStore:
|
||||||
|
"""One upserted row per (scope, thread_id); latest snapshot only."""
|
||||||
|
|
||||||
|
async def save(
|
||||||
|
self, *, scope: str, thread_id: str, snapshot: AGUIThreadSnapshot
|
||||||
|
) -> None:
|
||||||
|
def _save() -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot).where(
|
||||||
|
ChatThreadSnapshot.scope == scope,
|
||||||
|
ChatThreadSnapshot.thread_id == thread_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
row = ChatThreadSnapshot(scope=scope, thread_id=thread_id)
|
||||||
|
row.messages = _clean_messages(snapshot.messages)
|
||||||
|
row.state = snapshot.state
|
||||||
|
row.interrupt = snapshot.interrupt
|
||||||
|
row.updated_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
run_id = current_chat_run_id()
|
||||||
|
if run_id is not None:
|
||||||
|
finalize_chat_run_in_session(
|
||||||
|
session,
|
||||||
|
run_id=run_id,
|
||||||
|
response_thread_id=thread_id,
|
||||||
|
messages=row.messages,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
await asyncio.to_thread(_save)
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self, *, scope: str, thread_id: str
|
||||||
|
) -> AGUIThreadSnapshot | None:
|
||||||
|
def _get() -> AGUIThreadSnapshot | None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot).where(
|
||||||
|
ChatThreadSnapshot.scope == scope,
|
||||||
|
ChatThreadSnapshot.thread_id == thread_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return AGUIThreadSnapshot(
|
||||||
|
messages=row.messages or [],
|
||||||
|
state=row.state,
|
||||||
|
interrupt=row.interrupt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_get)
|
||||||
|
|
||||||
|
async def delete(self, *, scope: str, thread_id: str) -> bool:
|
||||||
|
def _delete() -> bool:
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot).where(
|
||||||
|
ChatThreadSnapshot.scope == scope,
|
||||||
|
ChatThreadSnapshot.thread_id == thread_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_delete)
|
||||||
|
|
||||||
|
async def clear(self, *, scope: str | None = None) -> None:
|
||||||
|
def _clear() -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
stmt = select(ChatThreadSnapshot)
|
||||||
|
if scope is not None:
|
||||||
|
stmt = stmt.where(ChatThreadSnapshot.scope == scope)
|
||||||
|
for row in session.exec(stmt).all():
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
await asyncio.to_thread(_clear)
|
||||||
@@ -13,7 +13,7 @@ write tool requires an explicit user-confirmation step in the UI.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextvars
|
import contextvars
|
||||||
from datetime import datetime
|
from datetime import datetime, time, timedelta
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@@ -35,17 +35,20 @@ from app.models.models import (
|
|||||||
from app.services.budget_projection import (
|
from app.services.budget_projection import (
|
||||||
MAX_YEAR,
|
MAX_YEAR,
|
||||||
MIN_YEAR,
|
MIN_YEAR,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
compute_monthly_projection,
|
compute_monthly_projection,
|
||||||
compute_yearly_projection_with_cumulative,
|
compute_yearly_projection_with_cumulative,
|
||||||
get_cycle_range,
|
get_cycle_range,
|
||||||
)
|
)
|
||||||
from app.services import exchange_rate as fx
|
from app.services import exchange_rate as fx
|
||||||
|
from app.timeutil import client_tz, today_client
|
||||||
from app.services.exchange_rate import (
|
from app.services.exchange_rate import (
|
||||||
get_converted_amount_expr,
|
get_converted_amount_expr,
|
||||||
get_current_rate,
|
get_current_rate,
|
||||||
)
|
)
|
||||||
|
|
||||||
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
|
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
|
||||||
|
SALARY_TRANSACTION_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
|
||||||
|
|
||||||
|
|
||||||
def set_session(session: Session) -> contextvars.Token:
|
def set_session(session: Session) -> contextvars.Token:
|
||||||
@@ -140,7 +143,11 @@ def get_recent_transactions(
|
|||||||
q = select(Transaction).where(
|
q = select(Transaction).where(
|
||||||
col(Transaction.transaction_type).notin_(
|
col(Transaction.transaction_type).notin_(
|
||||||
[TransactionType.SALARY, TransactionType.DEPOSITO]
|
[TransactionType.SALARY, TransactionType.DEPOSITO]
|
||||||
)
|
),
|
||||||
|
# Tasa Cero generates future-dated cuotas; "recent" means already
|
||||||
|
# billed (same rule as /transactions/recent). Bound is end of the
|
||||||
|
# user's today in their request timezone (CR fallback).
|
||||||
|
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
|
||||||
)
|
)
|
||||||
if source:
|
if source:
|
||||||
q = q.where(Transaction.source == TransactionSource(source))
|
q = q.where(Transaction.source == TransactionSource(source))
|
||||||
@@ -333,7 +340,7 @@ def get_salary_summary() -> dict:
|
|||||||
func.count(),
|
func.count(),
|
||||||
func.coalesce(func.sum(amount_crc), 0),
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
func.max(Transaction.date),
|
func.max(Transaction.date),
|
||||||
).where(Transaction.transaction_type == TransactionType.SALARY)
|
).where(col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES))
|
||||||
).first()
|
).first()
|
||||||
count = row[0] if row else 0
|
count = row[0] if row else 0
|
||||||
total = float(row[1]) if row else 0.0
|
total = float(row[1]) if row else 0.0
|
||||||
@@ -341,6 +348,44 @@ def get_salary_summary() -> dict:
|
|||||||
return {"count": count, "total_crc": total, "latest_date": latest}
|
return {"count": count, "total_crc": total, "latest_date": latest}
|
||||||
|
|
||||||
|
|
||||||
|
def get_salary_deposits(
|
||||||
|
limit: Annotated[int, Field(ge=1, le=100, description="How many deposits to return")] = 20,
|
||||||
|
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]:
|
||||||
|
"""Individual salary deposits, newest first. Use this for questions about
|
||||||
|
a specific salary or a day/range (for example, "cuánto recibí hoy").
|
||||||
|
Amounts remain in their recorded currency; use the currency field when
|
||||||
|
presenting them."""
|
||||||
|
q = select(Transaction).where(
|
||||||
|
col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES)
|
||||||
|
)
|
||||||
|
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(), col(Transaction.id).desc()).limit(limit)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"date": t.date.isoformat(),
|
||||||
|
"amount": float(t.amount),
|
||||||
|
"currency": t.currency.value,
|
||||||
|
"merchant": t.merchant,
|
||||||
|
"source": t.source.value,
|
||||||
|
"bank": t.bank.value,
|
||||||
|
"transaction_type": t.transaction_type.value,
|
||||||
|
"reference": t.reference,
|
||||||
|
"notes": t.notes,
|
||||||
|
}
|
||||||
|
for t in _s().exec(q).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_municipal_receipts(
|
def get_municipal_receipts(
|
||||||
limit: Annotated[int, Field(ge=1, le=50)] = 12,
|
limit: Annotated[int, Field(ge=1, le=50)] = 12,
|
||||||
offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0,
|
offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0,
|
||||||
@@ -491,7 +536,68 @@ def list_categories() -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
# Registered with the agent in agent.py
|
# Registered with the agent in agent.py
|
||||||
|
def get_daily_spending(
|
||||||
|
start_date: Annotated[str, Field(description="ISO date lower bound, inclusive")],
|
||||||
|
end_date: Annotated[str, Field(description="ISO date upper bound, exclusive")],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Spending per day in CRC (converted), COMPRA only, excluding future
|
||||||
|
Tasa Cero cuotas and installment anchors. THE tool for day-scoped
|
||||||
|
questions: 'cuánto gasté hoy / ayer / el martes'. Days with no spending
|
||||||
|
are simply absent from the result."""
|
||||||
|
session = _s()
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
rows = session.exec(
|
||||||
|
select(
|
||||||
|
func.date(Transaction.date).label("day"),
|
||||||
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
|
func.count(),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
|
Transaction.date >= datetime.fromisoformat(start_date),
|
||||||
|
Transaction.date < datetime.fromisoformat(end_date),
|
||||||
|
# Future-dated Tasa Cero cuotas are not money already spent.
|
||||||
|
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
|
||||||
|
)
|
||||||
|
.group_by(func.date(Transaction.date))
|
||||||
|
.order_by(func.date(Transaction.date))
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{"date": str(day), "total_crc": float(total), "count": count}
|
||||||
|
for day, total, count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_date() -> dict:
|
||||||
|
"""Today's date in the user's own timezone (sent by their browser;
|
||||||
|
Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
|
||||||
|
call this before resolving any relative date reference — 'hoy', 'ayer',
|
||||||
|
'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
|
||||||
|
own assumptions about today are unreliable."""
|
||||||
|
today = today_client()
|
||||||
|
# get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before
|
||||||
|
# the 18th we are still in the cycle that started last month.
|
||||||
|
if today.day >= 18:
|
||||||
|
cycle_year, cycle_month = today.year, today.month
|
||||||
|
elif today.month == 1:
|
||||||
|
cycle_year, cycle_month = today.year - 1, 12
|
||||||
|
else:
|
||||||
|
cycle_year, cycle_month = today.year, today.month - 1
|
||||||
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
|
return {
|
||||||
|
"date": today.isoformat(),
|
||||||
|
"weekday": today.strftime("%A"),
|
||||||
|
"timezone": str(client_tz()),
|
||||||
|
"cycle_year": cycle_year,
|
||||||
|
"cycle_month": cycle_month,
|
||||||
|
"cycle_range": [start.date().isoformat(), end.date().isoformat()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
TOOLS = [
|
TOOLS = [
|
||||||
|
get_current_date,
|
||||||
|
get_daily_spending,
|
||||||
get_accounts,
|
get_accounts,
|
||||||
get_net_worth,
|
get_net_worth,
|
||||||
get_recent_transactions,
|
get_recent_transactions,
|
||||||
@@ -500,6 +606,7 @@ TOOLS = [
|
|||||||
list_recurring_items,
|
list_recurring_items,
|
||||||
get_pension_snapshots,
|
get_pension_snapshots,
|
||||||
get_salary_summary,
|
get_salary_summary,
|
||||||
|
get_salary_deposits,
|
||||||
get_municipal_receipts,
|
get_municipal_receipts,
|
||||||
get_analytics_by_category,
|
get_analytics_by_category,
|
||||||
get_monthly_trend,
|
get_monthly_trend,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from sqlmodel import Session, func, select
|
|||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.models import Category, Transaction, TransactionType
|
from app.models.models import Category, Transaction, TransactionType
|
||||||
from app.services.budget_projection import get_cycle_range
|
from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range
|
||||||
|
from app.timeutil import utcnow
|
||||||
from app.services.exchange_rate import get_converted_amount_expr
|
from app.services.exchange_rate import get_converted_amount_expr
|
||||||
|
|
||||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||||
@@ -42,6 +43,8 @@ class DailySpending(BaseModel):
|
|||||||
def spending_by_category(
|
def spending_by_category(
|
||||||
cycle_year: Optional[int] = None,
|
cycle_year: Optional[int] = None,
|
||||||
cycle_month: Optional[int] = None,
|
cycle_month: Optional[int] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -53,17 +56,26 @@ def spending_by_category(
|
|||||||
func.sum(amount_crc).label("total"),
|
func.sum(amount_crc).label("total"),
|
||||||
func.count().label("count"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == TransactionType.COMPRA)
|
.where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
|
)
|
||||||
.group_by(Transaction.category_id)
|
.group_by(Transaction.category_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if cycle_year and cycle_month:
|
# Arbitrary date range takes precedence over the cycle filter.
|
||||||
|
if start_date and end_date:
|
||||||
|
query = query.where(
|
||||||
|
Transaction.date >= datetime.fromisoformat(start_date),
|
||||||
|
Transaction.date < datetime.fromisoformat(end_date),
|
||||||
|
)
|
||||||
|
elif cycle_year and cycle_month:
|
||||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||||
|
|
||||||
rows = session.exec(query).all()
|
rows = session.exec(query).all()
|
||||||
|
|
||||||
grand_total = sum(r[1] for r in rows) or 1
|
grand_total = float(sum(float(r[1]) for r in rows)) or 1.0
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for category_id, total, count in rows:
|
for category_id, total, count in rows:
|
||||||
@@ -126,6 +138,7 @@ def monthly_trend(
|
|||||||
Transaction.transaction_type == TransactionType.COMPRA,
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
Transaction.date >= start,
|
Transaction.date >= start,
|
||||||
Transaction.date < end,
|
Transaction.date < end,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -160,6 +173,8 @@ def monthly_trend(
|
|||||||
def daily_spending(
|
def daily_spending(
|
||||||
cycle_year: Optional[int] = None,
|
cycle_year: Optional[int] = None,
|
||||||
cycle_month: Optional[int] = None,
|
cycle_month: Optional[int] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -171,12 +186,23 @@ def daily_spending(
|
|||||||
func.sum(amount_crc).label("total"),
|
func.sum(amount_crc).label("total"),
|
||||||
func.count().label("count"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == TransactionType.COMPRA)
|
.where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
|
# Tasa Cero generates future-dated cuotas; daily spending is
|
||||||
|
# about money already spent.
|
||||||
|
Transaction.date <= utcnow(),
|
||||||
|
)
|
||||||
.group_by(func.date(Transaction.date))
|
.group_by(func.date(Transaction.date))
|
||||||
.order_by(func.date(Transaction.date))
|
.order_by(func.date(Transaction.date))
|
||||||
)
|
)
|
||||||
|
|
||||||
if cycle_year and cycle_month:
|
if start_date and end_date:
|
||||||
|
query = query.where(
|
||||||
|
Transaction.date >= datetime.fromisoformat(start_date),
|
||||||
|
Transaction.date < datetime.fromisoformat(end_date),
|
||||||
|
)
|
||||||
|
elif cycle_year and cycle_month:
|
||||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||||
|
|
||||||
|
|||||||
41
backend/app/api/v1/endpoints/chat.py
Normal file
41
backend/app/api/v1/endpoints/chat.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Assistant chat thread management.
|
||||||
|
|
||||||
|
The conversation itself is persisted by the AG-UI snapshot store
|
||||||
|
(app/agent/snapshot_store.py) under a single ("default", "main") thread;
|
||||||
|
this router only exposes the reset used by "Nueva conversación".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import ChatThreadSnapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||||
|
|
||||||
|
CHAT_SCOPE = "default"
|
||||||
|
CHAT_THREAD_ID = "main"
|
||||||
|
|
||||||
|
|
||||||
|
class ClearThreadResponse(BaseModel):
|
||||||
|
cleared: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/thread", response_model=ClearThreadResponse)
|
||||||
|
def clear_thread(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot).where(
|
||||||
|
ChatThreadSnapshot.scope == CHAT_SCOPE,
|
||||||
|
ChatThreadSnapshot.thread_id == CHAT_THREAD_ID,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return {"cleared": False}
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
return {"cleared": True}
|
||||||
234
backend/app/api/v1/endpoints/installments.py
Normal file
234
backend/app/api/v1/endpoints/installments.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import (
|
||||||
|
InstallmentPlan,
|
||||||
|
InstallmentPlanCreate,
|
||||||
|
InstallmentPlanUpdate,
|
||||||
|
Transaction,
|
||||||
|
TransactionRead,
|
||||||
|
TransactionSource,
|
||||||
|
TransactionType,
|
||||||
|
)
|
||||||
|
from app.services.installments import (
|
||||||
|
compute_installment_amounts,
|
||||||
|
create_plan,
|
||||||
|
delete_plan,
|
||||||
|
regenerate_cuotas,
|
||||||
|
)
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/installment-plans", tags=["installments"])
|
||||||
|
|
||||||
|
MIN_INSTALLMENTS = 2
|
||||||
|
MAX_INSTALLMENTS = 48
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlanRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
anchor_transaction_id: int
|
||||||
|
merchant: str
|
||||||
|
purchase_date: datetime
|
||||||
|
num_installments: int
|
||||||
|
first_installment_date: datetime
|
||||||
|
total_amount: float
|
||||||
|
installment_amount: float
|
||||||
|
last_installment_amount: float
|
||||||
|
currency: str
|
||||||
|
notes: Optional[str] = None
|
||||||
|
cuotas_billed: int
|
||||||
|
paid_amount: float
|
||||||
|
remaining_amount: float
|
||||||
|
next_cuota_date: Optional[datetime] = None
|
||||||
|
is_completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlanDetail(InstallmentPlanRead):
|
||||||
|
cuotas: list[TransactionRead] = []
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlanListResponse(BaseModel):
|
||||||
|
plans: list[InstallmentPlanRead]
|
||||||
|
total_remaining: float
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_num_installments(n: int) -> None:
|
||||||
|
if not MIN_INSTALLMENTS <= n <= MAX_INSTALLMENTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"num_installments must be {MIN_INSTALLMENTS}-{MAX_INSTALLMENTS}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_plan_and_anchor(
|
||||||
|
session: Session, plan_id: int
|
||||||
|
) -> tuple[InstallmentPlan, Transaction]:
|
||||||
|
plan = session.get(InstallmentPlan, plan_id)
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="Installment plan not found")
|
||||||
|
anchor = session.get(Transaction, plan.anchor_transaction_id)
|
||||||
|
if not anchor:
|
||||||
|
raise HTTPException(status_code=404, detail="Anchor transaction not found")
|
||||||
|
return plan, anchor
|
||||||
|
|
||||||
|
|
||||||
|
def _to_read(
|
||||||
|
plan: InstallmentPlan, anchor: Transaction, cuotas: list[Transaction]
|
||||||
|
) -> InstallmentPlanRead:
|
||||||
|
now = utcnow()
|
||||||
|
billed = [c for c in cuotas if c.date <= now]
|
||||||
|
future = [c for c in cuotas if c.date > now]
|
||||||
|
paid = float(sum(c.amount for c in billed))
|
||||||
|
total = float(plan.total_amount)
|
||||||
|
amounts = compute_installment_amounts(
|
||||||
|
plan.total_amount, plan.num_installments
|
||||||
|
)
|
||||||
|
return InstallmentPlanRead(
|
||||||
|
id=plan.id,
|
||||||
|
anchor_transaction_id=plan.anchor_transaction_id,
|
||||||
|
merchant=anchor.merchant,
|
||||||
|
purchase_date=anchor.date,
|
||||||
|
num_installments=plan.num_installments,
|
||||||
|
first_installment_date=plan.first_installment_date,
|
||||||
|
total_amount=total,
|
||||||
|
installment_amount=float(plan.installment_amount),
|
||||||
|
last_installment_amount=float(amounts[-1]),
|
||||||
|
currency=plan.currency.value,
|
||||||
|
notes=plan.notes,
|
||||||
|
cuotas_billed=len(billed),
|
||||||
|
paid_amount=paid,
|
||||||
|
remaining_amount=total - paid,
|
||||||
|
next_cuota_date=min((c.date for c in future), default=None),
|
||||||
|
is_completed=len(billed) == plan.num_installments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cuotas_by_plan(
|
||||||
|
session: Session, plan_ids: list[int]
|
||||||
|
) -> dict[int, list[Transaction]]:
|
||||||
|
grouped: dict[int, list[Transaction]] = {pid: [] for pid in plan_ids}
|
||||||
|
if plan_ids:
|
||||||
|
rows = session.exec(
|
||||||
|
select(Transaction)
|
||||||
|
.where(col(Transaction.installment_plan_id).in_(plan_ids))
|
||||||
|
.order_by(col(Transaction.date))
|
||||||
|
).all()
|
||||||
|
for row in rows:
|
||||||
|
grouped[row.installment_plan_id].append(row)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=InstallmentPlanListResponse)
|
||||||
|
def list_installment_plans(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
plans = session.exec(select(InstallmentPlan)).all()
|
||||||
|
anchors = {
|
||||||
|
t.id: t
|
||||||
|
for t in session.exec(
|
||||||
|
select(Transaction).where(
|
||||||
|
col(Transaction.id).in_([p.anchor_transaction_id for p in plans])
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
} if plans else {}
|
||||||
|
cuotas = _cuotas_by_plan(session, [p.id for p in plans])
|
||||||
|
reads = [
|
||||||
|
_to_read(p, anchors[p.anchor_transaction_id], cuotas[p.id])
|
||||||
|
for p in plans
|
||||||
|
if p.anchor_transaction_id in anchors
|
||||||
|
]
|
||||||
|
reads.sort(key=lambda r: r.purchase_date, reverse=True)
|
||||||
|
return InstallmentPlanListResponse(
|
||||||
|
plans=reads,
|
||||||
|
total_remaining=sum(r.remaining_amount for r in reads),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{plan_id}", response_model=InstallmentPlanDetail)
|
||||||
|
def get_installment_plan(
|
||||||
|
plan_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
plan, anchor = _get_plan_and_anchor(session, plan_id)
|
||||||
|
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
|
||||||
|
read = _to_read(plan, anchor, cuotas)
|
||||||
|
return InstallmentPlanDetail(**read.model_dump(), cuotas=cuotas)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=InstallmentPlanRead, status_code=201)
|
||||||
|
def create_installment_plan(
|
||||||
|
data: InstallmentPlanCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Convert an existing credit-card purchase into a Tasa Cero plan."""
|
||||||
|
_validate_num_installments(data.num_installments)
|
||||||
|
anchor = session.get(Transaction, data.transaction_id)
|
||||||
|
if not anchor:
|
||||||
|
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||||
|
if anchor.source != TransactionSource.CREDIT_CARD:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Only credit card transactions can be financed"
|
||||||
|
)
|
||||||
|
if anchor.transaction_type != TransactionType.COMPRA:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Only COMPRA transactions can be financed"
|
||||||
|
)
|
||||||
|
if anchor.installment_plan_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="A cuota cannot be converted to a plan"
|
||||||
|
)
|
||||||
|
if anchor.is_installment_anchor:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="Transaction already has an installment plan"
|
||||||
|
)
|
||||||
|
plan = create_plan(
|
||||||
|
session, anchor, data.num_installments, data.first_installment_date
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(plan)
|
||||||
|
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
|
||||||
|
return _to_read(plan, anchor, cuotas)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{plan_id}", response_model=InstallmentPlanRead)
|
||||||
|
def update_installment_plan(
|
||||||
|
plan_id: int,
|
||||||
|
data: InstallmentPlanUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Edit a plan; cuota rows are regenerated (per-cuota edits are lost)."""
|
||||||
|
plan, anchor = _get_plan_and_anchor(session, plan_id)
|
||||||
|
if data.num_installments is not None:
|
||||||
|
_validate_num_installments(data.num_installments)
|
||||||
|
if data.notes is not None:
|
||||||
|
plan.notes = data.notes
|
||||||
|
regenerate_cuotas(
|
||||||
|
session, plan, anchor, data.num_installments, data.first_installment_date
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(plan)
|
||||||
|
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
|
||||||
|
return _to_read(plan, anchor, cuotas)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{plan_id}", status_code=204)
|
||||||
|
def delete_installment_plan(
|
||||||
|
plan_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Unconvert: remove the plan and its cuotas; the anchor becomes a normal
|
||||||
|
transaction again."""
|
||||||
|
plan, anchor = _get_plan_and_anchor(session, plan_id)
|
||||||
|
delete_plan(session, plan, anchor)
|
||||||
|
session.commit()
|
||||||
@@ -21,9 +21,19 @@ from app.models.models import (
|
|||||||
TransactionUpdate,
|
TransactionUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.budget_projection import get_cycle_range, get_previous_cycle
|
from app.services.budget_projection import (
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
|
get_cycle_range,
|
||||||
|
get_previous_cycle,
|
||||||
|
)
|
||||||
from app.services.csv_export import build_transactions_csv
|
from app.services.csv_export import build_transactions_csv
|
||||||
from app.services.exchange_rate import get_converted_amount_expr
|
from app.services.exchange_rate import get_converted_amount_expr
|
||||||
|
from app.services.installments import (
|
||||||
|
DEFAULT_NUM_INSTALLMENTS,
|
||||||
|
create_plan,
|
||||||
|
is_tasa_cero_merchant,
|
||||||
|
teardown_plan_for_anchor,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
|
|
||||||
@@ -130,17 +140,22 @@ def bulk_action(
|
|||||||
txs = session.exec(
|
txs = session.exec(
|
||||||
select(Transaction).where(col(Transaction.id).in_(req.ids))
|
select(Transaction).where(col(Transaction.id).in_(req.ids))
|
||||||
).all()
|
).all()
|
||||||
|
affected = 0
|
||||||
for tx in txs:
|
for tx in txs:
|
||||||
if req.action == "delete":
|
if req.action == "delete":
|
||||||
|
teardown_plan_for_anchor(session, tx)
|
||||||
session.delete(tx)
|
session.delete(tx)
|
||||||
elif req.action == "set_category":
|
elif req.action == "set_category":
|
||||||
tx.category_id = req.category_id
|
tx.category_id = req.category_id
|
||||||
session.add(tx)
|
session.add(tx)
|
||||||
elif req.action == "set_deferred":
|
elif req.action == "set_deferred":
|
||||||
|
if tx.installment_plan_id:
|
||||||
|
continue # cuotas of a plan cannot be deferred
|
||||||
tx.deferred_to_next_cycle = bool(req.deferred)
|
tx.deferred_to_next_cycle = bool(req.deferred)
|
||||||
session.add(tx)
|
session.add(tx)
|
||||||
|
affected += 1
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"affected": len(txs)}
|
return {"affected": affected}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export")
|
@router.get("/export")
|
||||||
@@ -205,7 +220,9 @@ def list_billing_cycles(
|
|||||||
# Count transactions in this cycle
|
# Count transactions in this cycle
|
||||||
count_result = session.exec(
|
count_result = session.exec(
|
||||||
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
|
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
|
||||||
Transaction.date >= start, Transaction.date < end
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
count = count_result[0] if count_result else 0
|
count = count_result[0] if count_result else 0
|
||||||
@@ -236,9 +253,15 @@ def recent_transactions(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Transaction)
|
select(Transaction)
|
||||||
.where(Transaction.source == TransactionSource.CREDIT_CARD)
|
.where(
|
||||||
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
|
# Tasa Cero generates future-dated cuotas; "recent" means billed.
|
||||||
|
Transaction.date <= utcnow(),
|
||||||
|
)
|
||||||
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
|
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
@@ -268,6 +291,20 @@ def create_transaction(
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(tx)
|
session.refresh(tx)
|
||||||
|
|
||||||
|
# Tasa Cero auto-detection: BAC prefixes financed purchases with "CC ".
|
||||||
|
# Default 3 cuotas starting on the purchase date; editable afterwards via
|
||||||
|
# /installment-plans.
|
||||||
|
tasa_cero_note = ""
|
||||||
|
if (
|
||||||
|
tx.source == TransactionSource.CREDIT_CARD
|
||||||
|
and tx.transaction_type == TransactionType.COMPRA
|
||||||
|
and is_tasa_cero_merchant(tx.merchant)
|
||||||
|
):
|
||||||
|
create_plan(session, tx, DEFAULT_NUM_INSTALLMENTS, tx.date)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(tx)
|
||||||
|
tasa_cero_note = f" — Tasa Cero {DEFAULT_NUM_INSTALLMENTS} cuotas"
|
||||||
|
|
||||||
# Send push notification
|
# Send push notification
|
||||||
symbols = {Currency.CRC: "₡", Currency.USD: "$", Currency.EUR: "€"}
|
symbols = {Currency.CRC: "₡", Currency.USD: "$", Currency.EUR: "€"}
|
||||||
symbol = symbols.get(tx.currency, tx.currency.value)
|
symbol = symbols.get(tx.currency, tx.currency.value)
|
||||||
@@ -278,7 +315,7 @@ def create_transaction(
|
|||||||
send_push_to_all(
|
send_push_to_all(
|
||||||
session,
|
session,
|
||||||
title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
|
title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
|
||||||
body=f"{amount_str} — {tx.bank.value} {label}",
|
body=f"{amount_str} — {tx.bank.value} {label}{tasa_cero_note}",
|
||||||
url="/salarios" if is_income else "/budget",
|
url="/salarios" if is_income else "/budget",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -300,6 +337,11 @@ def update_transaction(
|
|||||||
if not tx:
|
if not tx:
|
||||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
if update_data.get("deferred_to_next_cycle") is not None and tx.installment_plan_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cuotas of an installment plan cannot be deferred",
|
||||||
|
)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(tx, key, value)
|
setattr(tx, key, value)
|
||||||
session.add(tx)
|
session.add(tx)
|
||||||
@@ -317,5 +359,6 @@ def delete_transaction(
|
|||||||
tx = session.get(Transaction, transaction_id)
|
tx = session.get(Transaction, transaction_id)
|
||||||
if not tx:
|
if not tx:
|
||||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||||
|
teardown_plan_for_anchor(session, tx)
|
||||||
session.delete(tx)
|
session.delete(tx)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from app.api.v1.endpoints import (
|
|||||||
auth,
|
auth,
|
||||||
budget,
|
budget,
|
||||||
categories,
|
categories,
|
||||||
|
chat,
|
||||||
exchange_rate,
|
exchange_rate,
|
||||||
import_transactions,
|
import_transactions,
|
||||||
|
installments,
|
||||||
municipal_receipts,
|
municipal_receipts,
|
||||||
notifications,
|
notifications,
|
||||||
pensions,
|
pensions,
|
||||||
@@ -25,6 +27,7 @@ api_router.include_router(accounts.router)
|
|||||||
api_router.include_router(categories.router)
|
api_router.include_router(categories.router)
|
||||||
api_router.include_router(transactions.router)
|
api_router.include_router(transactions.router)
|
||||||
api_router.include_router(import_transactions.router)
|
api_router.include_router(import_transactions.router)
|
||||||
|
api_router.include_router(installments.router)
|
||||||
api_router.include_router(exchange_rate.router)
|
api_router.include_router(exchange_rate.router)
|
||||||
api_router.include_router(tokens.router)
|
api_router.include_router(tokens.router)
|
||||||
api_router.include_router(analytics.router)
|
api_router.include_router(analytics.router)
|
||||||
@@ -36,3 +39,4 @@ api_router.include_router(pensions.router)
|
|||||||
api_router.include_router(municipal_receipts.router)
|
api_router.include_router(municipal_receipts.router)
|
||||||
api_router.include_router(savings_accrual.router)
|
api_router.include_router(savings_accrual.router)
|
||||||
api_router.include_router(sync_status.router)
|
api_router.include_router(sync_status.router)
|
||||||
|
api_router.include_router(chat.router)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Settings(BaseSettings):
|
|||||||
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 = ""
|
OPENAI_API_KEY: str = ""
|
||||||
AGENT_MODEL: str = "gpt-5.4-mini"
|
AGENT_MODEL: str = "gpt-5.6-luna"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> list[str]:
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
|||||||
31
backend/app/logging.py
Normal file
31
backend/app/logging.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Structured, redacted application logging for container stdout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class _JSONEncoder(json.JSONEncoder):
|
||||||
|
def default(self, value: Any) -> Any:
|
||||||
|
if isinstance(value, (datetime, date)):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
return super().default(value)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_structured_logging() -> None:
|
||||||
|
"""Reserved lifecycle hook for the structured stdout audit channel."""
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(event: str, **fields: Any) -> None:
|
||||||
|
"""Write a single JSON line; callers must not pass credentials or bodies."""
|
||||||
|
# stdout is Docker's durable capture point. `flush=True` also makes this
|
||||||
|
# visible immediately in `docker compose logs` during an active SSE run.
|
||||||
|
print(
|
||||||
|
json.dumps({"event": event, **fields}, cls=_JSONEncoder, separators=(",", ":")),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
from time import perf_counter
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -11,6 +12,15 @@ from jose import JWTError, jwt
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.agent.agent import build_agent
|
from app.agent.agent import build_agent
|
||||||
|
from app.agent.chat_audit import (
|
||||||
|
create_chat_run,
|
||||||
|
finish_pending_chat_run,
|
||||||
|
mark_chat_run_failed,
|
||||||
|
purge_expired_chat_runs_periodically,
|
||||||
|
reset_current_chat_run,
|
||||||
|
set_current_chat_run,
|
||||||
|
)
|
||||||
|
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||||
from app.agent.tools import reset_session, set_session
|
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 (
|
from app.auth import (
|
||||||
@@ -22,10 +32,47 @@ from app.auth import (
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_session, run_alembic_upgrade
|
from app.db import get_session, run_alembic_upgrade
|
||||||
from app.seed import seed_db
|
from app.seed import seed_db
|
||||||
|
from app.timeutil import reset_client_timezone, set_client_timezone
|
||||||
from app.services.exchange_rate import refresh_rates_periodically
|
from app.services.exchange_rate import refresh_rates_periodically
|
||||||
|
from app.logging import configure_structured_logging, log_event
|
||||||
|
|
||||||
|
|
||||||
AGENT_PATH = "/api/v1/agent/agui"
|
AGENT_PATH = "/api/v1/agent/agui"
|
||||||
|
MAX_USER_AGENT_LENGTH = 512
|
||||||
|
|
||||||
|
|
||||||
|
def _request_client_details(request: Request) -> tuple[str, str | None, str | None]:
|
||||||
|
"""Return the browser client details propagated through the frontend BFF.
|
||||||
|
|
||||||
|
Production nginx-proxy supplies X-Real-IP and the frontend forwards it to
|
||||||
|
FastAPI. For direct local development, fall back to X-Forwarded-For and
|
||||||
|
then the socket peer. The BFF is the only production path to the backend,
|
||||||
|
which is not publicly exposed.
|
||||||
|
"""
|
||||||
|
client_ip = request.headers.get("x-real-ip", "").strip()
|
||||||
|
if not client_ip:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
client_ip = forwarded.split(",", 1)[0].strip()
|
||||||
|
if not client_ip:
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
user_agent = request.headers.get("user-agent", "").strip()[:MAX_USER_AGENT_LENGTH] or None
|
||||||
|
if not user_agent:
|
||||||
|
return client_ip, None, None
|
||||||
|
ua = user_agent.lower()
|
||||||
|
if "edg/" in ua or "edgios/" in ua:
|
||||||
|
browser = "Edge"
|
||||||
|
elif "opr/" in ua or "opera" in ua:
|
||||||
|
browser = "Opera"
|
||||||
|
elif "firefox/" in ua or "fxios/" in ua:
|
||||||
|
browser = "Firefox"
|
||||||
|
elif "chrome/" in ua or "crios/" in ua:
|
||||||
|
browser = "Chrome"
|
||||||
|
elif "safari/" in ua:
|
||||||
|
browser = "Safari"
|
||||||
|
else:
|
||||||
|
browser = "Other"
|
||||||
|
return client_ip, user_agent, browser
|
||||||
|
|
||||||
|
|
||||||
def _pair_orphan_tool_calls(messages: list) -> list:
|
def _pair_orphan_tool_calls(messages: list) -> list:
|
||||||
@@ -63,19 +110,60 @@ def _pair_orphan_tool_calls(messages: list) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_stale_client_history(session, thread_id, messages: list) -> list:
|
||||||
|
"""Conversation history is server-owned (the AG-UI snapshot store).
|
||||||
|
|
||||||
|
A request that carries assistant/tool history for a thread with NO
|
||||||
|
stored snapshot comes from a stale client — typically a second tab that
|
||||||
|
was open when 'Nueva conversación' cleared the thread. Trusting it
|
||||||
|
would re-persist the cleared conversation ('chunks' resurrecting after
|
||||||
|
every clear). Keep only the last user turn; MAF starts the thread
|
||||||
|
fresh from there.
|
||||||
|
"""
|
||||||
|
if not thread_id or not messages:
|
||||||
|
return messages
|
||||||
|
has_history = any(m.get("role") in ("assistant", "tool") for m in messages)
|
||||||
|
if not has_history:
|
||||||
|
return messages
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.models.models import ChatThreadSnapshot
|
||||||
|
|
||||||
|
row = session.exec(
|
||||||
|
select(ChatThreadSnapshot.id).where(
|
||||||
|
ChatThreadSnapshot.thread_id == str(thread_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is not None:
|
||||||
|
return messages # thread exists; history is legitimate
|
||||||
|
last_user = None
|
||||||
|
for i in range(len(messages) - 1, -1, -1):
|
||||||
|
if messages[i].get("role") == "user":
|
||||||
|
last_user = i
|
||||||
|
break
|
||||||
|
return messages[last_user:] if last_user is not None else []
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
configure_structured_logging()
|
||||||
run_alembic_upgrade()
|
run_alembic_upgrade()
|
||||||
seed_db()
|
seed_db()
|
||||||
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
||||||
|
chat_audit_cleanup_task = asyncio.create_task(purge_expired_chat_runs_periodically())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
rate_refresh_task.cancel()
|
rate_refresh_task.cancel()
|
||||||
|
chat_audit_cleanup_task.cancel()
|
||||||
try:
|
try:
|
||||||
await rate_refresh_task
|
await rate_refresh_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
await chat_audit_cleanup_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
||||||
@@ -89,6 +177,67 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def log_api_request(request: Request, call_next):
|
||||||
|
"""Emit redacted, one-line diagnostics for API requests only.
|
||||||
|
|
||||||
|
This deliberately records no request/response bodies, headers, tokens,
|
||||||
|
passwords, or financial payloads. Full assistant content is retained only
|
||||||
|
in the bounded chat-run audit table.
|
||||||
|
"""
|
||||||
|
request_id = uuid.uuid4().hex
|
||||||
|
request.state.request_id = request_id
|
||||||
|
client_ip, user_agent, browser = _request_client_details(request)
|
||||||
|
request.state.client_ip = client_ip
|
||||||
|
request.state.user_agent = user_agent
|
||||||
|
request.state.browser = browser
|
||||||
|
started = perf_counter()
|
||||||
|
status_code = 500
|
||||||
|
is_logged_api = request.url.path.startswith("/api/") and request.url.path != "/api/health"
|
||||||
|
|
||||||
|
def emit(error_type: str | None = None) -> None:
|
||||||
|
if not is_logged_api:
|
||||||
|
return
|
||||||
|
route = request.scope.get("route")
|
||||||
|
log_event(
|
||||||
|
"api_request",
|
||||||
|
request_id=request_id,
|
||||||
|
method=request.method,
|
||||||
|
path=getattr(route, "path", request.url.path),
|
||||||
|
status_code=status_code,
|
||||||
|
duration_ms=round((perf_counter() - started) * 1000, 1),
|
||||||
|
error_type=error_type,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
browser=browser,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
status_code = response.status_code
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
original_iterator = getattr(response, "body_iterator", None)
|
||||||
|
if original_iterator is None:
|
||||||
|
emit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def logged_stream():
|
||||||
|
error_type: str | None = None
|
||||||
|
try:
|
||||||
|
async for chunk in original_iterator:
|
||||||
|
yield chunk
|
||||||
|
except Exception as exc:
|
||||||
|
error_type = type(exc).__name__
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
emit(error_type)
|
||||||
|
|
||||||
|
response.body_iterator = logged_stream()
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
emit(type(exc).__name__)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def agent_auth_and_session(request: Request, call_next):
|
async def agent_auth_and_session(request: Request, call_next):
|
||||||
"""For the AG-UI route, validate the JWT, repair message history, and
|
"""For the AG-UI route, validate the JWT, repair message history, and
|
||||||
@@ -119,25 +268,94 @@ async def agent_auth_and_session(request: Request, call_next):
|
|||||||
except JWTError:
|
except JWTError:
|
||||||
return Response(status_code=401, content="Invalid token")
|
return Response(status_code=401, content="Invalid token")
|
||||||
|
|
||||||
|
session_gen = get_session()
|
||||||
|
session = next(session_gen)
|
||||||
|
token_var = set_session(session)
|
||||||
|
|
||||||
|
chat_run_id: int | None = None
|
||||||
# Repair orphan tool_calls before the MAF agent sees the message history.
|
# 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", ""):
|
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
||||||
raw = await request.body()
|
raw = await request.body()
|
||||||
try:
|
try:
|
||||||
body = json.loads(raw)
|
body = json.loads(raw)
|
||||||
if isinstance(body.get("messages"), list):
|
if isinstance(body.get("messages"), list):
|
||||||
|
body["messages"] = _drop_stale_client_history(
|
||||||
|
session,
|
||||||
|
body.get("threadId") or body.get("thread_id"),
|
||||||
|
body["messages"],
|
||||||
|
)
|
||||||
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
||||||
raw = json.dumps(body).encode()
|
raw = json.dumps(body).encode()
|
||||||
except Exception:
|
log_event(
|
||||||
pass
|
"agent_request",
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
agui_run_id=body.get("runId"),
|
||||||
|
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||||
|
message_count=len(body.get("messages") or []),
|
||||||
|
has_user_message=any(
|
||||||
|
message.get("role") == "user" for message in body.get("messages") or []
|
||||||
|
),
|
||||||
|
client_ip=request.state.client_ip,
|
||||||
|
user_agent=request.state.user_agent,
|
||||||
|
browser=request.state.browser,
|
||||||
|
)
|
||||||
|
# The BFF removes its `method: agent/run` envelope before
|
||||||
|
# forwarding to MAF. A non-empty user message is the reliable
|
||||||
|
# backend-side distinction between an agent run and hydration.
|
||||||
|
if any(message.get("role") == "user" for message in body.get("messages") or []):
|
||||||
|
chat_run_id = create_chat_run(
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
agui_run_id=body.get("runId"),
|
||||||
|
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||||
|
messages=body.get("messages") or [],
|
||||||
|
client_ip=request.state.client_ip,
|
||||||
|
user_agent=request.state.user_agent,
|
||||||
|
browser=request.state.browser,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Parsing/repair must not prevent the agent endpoint from serving;
|
||||||
|
# the access log will still capture a resulting error response.
|
||||||
|
log_event(
|
||||||
|
"chat_audit_start_failed",
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
)
|
||||||
|
body = None
|
||||||
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
||||||
request._body = raw # type: ignore[attr-defined]
|
request._body = raw # type: ignore[attr-defined]
|
||||||
|
# Browser's IANA timezone, forwarded by the BFF — lets tools resolve
|
||||||
session_gen = get_session()
|
# "today" wherever the user is (falls back to Costa Rica).
|
||||||
session = next(session_gen)
|
tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
|
||||||
token_var = set_session(session)
|
audit_token = set_current_chat_run(chat_run_id) if chat_run_id is not None else None
|
||||||
try:
|
try:
|
||||||
return await call_next(request)
|
response = await call_next(request)
|
||||||
|
if chat_run_id is None:
|
||||||
|
return response
|
||||||
|
|
||||||
|
original_iterator = response.body_iterator
|
||||||
|
|
||||||
|
async def audit_stream():
|
||||||
|
try:
|
||||||
|
async for chunk in original_iterator:
|
||||||
|
yield chunk
|
||||||
|
except Exception as exc:
|
||||||
|
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# MAF normally finalizes via PostgresSnapshotStore.save(). If
|
||||||
|
# it emitted no snapshot, do not leave a permanently-open row.
|
||||||
|
finish_pending_chat_run(chat_run_id)
|
||||||
|
|
||||||
|
response.body_iterator = audit_stream()
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
if chat_run_id is not None:
|
||||||
|
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
if audit_token is not None:
|
||||||
|
reset_current_chat_run(audit_token)
|
||||||
|
reset_client_timezone(tz_token)
|
||||||
reset_session(token_var)
|
reset_session(token_var)
|
||||||
try:
|
try:
|
||||||
next(session_gen)
|
next(session_gen)
|
||||||
@@ -145,11 +363,26 @@ async def agent_auth_and_session(request: Request, call_next):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI wraps function middlewares in reverse registration order. Register
|
||||||
|
# this after the AG-UI auth middleware so it observes every API response,
|
||||||
|
# including auth failures that return before calling the inner application.
|
||||||
|
app.middleware("http")(log_api_request)
|
||||||
|
|
||||||
|
|
||||||
# Register app routes
|
# Register app routes
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
# Mount the AG-UI agent endpoint.
|
# Mount the AG-UI agent endpoint. The snapshot store persists the chat
|
||||||
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
# thread across page reloads; scope is a constant because this is a
|
||||||
|
# single-user app and auth is already enforced by agent_auth_and_session
|
||||||
|
# (the scope resolver only sees the AGUIRequest, which carries no identity).
|
||||||
|
add_agent_framework_fastapi_endpoint(
|
||||||
|
app,
|
||||||
|
build_agent(),
|
||||||
|
AGENT_PATH,
|
||||||
|
snapshot_store=PostgresSnapshotStore(),
|
||||||
|
snapshot_scope_resolver=lambda _request: "default",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from decimal import Decimal
|
|||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from pydantic import PlainSerializer
|
from pydantic import PlainSerializer
|
||||||
from sqlalchemy import JSON, Column, UniqueConstraint
|
from sqlalchemy import JSON, Column, Text, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
@@ -169,6 +169,17 @@ class Transaction(TransactionBase, table=True):
|
|||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
category: Optional[Category] = Relationship(back_populates="transactions")
|
category: Optional[Category] = Relationship(back_populates="transactions")
|
||||||
|
# Tasa Cero: set on generated cuota rows. Plain int (no DB FK) to avoid a
|
||||||
|
# circular transaction<->installmentplan dependency; cascades are handled
|
||||||
|
# explicitly in app.services.installments (SQLite tests don't enforce FKs
|
||||||
|
# anyway). Table-class only so TransactionCreate/n8n payloads can't set it.
|
||||||
|
installment_plan_id: Optional[int] = Field(default=None, index=True)
|
||||||
|
# Tasa Cero: True on the original purchase once converted to a plan; the
|
||||||
|
# anchor stays visible in listings but is excluded from budget/analytics
|
||||||
|
# aggregates (its generated cuotas are counted instead).
|
||||||
|
is_installment_anchor: bool = Field(
|
||||||
|
default=False, sa_column_kwargs={"server_default": "false"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TransactionCreate(TransactionBase):
|
class TransactionCreate(TransactionBase):
|
||||||
@@ -179,6 +190,8 @@ class TransactionRead(TransactionBase):
|
|||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
category: Optional[CategoryRead] = None
|
category: Optional[CategoryRead] = None
|
||||||
|
installment_plan_id: Optional[int] = None
|
||||||
|
is_installment_anchor: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TransactionUpdate(SQLModel):
|
class TransactionUpdate(SQLModel):
|
||||||
@@ -194,6 +207,39 @@ class TransactionUpdate(SQLModel):
|
|||||||
deferred_to_next_cycle: Optional[bool] = None
|
deferred_to_next_cycle: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Installment Plan (Tasa Cero) ---
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlanBase(SQLModel):
|
||||||
|
num_installments: int
|
||||||
|
first_installment_date: datetime
|
||||||
|
total_amount: Money = Field(max_digits=15, decimal_places=2)
|
||||||
|
# All cuotas but the last; the last absorbs the rounding remainder.
|
||||||
|
installment_amount: Money = Field(max_digits=15, decimal_places=2)
|
||||||
|
currency: Currency = Currency.CRC
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlan(InstallmentPlanBase, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
anchor_transaction_id: int = Field(
|
||||||
|
foreign_key="transaction.id", ondelete="CASCADE", unique=True, index=True
|
||||||
|
)
|
||||||
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlanCreate(SQLModel):
|
||||||
|
transaction_id: int
|
||||||
|
num_installments: int = 3
|
||||||
|
first_installment_date: Optional[datetime] = None # default: anchor.date
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlanUpdate(SQLModel):
|
||||||
|
num_installments: Optional[int] = None
|
||||||
|
first_installment_date: Optional[datetime] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# --- Exchange Rate ---
|
# --- Exchange Rate ---
|
||||||
|
|
||||||
|
|
||||||
@@ -491,3 +537,67 @@ class WaterMeterReading(WaterMeterReadingBase, table=True):
|
|||||||
class WaterMeterReadingRead(WaterMeterReadingBase):
|
class WaterMeterReadingRead(WaterMeterReadingBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Assistant Chat Thread ---
|
||||||
|
|
||||||
|
|
||||||
|
class ChatThreadSnapshot(SQLModel, table=True):
|
||||||
|
"""Latest AG-UI thread snapshot per (scope, thread_id).
|
||||||
|
|
||||||
|
Written by app/agent/snapshot_store.py: MAF overwrites the full
|
||||||
|
cumulative message list at each run end — one row per thread, not an
|
||||||
|
append log.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("scope", "thread_id"),)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
scope: str = Field(index=True)
|
||||||
|
thread_id: str
|
||||||
|
messages: list = Field(
|
||||||
|
default_factory=list,
|
||||||
|
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
|
||||||
|
)
|
||||||
|
state: Optional[dict] = Field(
|
||||||
|
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
|
||||||
|
)
|
||||||
|
interrupt: Optional[list] = Field(
|
||||||
|
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
|
||||||
|
)
|
||||||
|
updated_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Assistant Chat Run Audit ---
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRunLog(SQLModel, table=True):
|
||||||
|
"""Append-only diagnostic record for an AG-UI agent/run request.
|
||||||
|
|
||||||
|
Thread snapshots are mutable conversation state. This table instead keeps
|
||||||
|
the request, model, tool activity, and final emitted answer for a bounded
|
||||||
|
retention period so individual assistant runs can be investigated later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
request_id: str = Field(index=True, unique=True)
|
||||||
|
agui_run_id: Optional[str] = Field(default=None, index=True)
|
||||||
|
requested_thread_id: Optional[str] = Field(default=None, index=True)
|
||||||
|
response_thread_id: Optional[str] = Field(default=None, index=True)
|
||||||
|
model: str
|
||||||
|
client_ip: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
user_agent: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
browser: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
user_prompt: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
tool_calls: list = Field(
|
||||||
|
default_factory=list,
|
||||||
|
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
|
||||||
|
)
|
||||||
|
assistant_response: Optional[str] = Field(
|
||||||
|
default=None, sa_column=Column(Text, nullable=True)
|
||||||
|
)
|
||||||
|
status: str = Field(default="started", index=True)
|
||||||
|
error: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
started_at: datetime = Field(default_factory=utcnow)
|
||||||
|
completed_at: Optional[datetime] = Field(default=None)
|
||||||
|
expires_at: datetime = Field(index=True)
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ FRESH_START_MONTH = 3
|
|||||||
# Income-like transaction types that should never be counted as expenses
|
# Income-like transaction types that should never be counted as expenses
|
||||||
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
|
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
|
||||||
|
|
||||||
|
# Tasa Cero: the original purchase ("anchor") is excluded from all budget
|
||||||
|
# aggregates — its generated cuota transactions are counted instead.
|
||||||
|
# See app/services/installments.py.
|
||||||
|
NOT_INSTALLMENT_ANCHOR = Transaction.is_installment_anchor == False # noqa: E712
|
||||||
|
|
||||||
|
|
||||||
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
|
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
|
||||||
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
|
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
|
||||||
@@ -133,7 +138,7 @@ def compute_actuals_by_source(
|
|||||||
func.coalesce(func.sum(amount_crc), 0),
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
func.count(),
|
func.count(),
|
||||||
)
|
)
|
||||||
.where(*where)
|
.where(NOT_INSTALLMENT_ANCHOR, *where)
|
||||||
.group_by(Transaction.source, Transaction.transaction_type)
|
.group_by(Transaction.source, Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
|
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
|
||||||
@@ -225,6 +230,7 @@ def compute_actuals_by_category(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -245,6 +251,7 @@ def compute_actuals_by_category(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -265,6 +272,7 @@ def compute_actuals_by_category(
|
|||||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
@@ -306,6 +314,7 @@ def compute_cc_by_category(
|
|||||||
Transaction.date < cc_end,
|
Transaction.date < cc_end,
|
||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -324,6 +333,7 @@ def compute_cc_by_category(
|
|||||||
Transaction.date < prev_end,
|
Transaction.date < prev_end,
|
||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -440,6 +450,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
@@ -455,6 +466,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
@@ -470,6 +482,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
187
backend/app/services/installments.py
Normal file
187
backend/app/services/installments.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""Tasa Cero (BAC zero-interest installment financing) plans.
|
||||||
|
|
||||||
|
A qualifying credit-card purchase ("anchor") is billed by the bank as N equal
|
||||||
|
monthly cuotas instead of one charge. Here the anchor transaction is flagged
|
||||||
|
(`is_installment_anchor`) and excluded from budget aggregates, and N real
|
||||||
|
cuota Transaction rows are generated so they flow through the existing
|
||||||
|
billing-cycle logic — including future-dated cuotas, which intentionally show
|
||||||
|
up in future months' budgets as committed spend.
|
||||||
|
|
||||||
|
Verified BAC mechanics (real Financiamientos data, 2026-05/07):
|
||||||
|
- cuota = total / N truncated to 0.10; the LAST cuota absorbs the remainder
|
||||||
|
(111,053.60/3 -> 37,017.80 x2 + 37,018.00).
|
||||||
|
- Cuotas post one per 18th-cut billing cycle, on the 19th, except the first
|
||||||
|
one whose date varies per plan (purchase date or a later 19th) — hence
|
||||||
|
`first_installment_date` is stored per plan and user-editable.
|
||||||
|
|
||||||
|
Known v1 gaps: paste-imported real "CUOTA:XX/YY" statement lines won't dedup
|
||||||
|
against the synthetic cuotas; editing the anchor's amount does not recompute
|
||||||
|
the plan (edit the plan instead).
|
||||||
|
|
||||||
|
Cascades are handled explicitly here in Python: tests run on SQLite without
|
||||||
|
FK enforcement, and transaction.installment_plan_id has no DB-level FK (it
|
||||||
|
would be circular with installmentplan.anchor_transaction_id).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import ROUND_DOWN, Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.models import (
|
||||||
|
InstallmentPlan,
|
||||||
|
Transaction,
|
||||||
|
TransactionType,
|
||||||
|
)
|
||||||
|
from app.services.budget_projection import get_previous_cycle
|
||||||
|
|
||||||
|
# BAC prefixes Tasa Cero voucher merchants with "CC " (observed with a double
|
||||||
|
# space: "CC CONSTRUPLAZA"). Non-prefixed plans (e.g. ALMACENES SIMAN) are
|
||||||
|
# converted manually via POST /installment-plans/.
|
||||||
|
TASA_CERO_RE = re.compile(r"^CC\s", re.IGNORECASE)
|
||||||
|
|
||||||
|
DEFAULT_NUM_INSTALLMENTS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def is_tasa_cero_merchant(merchant: str) -> bool:
|
||||||
|
return bool(TASA_CERO_RE.match(merchant))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_installment_amounts(total: Decimal, n: int) -> list[Decimal]:
|
||||||
|
"""Split `total` into n cuotas using BAC's rule: truncate to 0.10, last
|
||||||
|
cuota absorbs the remainder. Invariant: sum(result) == total."""
|
||||||
|
per = (total / n).quantize(Decimal("0.1"), rounding=ROUND_DOWN)
|
||||||
|
return [per] * (n - 1) + [total - per * (n - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_months(year: int, month: int, delta: int) -> tuple[int, int]:
|
||||||
|
idx = year * 12 + (month - 1) + delta
|
||||||
|
return idx // 12, idx % 12 + 1
|
||||||
|
|
||||||
|
|
||||||
|
def compute_installment_dates(first: datetime, n: int) -> list[datetime]:
|
||||||
|
"""Cuota 1 = `first` verbatim; cuotas 2..n land on the 19th of consecutive
|
||||||
|
billing cycles (cycle = [18th, next 18th), see get_cycle_range)."""
|
||||||
|
if first.day >= 18:
|
||||||
|
cycle_y, cycle_m = first.year, first.month
|
||||||
|
else:
|
||||||
|
cycle_y, cycle_m = get_previous_cycle(first.year, first.month)
|
||||||
|
dates = [first]
|
||||||
|
for i in range(1, n):
|
||||||
|
y, m = _add_months(cycle_y, cycle_m, i)
|
||||||
|
dates.append(datetime(y, m, 19))
|
||||||
|
return dates
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_cuotas(
|
||||||
|
session: Session, plan: InstallmentPlan, anchor: Transaction
|
||||||
|
) -> None:
|
||||||
|
amounts = compute_installment_amounts(anchor.amount, plan.num_installments)
|
||||||
|
dates = compute_installment_dates(
|
||||||
|
plan.first_installment_date, plan.num_installments
|
||||||
|
)
|
||||||
|
merchant_label = " ".join(anchor.merchant.split()) # collapse "CC X"
|
||||||
|
ref_base = anchor.reference or f"TC{anchor.id}"
|
||||||
|
n = plan.num_installments
|
||||||
|
for i, (amount, when) in enumerate(zip(amounts, dates), start=1):
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
amount=amount,
|
||||||
|
currency=anchor.currency,
|
||||||
|
merchant=f"{merchant_label} CUOTA:{i:02d}/{n:02d}",
|
||||||
|
city=anchor.city,
|
||||||
|
date=when,
|
||||||
|
card_type=anchor.card_type,
|
||||||
|
card_last4=anchor.card_last4,
|
||||||
|
reference=f"{ref_base}-C{i:02d}",
|
||||||
|
transaction_type=TransactionType.COMPRA,
|
||||||
|
source=anchor.source,
|
||||||
|
bank=anchor.bank,
|
||||||
|
notes=f"Tasa Cero plan #{plan.id}",
|
||||||
|
category_id=anchor.category_id,
|
||||||
|
deferred_to_next_cycle=False,
|
||||||
|
installment_plan_id=plan.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_cuotas(session: Session, plan: InstallmentPlan) -> None:
|
||||||
|
cuotas = session.exec(
|
||||||
|
select(Transaction).where(Transaction.installment_plan_id == plan.id)
|
||||||
|
).all()
|
||||||
|
for cuota in cuotas:
|
||||||
|
session.delete(cuota)
|
||||||
|
|
||||||
|
|
||||||
|
def create_plan(
|
||||||
|
session: Session,
|
||||||
|
anchor: Transaction,
|
||||||
|
num_installments: int = DEFAULT_NUM_INSTALLMENTS,
|
||||||
|
first_installment_date: Optional[datetime] = None,
|
||||||
|
) -> InstallmentPlan:
|
||||||
|
"""Convert `anchor` (must be committed, id set) into a Tasa Cero plan and
|
||||||
|
generate its cuota rows. Caller commits."""
|
||||||
|
amounts = compute_installment_amounts(anchor.amount, num_installments)
|
||||||
|
plan = InstallmentPlan(
|
||||||
|
anchor_transaction_id=anchor.id,
|
||||||
|
num_installments=num_installments,
|
||||||
|
first_installment_date=first_installment_date or anchor.date,
|
||||||
|
total_amount=anchor.amount,
|
||||||
|
installment_amount=amounts[0],
|
||||||
|
currency=anchor.currency,
|
||||||
|
)
|
||||||
|
session.add(plan)
|
||||||
|
session.flush() # assign plan.id for the cuota FK/notes
|
||||||
|
anchor.is_installment_anchor = True
|
||||||
|
session.add(anchor)
|
||||||
|
_generate_cuotas(session, plan, anchor)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def regenerate_cuotas(
|
||||||
|
session: Session,
|
||||||
|
plan: InstallmentPlan,
|
||||||
|
anchor: Transaction,
|
||||||
|
num_installments: Optional[int] = None,
|
||||||
|
first_installment_date: Optional[datetime] = None,
|
||||||
|
) -> InstallmentPlan:
|
||||||
|
"""Apply plan edits by delete-and-recreate of the cuota rows. Per-cuota
|
||||||
|
edits (e.g. recategorizations) are lost; category is re-inherited from
|
||||||
|
the anchor. Caller commits."""
|
||||||
|
if num_installments is not None:
|
||||||
|
plan.num_installments = num_installments
|
||||||
|
if first_installment_date is not None:
|
||||||
|
plan.first_installment_date = first_installment_date
|
||||||
|
plan.total_amount = anchor.amount
|
||||||
|
plan.installment_amount = compute_installment_amounts(
|
||||||
|
anchor.amount, plan.num_installments
|
||||||
|
)[0]
|
||||||
|
_delete_cuotas(session, plan)
|
||||||
|
_generate_cuotas(session, plan, anchor)
|
||||||
|
session.add(plan)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def delete_plan(session: Session, plan: InstallmentPlan, anchor: Transaction) -> None:
|
||||||
|
"""Unconvert: remove cuotas + plan, restore the anchor to a normal
|
||||||
|
transaction. Caller commits."""
|
||||||
|
_delete_cuotas(session, plan)
|
||||||
|
anchor.is_installment_anchor = False
|
||||||
|
session.add(anchor)
|
||||||
|
session.delete(plan)
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_plan_for_anchor(session: Session, anchor: Transaction) -> None:
|
||||||
|
"""The anchor itself is being deleted: remove its plan and cuotas."""
|
||||||
|
if not anchor.is_installment_anchor:
|
||||||
|
return
|
||||||
|
plan = session.exec(
|
||||||
|
select(InstallmentPlan).where(
|
||||||
|
InstallmentPlan.anchor_transaction_id == anchor.id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if plan:
|
||||||
|
_delete_cuotas(session, plan)
|
||||||
|
session.delete(plan)
|
||||||
@@ -1,4 +1,55 @@
|
|||||||
from datetime import datetime, timezone
|
import contextvars
|
||||||
|
from datetime import date, datetime, timedelta, timezone, tzinfo
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
# Costa Rica has no DST; a fixed offset is exact year-round. This is the
|
||||||
|
# FALLBACK when the request doesn't declare a timezone (n8n posts, tests).
|
||||||
|
CR_TZ = timezone(timedelta(hours=-6))
|
||||||
|
|
||||||
|
# Per-request client timezone, set by the agent middleware from the
|
||||||
|
# X-Client-Timezone header (IANA name sent by the browser). Falls back to
|
||||||
|
# Costa Rica so headless callers keep the owner's local semantics.
|
||||||
|
_client_tz: contextvars.ContextVar[tzinfo] = contextvars.ContextVar(
|
||||||
|
"client_tz", default=CR_TZ
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_client_timezone(name: str | None) -> contextvars.Token | None:
|
||||||
|
"""Bind the request's IANA timezone; ignores unknown/absent names.
|
||||||
|
|
||||||
|
Proxies that see the header twice merge the values into a comma list
|
||||||
|
("Zone, Zone") — take the first entry.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _client_tz.set(ZoneInfo(name.split(",")[0].strip()))
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_client_timezone(token: contextvars.Token | None) -> None:
|
||||||
|
if token is not None:
|
||||||
|
_client_tz.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def client_tz() -> tzinfo:
|
||||||
|
return _client_tz.get()
|
||||||
|
|
||||||
|
|
||||||
|
def today_client() -> date:
|
||||||
|
"""Current date where the USER is (request timezone, CR fallback).
|
||||||
|
|
||||||
|
The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow"
|
||||||
|
in UTC. Anything user-facing that means "today" (agent answers, date
|
||||||
|
defaults) must come from here, never date.today().
|
||||||
|
"""
|
||||||
|
return datetime.now(client_tz()).date()
|
||||||
|
|
||||||
|
|
||||||
|
def today_cr() -> date:
|
||||||
|
"""Current date in Costa Rica (UTC-6) — timezone-independent fallback."""
|
||||||
|
return datetime.now(CR_TZ).date()
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
|
|||||||
182
backend/constraints.txt
Normal file
182
backend/constraints.txt
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
a2a-sdk==0.3.23
|
||||||
|
ag-ui-protocol==0.1.19
|
||||||
|
agent-framework==1.11.0
|
||||||
|
agent-framework-a2a==1.0.0b260428
|
||||||
|
agent-framework-ag-ui==1.0.0rc8
|
||||||
|
agent-framework-anthropic==1.0.0b260428
|
||||||
|
agent-framework-azure-ai-search==1.0.0b260428
|
||||||
|
agent-framework-azure-cosmos==1.0.0b260428
|
||||||
|
agent-framework-azurefunctions==1.0.0b260428
|
||||||
|
agent-framework-bedrock==1.0.0b260428
|
||||||
|
agent-framework-chatkit==1.0.0b260428
|
||||||
|
agent-framework-claude==1.0.0b260428
|
||||||
|
agent-framework-copilotstudio==1.0.0b260428
|
||||||
|
agent-framework-core==1.11.0
|
||||||
|
agent-framework-declarative==1.0.0b260428
|
||||||
|
agent-framework-devui==1.0.0b260428
|
||||||
|
agent-framework-durabletask==1.0.0b260428
|
||||||
|
agent-framework-foundry==1.2.1
|
||||||
|
agent-framework-foundry-local==1.0.0b260428
|
||||||
|
agent-framework-github-copilot==1.0.0b260402
|
||||||
|
agent-framework-lab==1.0.0b251024
|
||||||
|
agent-framework-mem0==1.0.0b260428
|
||||||
|
agent-framework-ollama==1.0.0b260428
|
||||||
|
agent-framework-openai==1.10.1
|
||||||
|
agent-framework-orchestrations==1.0.0b260428
|
||||||
|
agent-framework-purview==1.0.0b260428
|
||||||
|
agent-framework-redis==1.0.0b260428
|
||||||
|
aiohappyeyeballs==2.6.2
|
||||||
|
aiohttp==3.14.1
|
||||||
|
aiosignal==1.4.0
|
||||||
|
alembic==1.18.4
|
||||||
|
annotated-doc==0.0.4
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anthropic==0.80.0
|
||||||
|
anyio==4.13.0
|
||||||
|
async-timeout==5.0.1
|
||||||
|
asyncio==4.0.0
|
||||||
|
attrs==26.1.0
|
||||||
|
azure-ai-inference==1.0.0b9
|
||||||
|
azure-ai-projects==2.2.0
|
||||||
|
azure-common==1.1.28
|
||||||
|
azure-core==1.41.0
|
||||||
|
azure-cosmos==4.16.1
|
||||||
|
azure-functions==1.24.0
|
||||||
|
azure-functions-durable==1.5.0
|
||||||
|
azure-identity==1.25.3
|
||||||
|
azure-search-documents==11.7.0b2
|
||||||
|
azure-storage-blob==12.30.0
|
||||||
|
backoff==2.2.1
|
||||||
|
bcrypt==5.0.0
|
||||||
|
boto3==1.43.26
|
||||||
|
botocore==1.43.26
|
||||||
|
certifi==2026.5.20
|
||||||
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
claude-agent-sdk==0.1.48
|
||||||
|
click==8.4.1
|
||||||
|
clr_loader==0.2.10
|
||||||
|
cryptography==48.0.1
|
||||||
|
detect-installer==0.1.0
|
||||||
|
distro==1.9.0
|
||||||
|
dnspython==2.8.0
|
||||||
|
docstring_parser==0.18.0
|
||||||
|
durabletask==1.5.0
|
||||||
|
durabletask.azuremanaged==1.5.0
|
||||||
|
ecdsa==0.19.2
|
||||||
|
email-validator==2.3.0
|
||||||
|
fastapi==0.133.0
|
||||||
|
fastapi-cli==0.0.24
|
||||||
|
fastapi-cloud-cli==0.19.0
|
||||||
|
fastar==0.11.0
|
||||||
|
foundry-local-sdk==0.5.1
|
||||||
|
frozenlist==1.8.0
|
||||||
|
furl==2.1.4
|
||||||
|
github-copilot-sdk==0.1.32
|
||||||
|
google-api-core==2.31.0
|
||||||
|
google-auth==2.53.0
|
||||||
|
googleapis-common-protos==1.75.0
|
||||||
|
griffelib==2.0.2
|
||||||
|
grpcio==1.81.0
|
||||||
|
h11==0.16.0
|
||||||
|
h2==4.3.0
|
||||||
|
hpack==4.1.0
|
||||||
|
http-ece==1.2.1
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.8.0
|
||||||
|
httpx==0.28.1
|
||||||
|
httpx-sse==0.4.3
|
||||||
|
hyperframe==6.1.0
|
||||||
|
idna==3.18
|
||||||
|
iniconfig==2.3.0
|
||||||
|
isodate==0.7.2
|
||||||
|
Jinja2==3.1.6
|
||||||
|
jiter==0.15.0
|
||||||
|
jmespath==1.1.0
|
||||||
|
jsonpath-ng==1.8.0
|
||||||
|
jsonschema==4.26.0
|
||||||
|
jsonschema-specifications==2025.9.1
|
||||||
|
Mako==1.3.12
|
||||||
|
markdown-it-py==4.2.0
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
mcp==1.27.2
|
||||||
|
mdurl==0.1.2
|
||||||
|
mem0ai==1.0.11
|
||||||
|
microsoft-agents-activity==0.3.1
|
||||||
|
microsoft-agents-copilotstudio-client==0.3.1
|
||||||
|
microsoft-agents-hosting-core==0.3.1
|
||||||
|
ml_dtypes==0.5.4
|
||||||
|
msal==1.37.0
|
||||||
|
msal-extensions==1.3.1
|
||||||
|
multidict==6.7.1
|
||||||
|
numpy==2.4.6
|
||||||
|
ollama==0.5.3
|
||||||
|
openai==2.41.0
|
||||||
|
openai-agents==0.17.4
|
||||||
|
openai-chatkit==1.6.5
|
||||||
|
opentelemetry-api==1.42.1
|
||||||
|
opentelemetry-sdk==1.42.1
|
||||||
|
opentelemetry-semantic-conventions==0.63b1
|
||||||
|
orderedmultidict==1.0.2
|
||||||
|
packaging==26.2
|
||||||
|
passlib==1.7.4
|
||||||
|
pluggy==1.6.0
|
||||||
|
portalocker==3.2.0
|
||||||
|
posthog==7.18.0
|
||||||
|
powerfx==0.0.34
|
||||||
|
propcache==0.5.2
|
||||||
|
proto-plus==1.28.0
|
||||||
|
protobuf==6.33.6
|
||||||
|
psycopg2-binary==2.9.12
|
||||||
|
py-vapid==1.9.4
|
||||||
|
pyasn1==0.6.3
|
||||||
|
pyasn1_modules==0.4.2
|
||||||
|
pycparser==3.0
|
||||||
|
pydantic==2.13.4
|
||||||
|
pydantic-extra-types==2.11.1
|
||||||
|
pydantic-settings==2.14.1
|
||||||
|
pydantic_core==2.46.4
|
||||||
|
Pygments==2.20.0
|
||||||
|
PyJWT==2.13.0
|
||||||
|
pytest==9.0.3
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-jose==3.5.0
|
||||||
|
python-multipart==0.0.32
|
||||||
|
python-ulid==3.1.0
|
||||||
|
pythonnet==3.0.5
|
||||||
|
pytz==2026.2
|
||||||
|
pywebpush==2.3.0
|
||||||
|
PyYAML==6.0.3
|
||||||
|
qdrant-client==1.18.0
|
||||||
|
redis==7.1.1
|
||||||
|
redisvl==0.15.0
|
||||||
|
referencing==0.37.0
|
||||||
|
requests==2.34.2
|
||||||
|
rich==15.0.0
|
||||||
|
rich-toolkit==0.20.1
|
||||||
|
rignore==0.7.6
|
||||||
|
rpds-py==2026.5.1
|
||||||
|
rsa==4.9.1
|
||||||
|
s3transfer==0.18.0
|
||||||
|
sentry-sdk==2.62.0
|
||||||
|
shellingham==1.5.4
|
||||||
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
SQLAlchemy==2.0.50
|
||||||
|
sqlmodel==0.0.38
|
||||||
|
sse-starlette==3.4.5
|
||||||
|
starlette==1.2.1
|
||||||
|
tenacity==9.1.4
|
||||||
|
tqdm==4.68.2
|
||||||
|
typer==0.26.7
|
||||||
|
types-requests==2.33.0.20260518
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.7.0
|
||||||
|
uvicorn==0.41.0
|
||||||
|
uvloop==0.22.1
|
||||||
|
watchfiles==1.2.0
|
||||||
|
websockets==16.0
|
||||||
|
Werkzeug==3.1.8
|
||||||
|
yarl==1.24.2
|
||||||
@@ -11,6 +11,6 @@ httpx
|
|||||||
pywebpush
|
pywebpush
|
||||||
py-vapid
|
py-vapid
|
||||||
python-dateutil
|
python-dateutil
|
||||||
agent-framework==1.2.1
|
agent-framework==1.11.0
|
||||||
agent-framework-ag-ui==1.0.0b260428
|
agent-framework-ag-ui==1.0.0rc8
|
||||||
agent-framework-openai==1.2.1
|
agent-framework-openai==1.10.1
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ os.environ.setdefault(
|
|||||||
os.environ.setdefault("ADMIN_USERNAME", "testadmin")
|
os.environ.setdefault("ADMIN_USERNAME", "testadmin")
|
||||||
os.environ.setdefault("ADMIN_PASSWORD", "test-password")
|
os.environ.setdefault("ADMIN_PASSWORD", "test-password")
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||||
|
# app.main builds the MAF agent at import; the OpenAI client only needs a
|
||||||
|
# syntactically-present key.
|
||||||
|
os.environ.setdefault("OPENAI_API_KEY", "test-dummy-key")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|||||||
14
backend/tests/test_agent_config.py
Normal file
14
backend/tests/test_agent_config.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Assistant transport configuration."""
|
||||||
|
|
||||||
|
from agent_framework.openai import OpenAIChatClient
|
||||||
|
|
||||||
|
from app.agent import agent as agent_module
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_uses_responses_client_for_the_configured_model(monkeypatch):
|
||||||
|
monkeypatch.setattr(agent_module.settings, "AGENT_MODEL", "gpt-5.6-luna")
|
||||||
|
|
||||||
|
agent = agent_module.build_agent()
|
||||||
|
|
||||||
|
assert isinstance(agent.client, OpenAIChatClient)
|
||||||
|
assert agent.client.model == "gpt-5.6-luna"
|
||||||
@@ -17,6 +17,7 @@ from app.models.models import (
|
|||||||
MunicipalReceipt,
|
MunicipalReceipt,
|
||||||
PensionSnapshot,
|
PensionSnapshot,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TransactionType,
|
||||||
WaterMeterReading,
|
WaterMeterReading,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,3 +150,137 @@ def test_recent_transactions_json_safe(bound_session):
|
|||||||
out = tools.get_recent_transactions()
|
out = tools.get_recent_transactions()
|
||||||
json.dumps(out)
|
json.dumps(out)
|
||||||
assert out[0]["amount"] == 42.42
|
assert out[0]["amount"] == 42.42
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_transactions_exclude_future_cuotas(bound_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.timeutil import today_cr
|
||||||
|
|
||||||
|
today = datetime.combine(today_cr(), datetime.min.time())
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(amount=Decimal("10"), merchant="BILLED", date=today)
|
||||||
|
)
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("20"),
|
||||||
|
merchant="FUTURE CUOTA:03/06",
|
||||||
|
date=today + timedelta(days=45),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bound_session.commit()
|
||||||
|
merchants = [t["merchant"] for t in tools.get_recent_transactions()]
|
||||||
|
assert "BILLED" in merchants
|
||||||
|
assert "FUTURE CUOTA:03/06" not in merchants
|
||||||
|
|
||||||
|
|
||||||
|
def test_salary_deposits_returns_individual_filtered_rows(bound_session):
|
||||||
|
bound_session.add_all(
|
||||||
|
[
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("1500"),
|
||||||
|
merchant="SALARY JULY",
|
||||||
|
date=datetime(2026, 7, 14, 9),
|
||||||
|
transaction_type=TransactionType.SALARY,
|
||||||
|
reference="PAY-2026-07",
|
||||||
|
),
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("1400"),
|
||||||
|
merchant="SALARY JUNE",
|
||||||
|
date=datetime(2026, 6, 14, 9),
|
||||||
|
transaction_type=TransactionType.SALARY,
|
||||||
|
),
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("999"),
|
||||||
|
merchant="PURCHASE",
|
||||||
|
date=datetime(2026, 7, 14, 10),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
bound_session.commit()
|
||||||
|
|
||||||
|
out = tools.get_salary_deposits(
|
||||||
|
start_date="2026-07-14", end_date="2026-07-15"
|
||||||
|
)
|
||||||
|
|
||||||
|
json.dumps(out)
|
||||||
|
assert out == [
|
||||||
|
{
|
||||||
|
"id": out[0]["id"],
|
||||||
|
"date": "2026-07-14T09:00:00",
|
||||||
|
"amount": 1500.0,
|
||||||
|
"currency": "CRC",
|
||||||
|
"merchant": "SALARY JULY",
|
||||||
|
"source": "CREDIT_CARD",
|
||||||
|
"bank": "BAC",
|
||||||
|
"transaction_type": "SALARY",
|
||||||
|
"reference": "PAY-2026-07",
|
||||||
|
"notes": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_current_date_is_costa_rica_and_cycle_consistent():
|
||||||
|
from app.timeutil import today_cr
|
||||||
|
|
||||||
|
out = tools.get_current_date()
|
||||||
|
today = today_cr()
|
||||||
|
assert out["date"] == today.isoformat()
|
||||||
|
# The active cycle must contain today: [start, end)
|
||||||
|
start, end = out["cycle_range"]
|
||||||
|
assert start <= out["date"] < end
|
||||||
|
assert start.endswith("-18") and end.endswith("-18")
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_spending_day_scoped(bound_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.timeutil import today_cr
|
||||||
|
|
||||||
|
today = datetime.combine(today_cr(), datetime.min.time())
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(amount=Decimal("100"), merchant="TODAY-A", date=today.replace(hour=9))
|
||||||
|
)
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(amount=Decimal("50"), merchant="TODAY-B", date=today.replace(hour=15))
|
||||||
|
)
|
||||||
|
bound_session.add(
|
||||||
|
Transaction(
|
||||||
|
amount=Decimal("999"),
|
||||||
|
merchant="FUTURE CUOTA",
|
||||||
|
date=today + timedelta(days=40),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bound_session.commit()
|
||||||
|
|
||||||
|
out = tools.get_daily_spending(
|
||||||
|
start_date=today.date().isoformat(),
|
||||||
|
end_date=(today.date() + timedelta(days=60)).isoformat(),
|
||||||
|
)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["date"] == today.date().isoformat()
|
||||||
|
assert out[0]["total_crc"] == 150.0
|
||||||
|
assert out[0]["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_current_date_respects_client_timezone():
|
||||||
|
from datetime import datetime, timezone as tz
|
||||||
|
|
||||||
|
from app.timeutil import (
|
||||||
|
client_tz,
|
||||||
|
reset_client_timezone,
|
||||||
|
set_client_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = set_client_timezone("Asia/Tokyo")
|
||||||
|
try:
|
||||||
|
out = tools.get_current_date()
|
||||||
|
assert out["timezone"] == "Asia/Tokyo"
|
||||||
|
assert out["date"] == datetime.now(client_tz()).date().isoformat()
|
||||||
|
finally:
|
||||||
|
reset_client_timezone(token)
|
||||||
|
|
||||||
|
# Unknown names are ignored — CR fallback stays active.
|
||||||
|
assert set_client_timezone("Not/AZone") is None
|
||||||
|
assert set_client_timezone(None) is None
|
||||||
|
assert tools.get_current_date()["timezone"] == "UTC-06:00"
|
||||||
|
|||||||
62
backend/tests/test_analytics.py
Normal file
62
backend/tests/test_analytics.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Analytics endpoints: category aggregation (Decimal regression) and the
|
||||||
|
arbitrary date-range filters added for the Analytics range picker."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.analytics import daily_spending, spending_by_category
|
||||||
|
from app.models.models import Category, Transaction
|
||||||
|
|
||||||
|
from tests.test_installments import make_cc_tx
|
||||||
|
|
||||||
|
|
||||||
|
def seed(session):
|
||||||
|
cat = Category(name="Groceries")
|
||||||
|
session.add(cat)
|
||||||
|
session.commit()
|
||||||
|
make_cc_tx(session, merchant="A", amount="1000.50", reference="a1",
|
||||||
|
date=datetime(2026, 6, 2), category_id=cat.id)
|
||||||
|
make_cc_tx(session, merchant="B", amount="2000.00", reference="b1",
|
||||||
|
date=datetime(2026, 6, 20))
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpendingByCategory:
|
||||||
|
def test_decimal_sum_does_not_crash_and_percentages_add_up(self, session):
|
||||||
|
"""Regression: Decimal grand total vs float row total raised
|
||||||
|
TypeError, 500ing the endpoint whenever it had data."""
|
||||||
|
seed(session)
|
||||||
|
rows = spending_by_category(session=session, _user="t")
|
||||||
|
assert {r.category_name for r in rows} == {"Groceries", "Uncategorized"}
|
||||||
|
assert sum(r.percentage for r in rows) == 100.0
|
||||||
|
|
||||||
|
def test_date_range_overrides_cycle(self, session):
|
||||||
|
seed(session)
|
||||||
|
rows = spending_by_category(
|
||||||
|
start_date="2026-06-01",
|
||||||
|
end_date="2026-06-10",
|
||||||
|
cycle_year=2026,
|
||||||
|
cycle_month=1, # would match nothing; range must win
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].category_name == "Groceries"
|
||||||
|
assert rows[0].total == 1000.50
|
||||||
|
|
||||||
|
|
||||||
|
class TestDailySpending:
|
||||||
|
def test_date_range_filter(self, session):
|
||||||
|
seed(session)
|
||||||
|
rows = daily_spending(
|
||||||
|
start_date="2026-06-15", end_date="2026-07-01",
|
||||||
|
session=session, _user="t",
|
||||||
|
)
|
||||||
|
assert [r.date for r in rows] == ["2026-06-20"]
|
||||||
|
assert rows[0].total == 2000.00
|
||||||
|
|
||||||
|
def test_future_cuotas_excluded(self, session):
|
||||||
|
make_cc_tx(session, merchant="FUTURE", amount="500.00",
|
||||||
|
reference="f1", date=datetime(2030, 1, 19))
|
||||||
|
rows = daily_spending(session=session, _user="t")
|
||||||
|
assert rows == []
|
||||||
139
backend/tests/test_chat_audit.py
Normal file
139
backend/tests/test_chat_audit.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Chat-run audit persistence and redacted HTTP access logs."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
import app.agent.chat_audit as chat_audit
|
||||||
|
import app.agent.snapshot_store as snapshot_store_module
|
||||||
|
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||||
|
from app.models.models import ChatRunLog
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_store(monkeypatch, engine):
|
||||||
|
monkeypatch.setattr(chat_audit, "engine", engine)
|
||||||
|
monkeypatch.setattr(snapshot_store_module, "engine", engine)
|
||||||
|
return PostgresSnapshotStore()
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_completes_chat_run_with_tools_and_response(monkeypatch, engine, session):
|
||||||
|
store = _audit_store(monkeypatch, engine)
|
||||||
|
run_id = chat_audit.create_chat_run(
|
||||||
|
request_id="req-1",
|
||||||
|
agui_run_id="run-1",
|
||||||
|
thread_id="main",
|
||||||
|
messages=[{"role": "user", "content": "¿Cuál es mi saldo?"}],
|
||||||
|
client_ip="203.0.113.7",
|
||||||
|
user_agent="Mozilla/5.0 TestBrowser/1.0",
|
||||||
|
browser="TestBrowser",
|
||||||
|
)
|
||||||
|
token = chat_audit.set_current_chat_run(run_id)
|
||||||
|
try:
|
||||||
|
asyncio.run(
|
||||||
|
store.save(
|
||||||
|
scope="default",
|
||||||
|
thread_id="resp_123",
|
||||||
|
snapshot=AGUIThreadSnapshot(
|
||||||
|
messages=[
|
||||||
|
{"id": "u1", "role": "user", "content": "¿Cuál es mi saldo?"},
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "get_net_worth", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"id": "t1", "role": "tool", "tool_call_id": "call-1", "content": "{\"net_crc\": 1}"},
|
||||||
|
{"id": "a2", "role": "assistant", "content": "Tu saldo es ₡1."},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
chat_audit.reset_current_chat_run(token)
|
||||||
|
|
||||||
|
row = session.get(ChatRunLog, run_id)
|
||||||
|
assert row is not None
|
||||||
|
assert row.status == "completed"
|
||||||
|
assert row.agui_run_id == "run-1"
|
||||||
|
assert row.response_thread_id == "resp_123"
|
||||||
|
assert row.user_prompt == "¿Cuál es mi saldo?"
|
||||||
|
assert row.client_ip == "203.0.113.7"
|
||||||
|
assert row.user_agent == "Mozilla/5.0 TestBrowser/1.0"
|
||||||
|
assert row.browser == "TestBrowser"
|
||||||
|
assert row.assistant_response == "Tu saldo es ₡1."
|
||||||
|
assert row.tool_calls == [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"name": "get_net_worth",
|
||||||
|
"arguments": "{}",
|
||||||
|
"result": '{"net_crc": 1}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_and_retention_cleanup(monkeypatch, engine, session):
|
||||||
|
_audit_store(monkeypatch, engine)
|
||||||
|
failed_id = chat_audit.create_chat_run(
|
||||||
|
request_id="req-failed", agui_run_id="run-failed", thread_id="main", messages=[]
|
||||||
|
)
|
||||||
|
chat_audit.mark_chat_run_failed(failed_id, "RuntimeError: provider unavailable")
|
||||||
|
assert session.get(ChatRunLog, failed_id).status == "failed"
|
||||||
|
|
||||||
|
expired = ChatRunLog(
|
||||||
|
request_id="req-expired",
|
||||||
|
model="gpt-5.6-luna",
|
||||||
|
expires_at=utcnow() - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
session.add(expired)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert chat_audit.purge_expired_chat_runs() == 1
|
||||||
|
assert session.exec(select(ChatRunLog).where(ChatRunLog.request_id == "req-expired")).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_access_log_never_contains_request_body(monkeypatch):
|
||||||
|
from app.main import log_api_request
|
||||||
|
|
||||||
|
logged = []
|
||||||
|
monkeypatch.setattr("app.main.log_event", lambda event, **fields: logged.append((event, fields)))
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/auth/login",
|
||||||
|
"raw_path": b"/api/auth/login",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [
|
||||||
|
(b"x-real-ip", b"203.0.113.9"),
|
||||||
|
(b"user-agent", b"Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"),
|
||||||
|
],
|
||||||
|
"scheme": "http",
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
"client": ("testclient", 1234),
|
||||||
|
}
|
||||||
|
request = Request(scope)
|
||||||
|
|
||||||
|
async def call_next(_request):
|
||||||
|
return Response(status_code=201)
|
||||||
|
|
||||||
|
response = asyncio.run(log_api_request(request, call_next))
|
||||||
|
assert response.headers["X-Request-ID"]
|
||||||
|
assert logged[0][0] == "api_request"
|
||||||
|
fields = logged[0][1]
|
||||||
|
assert fields["path"] == "/api/auth/login"
|
||||||
|
assert fields["status_code"] == 201
|
||||||
|
assert fields["client_ip"] == "203.0.113.9"
|
||||||
|
assert fields["browser"] == "Chrome"
|
||||||
|
assert fields["user_agent"] == "Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"
|
||||||
|
assert "password" not in fields
|
||||||
|
assert "body" not in fields
|
||||||
167
backend/tests/test_chat_persistence.py
Normal file
167
backend/tests/test_chat_persistence.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Chat thread persistence: snapshot store round-trip + clear endpoint."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
import app.agent.snapshot_store as snapshot_store_module
|
||||||
|
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||||
|
from app.api.v1.endpoints.chat import clear_thread
|
||||||
|
from app.models.models import ChatThreadSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _store(monkeypatch, engine):
|
||||||
|
# The store opens its own sessions from app.db.engine; point it at the
|
||||||
|
# test engine instead.
|
||||||
|
monkeypatch.setattr(snapshot_store_module, "engine", engine)
|
||||||
|
return PostgresSnapshotStore()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_get_roundtrip_normalizes_and_filters(monkeypatch, engine):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
snapshot = AGUIThreadSnapshot(
|
||||||
|
messages=[
|
||||||
|
{"id": "u1", "role": "user", "content": "hola"},
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
# MAF emits snake_case; the store must return camelCase so
|
||||||
|
# @ag-ui/client's Zod schema doesn't strip it.
|
||||||
|
"tool_calls": [{"id": "t1", "type": "function"}],
|
||||||
|
},
|
||||||
|
{"id": "t1r", "role": "tool", "content": "ok", "tool_call_id": "t1"},
|
||||||
|
# MAF writes tool_calls: null on plain-text assistant messages —
|
||||||
|
# must not survive as toolCalls: null in the replay.
|
||||||
|
{"id": "a2", "role": "assistant", "content": "done", "tool_calls": None},
|
||||||
|
{"id": "ctx-123", "role": "system", "content": "run context"},
|
||||||
|
],
|
||||||
|
state={"k": "v"},
|
||||||
|
)
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
|
||||||
|
|
||||||
|
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
|
||||||
|
assert loaded is not None
|
||||||
|
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1r", "a2"]
|
||||||
|
assistant = loaded.messages[1]
|
||||||
|
assert "tool_calls" not in assistant
|
||||||
|
assert assistant["toolCalls"] == [{"id": "t1", "type": "function"}]
|
||||||
|
tool_msg = loaded.messages[2]
|
||||||
|
assert "tool_call_id" not in tool_msg
|
||||||
|
assert tool_msg["toolCallId"] == "t1"
|
||||||
|
text_msg = loaded.messages[3]
|
||||||
|
assert "toolCalls" not in text_msg and "tool_calls" not in text_msg
|
||||||
|
assert loaded.state == {"k": "v"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_upserts_single_row(monkeypatch, engine, session):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
for n in range(3):
|
||||||
|
snap = AGUIThreadSnapshot(
|
||||||
|
messages=[{"id": f"u{i}", "role": "user", "content": str(i)} for i in range(n + 1)]
|
||||||
|
)
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||||
|
|
||||||
|
rows = session.exec(select(ChatThreadSnapshot)).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert len(rows[0].messages) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unknown_thread_returns_none(monkeypatch, engine):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
assert asyncio.run(store.get(scope="default", thread_id="nope")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_and_scope_isolation(monkeypatch, engine):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||||
|
|
||||||
|
assert asyncio.run(store.get(scope="other", thread_id="main")) is None
|
||||||
|
assert asyncio.run(store.delete(scope="default", thread_id="main")) is True
|
||||||
|
assert asyncio.run(store.delete(scope="default", thread_id="main")) is False
|
||||||
|
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_dedupes_multi_run_turn(monkeypatch, engine):
|
||||||
|
"""Mirror of MAF's duplicate merge on frontend-tool roundtrips: repeated
|
||||||
|
message ids, a re-issued identical render call, and extra tool results."""
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
|
||||||
|
def call(cid, name, args='{"a":1}'):
|
||||||
|
return {"id": cid, "type": "function", "function": {"name": name, "arguments": args}}
|
||||||
|
|
||||||
|
snapshot = AGUIThreadSnapshot(
|
||||||
|
messages=[
|
||||||
|
{"id": "u1", "role": "user", "content": "resumen"},
|
||||||
|
{"id": "a1", "role": "assistant", "content": "",
|
||||||
|
"tool_calls": [call("c1", "get_cycle_summary"), call("c2", "render_spending_summary")]},
|
||||||
|
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
|
||||||
|
# MAF re-records the resent suffix verbatim…
|
||||||
|
{"id": "u1", "role": "user", "content": "resumen"},
|
||||||
|
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
|
||||||
|
{"id": "t2", "role": "tool", "content": "ok", "tool_call_id": "c2"},
|
||||||
|
# …plus a synthetic second result for an answered call…
|
||||||
|
{"id": "t3", "role": "tool", "content": "", "tool_call_id": "c1"},
|
||||||
|
# …and the model re-issues the same render call with a new id.
|
||||||
|
{"id": "a2", "role": "assistant", "content": "",
|
||||||
|
"tool_calls": [call("c3", "render_spending_summary")]},
|
||||||
|
{"id": "t4", "role": "tool", "content": "ok", "tool_call_id": "c3"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
|
||||||
|
|
||||||
|
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
|
||||||
|
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1", "t2"]
|
||||||
|
# The duplicate render call (c3) and its result are gone; each surviving
|
||||||
|
# call has exactly one result.
|
||||||
|
assert [tc["id"] for tc in loaded.messages[1]["toolCalls"]] == ["c1", "c2"]
|
||||||
|
assert [(m["toolCallId"]) for m in loaded.messages if m["role"] == "tool"] == ["c1", "c2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_thread_endpoint(monkeypatch, engine, session):
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
assert clear_thread(session=session, _user="testadmin") == {"cleared": False}
|
||||||
|
|
||||||
|
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
|
||||||
|
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||||
|
|
||||||
|
assert clear_thread(session=session, _user="testadmin") == {"cleared": True}
|
||||||
|
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_stale_client_history(session, monkeypatch, engine):
|
||||||
|
from app.main import _drop_stale_client_history
|
||||||
|
|
||||||
|
history = [
|
||||||
|
{"role": "user", "content": "vieja pregunta"},
|
||||||
|
{"role": "assistant", "content": "vieja respuesta"},
|
||||||
|
{"role": "tool", "content": "x", "toolCallId": "t1"},
|
||||||
|
{"role": "user", "content": "nueva pregunta"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# No stored snapshot for the thread → stale client: keep last user turn.
|
||||||
|
assert _drop_stale_client_history(session, "main", list(history)) == [
|
||||||
|
{"role": "user", "content": "nueva pregunta"}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fresh conversation (no assistant/tool yet) passes through.
|
||||||
|
fresh = [{"role": "user", "content": "hola"}]
|
||||||
|
assert _drop_stale_client_history(session, "main", list(fresh)) == fresh
|
||||||
|
|
||||||
|
# Empty messages (hydration trigger) pass through.
|
||||||
|
assert _drop_stale_client_history(session, "main", []) == []
|
||||||
|
|
||||||
|
# With a stored snapshot the full history is legitimate.
|
||||||
|
store = _store(monkeypatch, engine)
|
||||||
|
asyncio.run(
|
||||||
|
store.save(
|
||||||
|
scope="default",
|
||||||
|
thread_id="main",
|
||||||
|
snapshot=AGUIThreadSnapshot(
|
||||||
|
messages=[{"id": "u1", "role": "user", "content": "hola"}]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert _drop_stale_client_history(session, "main", list(history)) == history
|
||||||
385
backend/tests/test_installments.py
Normal file
385
backend/tests/test_installments.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
"""Tasa Cero installment plans: rounding, scheduling, budget exclusion,
|
||||||
|
auto-detection at ingestion, regeneration, unconvert, and cascade behavior.
|
||||||
|
|
||||||
|
Rounding and scheduling cases are pinned against real BAC Financiamientos
|
||||||
|
data (2026-05/07). Endpoint functions are exercised directly with a session,
|
||||||
|
same style as test_import_review_and_bulk.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.installments import (
|
||||||
|
create_installment_plan,
|
||||||
|
delete_installment_plan,
|
||||||
|
get_installment_plan,
|
||||||
|
list_installment_plans,
|
||||||
|
update_installment_plan,
|
||||||
|
)
|
||||||
|
from app.api.v1.endpoints.transactions import (
|
||||||
|
BulkActionRequest,
|
||||||
|
bulk_action,
|
||||||
|
create_transaction,
|
||||||
|
delete_transaction,
|
||||||
|
update_transaction,
|
||||||
|
)
|
||||||
|
from app.models.models import (
|
||||||
|
Category,
|
||||||
|
InstallmentPlan,
|
||||||
|
InstallmentPlanCreate,
|
||||||
|
InstallmentPlanUpdate,
|
||||||
|
Transaction,
|
||||||
|
TransactionCreate,
|
||||||
|
TransactionSource,
|
||||||
|
TransactionType,
|
||||||
|
TransactionUpdate,
|
||||||
|
)
|
||||||
|
from app.services.budget_projection import (
|
||||||
|
compute_actuals_by_source,
|
||||||
|
get_cycle_range,
|
||||||
|
)
|
||||||
|
from app.services.installments import (
|
||||||
|
compute_installment_amounts,
|
||||||
|
compute_installment_dates,
|
||||||
|
is_tasa_cero_merchant,
|
||||||
|
)
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
|
||||||
|
def make_cc_tx(
|
||||||
|
session,
|
||||||
|
merchant="CC CONSTRUPLAZA",
|
||||||
|
amount="111053.60",
|
||||||
|
date=datetime(2026, 6, 25, 15, 43),
|
||||||
|
reference="75669363",
|
||||||
|
source=TransactionSource.CREDIT_CARD,
|
||||||
|
**kw,
|
||||||
|
):
|
||||||
|
tx = Transaction(
|
||||||
|
amount=Decimal(amount),
|
||||||
|
merchant=merchant,
|
||||||
|
date=date,
|
||||||
|
reference=reference,
|
||||||
|
source=source,
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
session.add(tx)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(tx)
|
||||||
|
return tx
|
||||||
|
|
||||||
|
|
||||||
|
def convert(session, tx, n=3, first=None):
|
||||||
|
return create_installment_plan(
|
||||||
|
InstallmentPlanCreate(
|
||||||
|
transaction_id=tx.id, num_installments=n, first_installment_date=first
|
||||||
|
),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cuotas(session, plan_id):
|
||||||
|
return session.exec(
|
||||||
|
select(Transaction)
|
||||||
|
.where(Transaction.installment_plan_id == plan_id)
|
||||||
|
.order_by(Transaction.date)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRounding:
|
||||||
|
"""Pinned against real BAC plans."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"total,n,per,last",
|
||||||
|
[
|
||||||
|
("111053.60", 3, "37017.80", "37018.00"),
|
||||||
|
("425800.00", 6, "70966.60", "70967.00"),
|
||||||
|
("98632.00", 3, "32877.30", "32877.40"),
|
||||||
|
("190864.80", 3, "63621.60", "63621.60"), # divides exactly
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_real_bac_plans(self, total, n, per, last):
|
||||||
|
amounts = compute_installment_amounts(Decimal(total), n)
|
||||||
|
assert amounts[:-1] == [Decimal(per)] * (n - 1)
|
||||||
|
assert amounts[-1] == Decimal(last)
|
||||||
|
|
||||||
|
def test_sum_invariant(self):
|
||||||
|
for total, n in [("100.00", 3), ("214800.00", 3), ("99999.99", 7)]:
|
||||||
|
amounts = compute_installment_amounts(Decimal(total), n)
|
||||||
|
assert sum(amounts) == Decimal(total)
|
||||||
|
assert len(amounts) == n
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduling:
|
||||||
|
def test_first_before_cut_stays_in_purchase_cycle(self):
|
||||||
|
# 14/06 purchase (cycle 18/05-18/06) -> cuotas 19/06, 19/07
|
||||||
|
dates = compute_installment_dates(datetime(2026, 6, 14, 19, 22), 3)
|
||||||
|
assert dates == [
|
||||||
|
datetime(2026, 6, 14, 19, 22),
|
||||||
|
datetime(2026, 6, 19),
|
||||||
|
datetime(2026, 7, 19),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_first_after_cut(self):
|
||||||
|
# 27/06 purchase (cycle 18/06-18/07) -> cuotas 19/07, 19/08
|
||||||
|
dates = compute_installment_dates(datetime(2026, 6, 27, 19, 43), 3)
|
||||||
|
assert dates == [
|
||||||
|
datetime(2026, 6, 27, 19, 43),
|
||||||
|
datetime(2026, 7, 19),
|
||||||
|
datetime(2026, 8, 19),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_december_wrap(self):
|
||||||
|
dates = compute_installment_dates(datetime(2026, 12, 20), 3)
|
||||||
|
assert dates == [
|
||||||
|
datetime(2026, 12, 20),
|
||||||
|
datetime(2027, 1, 19),
|
||||||
|
datetime(2027, 2, 19),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_consecutive_cycles(self):
|
||||||
|
"""Each cuota lands in its own consecutive billing cycle."""
|
||||||
|
dates = compute_installment_dates(datetime(2026, 5, 5, 11, 19), 6)
|
||||||
|
cycles = []
|
||||||
|
for d in dates:
|
||||||
|
if d.day >= 18:
|
||||||
|
cy, cm = d.year, d.month
|
||||||
|
else:
|
||||||
|
cy, cm = (d.year, d.month - 1) if d.month > 1 else (d.year - 1, 12)
|
||||||
|
start, end = get_cycle_range(cy, cm)
|
||||||
|
assert start <= d < end
|
||||||
|
cycles.append((cy, cm))
|
||||||
|
assert cycles == [(2026, m) for m in range(4, 10)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetection:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"merchant,expected",
|
||||||
|
[
|
||||||
|
("CC CONSTRUPLAZA", True),
|
||||||
|
("CC TILO.CO", True),
|
||||||
|
("CONSTRUPLAZA S", False),
|
||||||
|
("ALMACENES SIMAN", False),
|
||||||
|
("MERCCADO", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_merchant_regex(self, merchant, expected):
|
||||||
|
assert is_tasa_cero_merchant(merchant) is expected
|
||||||
|
|
||||||
|
def test_ingestion_auto_creates_plan(self, session):
|
||||||
|
tx = create_transaction(
|
||||||
|
TransactionCreate(
|
||||||
|
amount=Decimal("301614.60"),
|
||||||
|
merchant="CC CONSTRUPLAZA",
|
||||||
|
date=datetime(2026, 6, 27, 19, 43),
|
||||||
|
reference="24724909",
|
||||||
|
),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert tx.is_installment_anchor is True
|
||||||
|
plan = session.exec(
|
||||||
|
select(InstallmentPlan).where(
|
||||||
|
InstallmentPlan.anchor_transaction_id == tx.id
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
assert plan.num_installments == 3
|
||||||
|
assert plan.first_installment_date == tx.date
|
||||||
|
cuotas = get_cuotas(session, plan.id)
|
||||||
|
assert [c.amount for c in cuotas] == [
|
||||||
|
Decimal("100538.20"),
|
||||||
|
Decimal("100538.20"),
|
||||||
|
Decimal("100538.20"),
|
||||||
|
]
|
||||||
|
assert cuotas[0].merchant == "CC CONSTRUPLAZA CUOTA:01/03"
|
||||||
|
assert cuotas[0].reference == "24724909-C01"
|
||||||
|
assert all(c.installment_plan_id == plan.id for c in cuotas)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kw",
|
||||||
|
[
|
||||||
|
{"merchant": "WALMART"},
|
||||||
|
{"transaction_type": TransactionType.DEVOLUCION},
|
||||||
|
{"source": TransactionSource.CASH},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ingestion_ignores_non_tasa_cero(self, session, kw):
|
||||||
|
data = dict(
|
||||||
|
amount=Decimal("1000"),
|
||||||
|
merchant="CC CONSTRUPLAZA",
|
||||||
|
date=datetime(2026, 6, 1),
|
||||||
|
)
|
||||||
|
data.update(kw)
|
||||||
|
tx = create_transaction(
|
||||||
|
TransactionCreate(**data), session=session, _user="t"
|
||||||
|
)
|
||||||
|
assert tx.is_installment_anchor is False
|
||||||
|
assert session.exec(select(InstallmentPlan)).all() == []
|
||||||
|
|
||||||
|
def test_duplicate_reference_still_409_and_creates_nothing(self, session):
|
||||||
|
make_cc_tx(session)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
create_transaction(
|
||||||
|
TransactionCreate(
|
||||||
|
amount=Decimal("1"),
|
||||||
|
merchant="CC CONSTRUPLAZA",
|
||||||
|
date=datetime(2026, 7, 1),
|
||||||
|
reference="75669363",
|
||||||
|
),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
assert session.exec(select(InstallmentPlan)).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetExclusion:
|
||||||
|
def test_anchor_excluded_cuotas_counted_per_month(self, session):
|
||||||
|
tx = make_cc_tx(session, date=datetime(2026, 6, 25))
|
||||||
|
convert(session, tx) # cuotas: 25/06, 19/07, 19/08
|
||||||
|
# Budget month = cycle ENDING on its 18th: 25/06 -> July, 19/07 -> Aug,
|
||||||
|
# 19/08 -> Sep. The full 111,053.60 must appear nowhere.
|
||||||
|
by_month = {
|
||||||
|
m: compute_actuals_by_source(session, 2026, m)["CREDIT_CARD"]["net"]
|
||||||
|
for m in (6, 7, 8, 9, 10)
|
||||||
|
}
|
||||||
|
assert by_month[6] == 0
|
||||||
|
assert by_month[7] == pytest.approx(37017.80)
|
||||||
|
assert by_month[8] == pytest.approx(37017.80)
|
||||||
|
assert by_month[9] == pytest.approx(37018.00)
|
||||||
|
assert by_month[10] == 0
|
||||||
|
|
||||||
|
def test_cuotas_inherit_anchor_category(self, session):
|
||||||
|
cat = Category(name="Ferretería")
|
||||||
|
session.add(cat)
|
||||||
|
session.commit()
|
||||||
|
tx = make_cc_tx(session, category_id=cat.id)
|
||||||
|
plan = convert(session, tx)
|
||||||
|
assert all(
|
||||||
|
c.category_id == cat.id for c in get_cuotas(session, plan.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanLifecycle:
|
||||||
|
def test_manual_convert_validations(self, session):
|
||||||
|
tx = make_cc_tx(session)
|
||||||
|
convert(session, tx)
|
||||||
|
# already converted
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
convert(session, tx)
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
# a cuota cannot itself be converted
|
||||||
|
cuota = session.exec(
|
||||||
|
select(Transaction).where(Transaction.installment_plan_id.is_not(None))
|
||||||
|
).first()
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
convert(session, cuota)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
# non-CC rejected
|
||||||
|
cash = make_cc_tx(
|
||||||
|
session, reference="r-cash", source=TransactionSource.CASH
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
convert(session, cash)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
def test_regenerate_3_to_6(self, session):
|
||||||
|
tx = make_cc_tx(session, amount="425800.00", reference="58710871")
|
||||||
|
plan = convert(session, tx)
|
||||||
|
updated = update_installment_plan(
|
||||||
|
plan.id,
|
||||||
|
InstallmentPlanUpdate(
|
||||||
|
num_installments=6,
|
||||||
|
first_installment_date=datetime(2026, 5, 19),
|
||||||
|
),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert updated.num_installments == 6
|
||||||
|
assert updated.installment_amount == pytest.approx(70966.60)
|
||||||
|
assert updated.last_installment_amount == pytest.approx(70967.00)
|
||||||
|
cuotas = get_cuotas(session, plan.id)
|
||||||
|
assert len(cuotas) == 6
|
||||||
|
assert cuotas[0].date == datetime(2026, 5, 19)
|
||||||
|
assert cuotas[-1].date == datetime(2026, 10, 19)
|
||||||
|
assert cuotas[0].reference == "58710871-C01"
|
||||||
|
assert cuotas[-1].merchant == "CC CONSTRUPLAZA CUOTA:06/06"
|
||||||
|
|
||||||
|
def test_unconvert_restores_anchor(self, session):
|
||||||
|
tx = make_cc_tx(session)
|
||||||
|
plan = convert(session, tx)
|
||||||
|
delete_installment_plan(plan.id, session=session, _user="t")
|
||||||
|
session.refresh(tx)
|
||||||
|
assert tx.is_installment_anchor is False
|
||||||
|
assert get_cuotas(session, plan.id) == []
|
||||||
|
assert session.exec(select(InstallmentPlan)).all() == []
|
||||||
|
|
||||||
|
def test_delete_anchor_tears_down_plan(self, session):
|
||||||
|
tx = make_cc_tx(session)
|
||||||
|
plan = convert(session, tx)
|
||||||
|
delete_transaction(tx.id, session=session, _user="t")
|
||||||
|
assert session.exec(select(InstallmentPlan)).all() == []
|
||||||
|
assert get_cuotas(session, plan.id) == []
|
||||||
|
|
||||||
|
def test_bulk_delete_anchor_tears_down_plan(self, session):
|
||||||
|
tx = make_cc_tx(session)
|
||||||
|
convert(session, tx)
|
||||||
|
bulk_action(
|
||||||
|
BulkActionRequest(ids=[tx.id], action="delete"),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert session.exec(select(InstallmentPlan)).all() == []
|
||||||
|
assert session.exec(select(Transaction)).all() == []
|
||||||
|
|
||||||
|
def test_list_and_detail(self, session):
|
||||||
|
tx = make_cc_tx(session, date=datetime(2026, 5, 12, 15, 41))
|
||||||
|
convert(session, tx, first=datetime(2026, 5, 19))
|
||||||
|
resp = list_installment_plans(session=session, _user="t")
|
||||||
|
assert len(resp.plans) == 1
|
||||||
|
plan = resp.plans[0]
|
||||||
|
assert plan.merchant == "CC CONSTRUPLAZA"
|
||||||
|
assert plan.total_amount == pytest.approx(111053.60)
|
||||||
|
assert plan.remaining_amount + plan.paid_amount == pytest.approx(
|
||||||
|
111053.60
|
||||||
|
)
|
||||||
|
assert resp.total_remaining == pytest.approx(plan.remaining_amount)
|
||||||
|
detail = get_installment_plan(plan.id, session=session, _user="t")
|
||||||
|
assert len(detail.cuotas) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeferredGuards:
|
||||||
|
def test_patch_cuota_deferred_rejected(self, session):
|
||||||
|
tx = make_cc_tx(session)
|
||||||
|
plan = convert(session, tx)
|
||||||
|
cuota = get_cuotas(session, plan.id)[0]
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
update_transaction(
|
||||||
|
cuota.id,
|
||||||
|
TransactionUpdate(deferred_to_next_cycle=True),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
def test_bulk_set_deferred_skips_cuotas(self, session):
|
||||||
|
tx = make_cc_tx(session)
|
||||||
|
plan = convert(session, tx)
|
||||||
|
cuota = get_cuotas(session, plan.id)[0]
|
||||||
|
normal = make_cc_tx(session, merchant="WALMART", reference="w1")
|
||||||
|
resp = bulk_action(
|
||||||
|
BulkActionRequest(
|
||||||
|
ids=[cuota.id, normal.id], action="set_deferred", deferred=True
|
||||||
|
),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert resp["affected"] == 1
|
||||||
|
session.refresh(cuota)
|
||||||
|
session.refresh(normal)
|
||||||
|
assert cuota.deferred_to_next_cycle is False
|
||||||
|
assert normal.deferred_to_next_cycle is True
|
||||||
@@ -16,6 +16,11 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -31,11 +36,17 @@ services:
|
|||||||
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}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
|
||||||
|
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
|
||||||
|
ENABLE_INSTRUMENTATION: "false"
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
networks:
|
networks:
|
||||||
- wealthysmart-network
|
wealthysmart-network:
|
||||||
|
# Avoid the generic `backend` name, which is also used on the shared
|
||||||
|
# nginx network by another application.
|
||||||
|
aliases:
|
||||||
|
- wealthysmart-api
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -45,6 +56,11 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -57,8 +73,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
BACKEND_URL: http://wealthysmart-backend-prod:8000
|
BACKEND_URL: http://wealthysmart-api:8000
|
||||||
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui
|
AGENT_URL: http://wealthysmart-api:8000/api/v1/agent/agui
|
||||||
JWT_SECRET: ${SECRET_KEY}
|
JWT_SECRET: ${SECRET_KEY}
|
||||||
COOKIE_DOMAIN: wealth.cescalante.dev
|
COOKIE_DOMAIN: wealth.cescalante.dev
|
||||||
COOKIE_SECURE: "true"
|
COOKIE_SECURE: "true"
|
||||||
@@ -78,6 +94,11 @@ services:
|
|||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
wealthysmart-network:
|
wealthysmart-network:
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -30,7 +35,9 @@ services:
|
|||||||
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:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
|
||||||
|
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
|
||||||
|
ENABLE_INSTRUMENTATION: "false"
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -38,6 +45,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- path: ./backend/app
|
- path: ./backend/app
|
||||||
@@ -58,9 +70,17 @@ services:
|
|||||||
- "5175:3000"
|
- "5175:3000"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
# Vite runs inside this container, so localhost would refer to the
|
||||||
|
# frontend rather than the Python service.
|
||||||
|
BACKEND_URL: http://backend:8000
|
||||||
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- path: ./frontend/src
|
- path: ./frontend/src
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
# Cap node heap so a build on a small VPS can't OOM-kill neighbours.
|
# Cap node heap so a build on a small VPS can't OOM-kill neighbours.
|
||||||
ENV NODE_OPTIONS=--max-old-space-size=1536
|
# 1536 stopped being enough at the 2026-07 UI revamp (cmdk, day-picker,
|
||||||
|
# chart wrappers); rollup now peaks between 1.5G and 2G.
|
||||||
|
ENV NODE_OPTIONS=--max-old-space-size=2048
|
||||||
RUN corepack enable && pnpm build
|
RUN corepack enable && pnpm build
|
||||||
|
|
||||||
# Production: Hono serves dist/ + /api/copilotkit on port 3000
|
# Production: Hono serves dist/ + /api/copilotkit on port 3000
|
||||||
|
|||||||
@@ -20,6 +20,5 @@
|
|||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"menuColor": "default-translucent",
|
"menuColor": "default-translucent",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle"
|
||||||
"registries": {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
113
frontend/e2e/asistente.spec.ts
Normal file
113
frontend/e2e/asistente.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
test.setTimeout(150_000);
|
||||||
|
|
||||||
|
async function startFreshAssistant(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||||
|
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL("/");
|
||||||
|
|
||||||
|
const initialConnect = page.waitForResponse((response) => {
|
||||||
|
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.goto("/asistente");
|
||||||
|
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||||
|
await (await initialConnect).finished();
|
||||||
|
|
||||||
|
const newConversation = page.getByRole("button", { name: "Nueva conversación" });
|
||||||
|
if (await newConversation.isEnabled()) {
|
||||||
|
await newConversation.click();
|
||||||
|
const reconnect = page.waitForResponse((response) => {
|
||||||
|
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Borrar historial" }).click();
|
||||||
|
await (await reconnect).finished();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: "Escribe tu pregunta…" }),
|
||||||
|
).toBeEnabled({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(page: Page, question: string, expectedTool: string) {
|
||||||
|
// The BFF response is the exact CopilotKit stream consumed by the browser.
|
||||||
|
// Checking it catches server-side RUN_ERROR events even if the UI changes.
|
||||||
|
const stream = page.waitForResponse(
|
||||||
|
(response) => {
|
||||||
|
if (
|
||||||
|
!response.url().includes("/api/copilotkit") ||
|
||||||
|
response.request().method() !== "POST"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/run";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const composer = page.getByRole("textbox", { name: "Escribe tu pregunta…" });
|
||||||
|
await expect(composer).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await composer.fill(question);
|
||||||
|
const sendButton = page.getByTestId("copilot-send-button");
|
||||||
|
await expect(sendButton).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await sendButton.click();
|
||||||
|
await expect(page.getByText(question, { exact: true }).last()).toBeVisible();
|
||||||
|
|
||||||
|
const response = await stream;
|
||||||
|
await response.finished();
|
||||||
|
const events = await response.text();
|
||||||
|
expect(events).not.toContain("RUN_ERROR");
|
||||||
|
expect(events).toContain(expectedTool);
|
||||||
|
|
||||||
|
// The HTTP stream may finish before CopilotKit has committed the tool run
|
||||||
|
// and released its client-side running state. Waiting for the page action
|
||||||
|
// avoids losing the next Enter keypress during that short interval.
|
||||||
|
await expect(page.getByRole("button", { name: "Nueva conversación" })).toBeEnabled({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Asistente completes Luna's read-only tool flows", async ({ page }) => {
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") consoleErrors.push(message.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await startFreshAssistant(page);
|
||||||
|
|
||||||
|
await ask(page, "What date is it today?", "get_current_date");
|
||||||
|
await ask(page, "List my last 10 transactions.", "get_recent_transactions");
|
||||||
|
await ask(page, "What is my net worth?", "get_net_worth");
|
||||||
|
await ask(page, "How much did I spend today?", "get_daily_spending");
|
||||||
|
await ask(page, "Show my spending by category this month.", "render_spending_summary");
|
||||||
|
|
||||||
|
const spendingCard = page.getByTestId("spending-summary-card");
|
||||||
|
await expect(spendingCard).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("textbox", { name: "Escribe tu pregunta…" })).toBeEnabled({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Show my spending by category this month.", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("spending-summary-card")).toBeVisible();
|
||||||
|
|
||||||
|
expect(consoleErrors.join("\n")).not.toContain("ChatClientException");
|
||||||
|
});
|
||||||
127
frontend/e2e/feature-doc.ts
Normal file
127
frontend/e2e/feature-doc.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, type Page, type TestInfo } from "@playwright/test";
|
||||||
|
import * as narration from "./narration";
|
||||||
|
import { renderFinalVideo } from "./video-render";
|
||||||
|
import type { Clip } from "./subtitles";
|
||||||
|
|
||||||
|
const DEMO = !!process.env.PW_DEMO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps test.step() so every step of a demo spec produces a captioned
|
||||||
|
* screenshot, and — in PW_DEMO mode — a native Playwright screencast
|
||||||
|
* recording with cursor annotations, chapter-title cards, burned-in
|
||||||
|
* subtitles and (when `say` is available) spoken Spanish narration.
|
||||||
|
* Screenshots/video/subtitles land in e2e-docs/<slug>/ and are also
|
||||||
|
* attached to the Playwright HTML report; save() stitches everything into
|
||||||
|
* a Markdown walkthrough that can be published as-is.
|
||||||
|
*/
|
||||||
|
export class FeatureDoc {
|
||||||
|
private steps: { title: string; caption: string; file: string }[] = [];
|
||||||
|
private clips: Clip[] = [];
|
||||||
|
private readonly dir: string;
|
||||||
|
private recordingStartedAt = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private page: Page,
|
||||||
|
private testInfo: TestInfo,
|
||||||
|
private slug: string,
|
||||||
|
private title: string,
|
||||||
|
private intro: string,
|
||||||
|
) {
|
||||||
|
this.dir = path.resolve("e2e-docs", slug);
|
||||||
|
fs.rmSync(this.dir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(this.dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the native screencast recording. Call before the first step(). */
|
||||||
|
async start() {
|
||||||
|
if (!DEMO) return;
|
||||||
|
this.recordingStartedAt = Date.now();
|
||||||
|
await this.page.screencast.start({
|
||||||
|
path: path.join(this.dir, "raw.webm"),
|
||||||
|
size: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
await this.page.screencast.showActions({ cursor: "pointer" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async step(title: string, caption: string, fn: () => Promise<void>) {
|
||||||
|
const narrationPromise = DEMO
|
||||||
|
? narration.synthesize(caption, this.testInfo.outputDir, this.clips.length)
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
const stepStart = Date.now();
|
||||||
|
if (DEMO) {
|
||||||
|
await this.page.screencast.showChapter(title, { description: caption, duration: 1500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step(title, fn);
|
||||||
|
|
||||||
|
const narrationResult = await narrationPromise;
|
||||||
|
if (DEMO) {
|
||||||
|
const elapsed = Date.now() - stepStart;
|
||||||
|
const target = (narrationResult?.durationMs ?? 0) + 300;
|
||||||
|
if (target > elapsed) await this.page.waitForTimeout(target - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = `${String(this.steps.length + 1).padStart(2, "0")}-${title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]+/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")}.png`;
|
||||||
|
const body = await this.page.screenshot({
|
||||||
|
path: path.join(this.dir, file),
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
await this.testInfo.attach(title, { body, contentType: "image/png" });
|
||||||
|
this.steps.push({ title, caption, file });
|
||||||
|
|
||||||
|
if (DEMO) {
|
||||||
|
this.clips.push({
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
file,
|
||||||
|
startMs: stepStart - this.recordingStartedAt,
|
||||||
|
endMs: Date.now() - this.recordingStartedAt,
|
||||||
|
narration: narrationResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
let videoName = "video.mp4";
|
||||||
|
if (DEMO) {
|
||||||
|
await this.page.screencast.stop();
|
||||||
|
const { videoFile, degraded } = await renderFinalVideo({
|
||||||
|
rawWebmPath: path.join(this.dir, "raw.webm"),
|
||||||
|
clips: this.clips,
|
||||||
|
outDir: this.dir,
|
||||||
|
});
|
||||||
|
for (const warning of degraded) console.warn(`[${this.slug}] ${warning}`);
|
||||||
|
videoName = path.basename(videoFile);
|
||||||
|
await this.testInfo.attach("video", {
|
||||||
|
path: videoFile,
|
||||||
|
contentType: videoName.endsWith(".mp4") ? "video/mp4" : "video/webm",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const md = [
|
||||||
|
`# ${this.title}`,
|
||||||
|
"",
|
||||||
|
this.intro,
|
||||||
|
"",
|
||||||
|
`> Generado automáticamente por \`${path.basename(this.testInfo.file)}\` — ${this.steps.length} pasos. El video completo del recorrido está en [${videoName}](${videoName}).`,
|
||||||
|
"",
|
||||||
|
...this.steps.flatMap((s, i) => [
|
||||||
|
`## ${i + 1}. ${s.title}`,
|
||||||
|
"",
|
||||||
|
s.caption,
|
||||||
|
"",
|
||||||
|
``,
|
||||||
|
"",
|
||||||
|
]),
|
||||||
|
].join("\n");
|
||||||
|
fs.writeFileSync(path.join(this.dir, "index.md"), md);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { FeatureDoc } from "./feature-doc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo walkthrough of the Financiamientos (Tasa Cero) feature. Doubles as an
|
||||||
|
* e2e smoke test and as the generator for e2e-docs/financiamientos/ — a
|
||||||
|
* captioned, screenshot-per-step Markdown doc plus the full video (native
|
||||||
|
* cursor/chapters, burned-in subtitles, spoken narration) in the Playwright
|
||||||
|
* HTML report.
|
||||||
|
*/
|
||||||
|
// NOTE: do not `test.use({ video: "off" })` here — disabling the fixture
|
||||||
|
// video breaks page.screencast's internal sizing (verified: it then records
|
||||||
|
// a shrunken frame letterboxed inside the requested canvas). The fixture's
|
||||||
|
// own video ends up redundant/unused but harmless.
|
||||||
|
|
||||||
|
test("@demo Financiamientos — compras Tasa Cero en cuotas", async ({ page }, testInfo) => {
|
||||||
|
const doc = new FeatureDoc(
|
||||||
|
page,
|
||||||
|
testInfo,
|
||||||
|
"financiamientos",
|
||||||
|
"Financiamientos — Tasa Cero",
|
||||||
|
"WealthySmart trackea las compras BAC Tasa Cero como planes de cuotas mensuales: cada cuota futura ya cuenta en el presupuesto del mes que le toca, en lugar de registrar la compra como un solo pago.",
|
||||||
|
);
|
||||||
|
await doc.start();
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Iniciar sesión",
|
||||||
|
"El acceso está protegido con usuario y contraseña; la sesión vive en una cookie HTTP-only.",
|
||||||
|
async () => {
|
||||||
|
await page.goto("/login");
|
||||||
|
// CopilotKit mounts its dev web-inspector in dev builds; keep it out of
|
||||||
|
// the recording — it isn't part of the product.
|
||||||
|
await page.addStyleTag({ content: "cpk-web-inspector { display: none !important; }" });
|
||||||
|
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||||
|
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||||
|
await expect(page.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Panel de inicio",
|
||||||
|
"Después del login se llega al dashboard Inicio, con el resumen del mes y la navegación lateral por módulos.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL("/");
|
||||||
|
await expect(page.getByRole("link", { name: "Financiamientos", exact: true })).toBeVisible();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Modo privacidad",
|
||||||
|
"El botón de privacidad difumina todos los montos sensibles — útil para compartir pantalla (y para este demo).",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: "Toggle privacy mode" }).click();
|
||||||
|
await expect(page.locator("html")).toHaveClass(/privacy/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Página de Financiamientos",
|
||||||
|
"El módulo resume todos los planes Tasa Cero: saldo faltante total, cuánto suman las cuotas de los planes activos cada mes, y cuántos planes siguen vivos.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("link", { name: "Financiamientos", exact: true }).click();
|
||||||
|
await page.waitForURL("/financiamientos");
|
||||||
|
await expect(page.getByText("Saldo faltante total")).toBeVisible();
|
||||||
|
await expect(page.getByText("Cuotas por mes (activos)")).toBeVisible();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Tabla de planes",
|
||||||
|
"Cada plan muestra el comercio, el avance de cuotas (facturadas vs. totales), el monto de la cuota, el saldo que falta y cuándo cae la próxima cuota.",
|
||||||
|
async () => {
|
||||||
|
const rows = page.getByRole("button", { name: /editar plan de/i });
|
||||||
|
await expect(rows.first()).toBeVisible();
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0);
|
||||||
|
await expect(page.getByRole("columnheader", { name: "Próxima cuota" })).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Detalle de un plan",
|
||||||
|
"Al hacer clic en un plan se abre su detalle editable: fecha ancla, número de cuotas y monto. Desde aquí también se puede deshacer la conversión a Tasa Cero.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: /editar plan de/i }).first().click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Cerrar el detalle",
|
||||||
|
"El plan queda igual que estaba — el recorrido es de solo lectura.",
|
||||||
|
async () => {
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeHidden();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
});
|
||||||
59
frontend/e2e/narration.ts
Normal file
59
frontend/e2e/narration.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
let sayChecked = false;
|
||||||
|
let sayAvailable = false;
|
||||||
|
|
||||||
|
function hasSay() {
|
||||||
|
if (!sayChecked) {
|
||||||
|
sayChecked = true;
|
||||||
|
try {
|
||||||
|
execFileSync("say", ["-v", "?"], { stdio: "ignore" });
|
||||||
|
sayAvailable = true;
|
||||||
|
} catch {
|
||||||
|
console.warn("`say` not found — skipping TTS narration (subtitles still work).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sayAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, Promise<{ path: string; durationMs: number } | null>>();
|
||||||
|
|
||||||
|
/** Synthesizes Spanish narration for a caption via macOS `say`. Returns null (never throws) if unavailable. */
|
||||||
|
export function synthesize(
|
||||||
|
text: string,
|
||||||
|
outDir: string,
|
||||||
|
index: number,
|
||||||
|
voice = "Paulina",
|
||||||
|
): Promise<{ path: string; durationMs: number } | null> {
|
||||||
|
const key = `${voice}:${text}`;
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
if (!hasSay()) return null;
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const file = path.join(outDir, `narration-${String(index).padStart(2, "0")}.aiff`);
|
||||||
|
try {
|
||||||
|
await execFileAsync("say", ["-v", voice, "-o", file, text]);
|
||||||
|
const { stdout } = await execFileAsync("ffprobe", [
|
||||||
|
"-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "csv=p=0",
|
||||||
|
file,
|
||||||
|
]);
|
||||||
|
const durationMs = Math.round(parseFloat(stdout.trim()) * 1000);
|
||||||
|
return { path: file, durationMs };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Narration synthesis failed for "${text.slice(0, 40)}…": ${err}`);
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
cache.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
33
frontend/e2e/subtitles.ts
Normal file
33
frontend/e2e/subtitles.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface Clip {
|
||||||
|
title: string;
|
||||||
|
caption: string;
|
||||||
|
file: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
narration: { path: string; durationMs: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function msToSrtTimestamp(ms: number): string {
|
||||||
|
const clamped = Math.max(0, Math.round(ms));
|
||||||
|
const hours = Math.floor(clamped / 3_600_000);
|
||||||
|
const minutes = Math.floor((clamped % 3_600_000) / 60_000);
|
||||||
|
const seconds = Math.floor((clamped % 60_000) / 1000);
|
||||||
|
const millis = clamped % 1000;
|
||||||
|
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
||||||
|
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One subtitle cue per step, spanning its full on-screen duration. */
|
||||||
|
export function buildSrt(clips: Clip[]): string {
|
||||||
|
return clips
|
||||||
|
.map((clip, i) => {
|
||||||
|
const index = i + 1;
|
||||||
|
return [
|
||||||
|
String(index),
|
||||||
|
`${msToSrtTimestamp(clip.startMs)} --> ${msToSrtTimestamp(clip.endMs)}`,
|
||||||
|
clip.caption,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
88
frontend/e2e/video-render.ts
Normal file
88
frontend/e2e/video-render.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { buildSrt, type Clip } from "./subtitles";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function hasFfmpeg() {
|
||||||
|
try {
|
||||||
|
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBTITLE_STYLE =
|
||||||
|
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
|
||||||
|
|
||||||
|
function escapeForFilter(p: string) {
|
||||||
|
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
|
||||||
|
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderFinalVideo({
|
||||||
|
rawWebmPath,
|
||||||
|
clips,
|
||||||
|
outDir,
|
||||||
|
}: {
|
||||||
|
rawWebmPath: string;
|
||||||
|
clips: Clip[];
|
||||||
|
outDir: string;
|
||||||
|
}): Promise<{ videoFile: string; degraded: string[] }> {
|
||||||
|
const degraded: string[] = [];
|
||||||
|
const srtPath = path.join(outDir, "captions.srt");
|
||||||
|
fs.writeFileSync(srtPath, buildSrt(clips));
|
||||||
|
|
||||||
|
if (!hasFfmpeg()) {
|
||||||
|
const dest = path.join(outDir, "video.webm");
|
||||||
|
fs.renameSync(rawWebmPath, dest);
|
||||||
|
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
|
||||||
|
return { videoFile: dest, degraded };
|
||||||
|
}
|
||||||
|
|
||||||
|
const narrated = clips.filter((c) => c.narration);
|
||||||
|
const mp4Path = path.join(outDir, "video.mp4");
|
||||||
|
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
|
||||||
|
|
||||||
|
if (narrated.length === 0) {
|
||||||
|
degraded.push("No narration available — burned-in subtitles only.");
|
||||||
|
await execFileAsync("ffmpeg", [
|
||||||
|
"-y", "-i", rawWebmPath,
|
||||||
|
"-vf", srtArg,
|
||||||
|
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
"-an",
|
||||||
|
mp4Path,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
|
||||||
|
const adelayParts = narrated.map(
|
||||||
|
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
|
||||||
|
);
|
||||||
|
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
|
||||||
|
const filterComplex = [
|
||||||
|
`[0:v]${srtArg}[vout]`,
|
||||||
|
...adelayParts,
|
||||||
|
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
await execFileAsync("ffmpeg", [
|
||||||
|
"-y", "-i", rawWebmPath, ...audioInputs,
|
||||||
|
"-filter_complex", filterComplex,
|
||||||
|
"-map", "[vout]", "-map", "[aout]",
|
||||||
|
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-shortest",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
mp4Path,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(rawWebmPath, { force: true });
|
||||||
|
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
|
||||||
|
|
||||||
|
return { videoFile: mp4Path, degraded };
|
||||||
|
}
|
||||||
@@ -9,14 +9,16 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "tsx server.ts",
|
"preview": "tsx server.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"demo": "PW_DEMO=1 playwright test --grep @demo",
|
||||||
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
|
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ag-ui/client": "0.0.52",
|
"@ag-ui/client": "0.0.57",
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"@copilotkit/react-core": "1.56.4",
|
"@copilotkit/react-core": "1.62.3",
|
||||||
"@copilotkit/react-ui": "1.56.4",
|
"@copilotkit/react-ui": "1.62.3",
|
||||||
"@copilotkit/runtime": "1.56.4",
|
"@copilotkit/runtime": "1.62.3",
|
||||||
"@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",
|
"@hono/node-server": "^1.14.4",
|
||||||
@@ -24,13 +26,16 @@
|
|||||||
"@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",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"hono": "^4.12.15",
|
"hono": "^4.12.15",
|
||||||
"lucide-react": "^1.12.0",
|
"lucide-react": "^1.12.0",
|
||||||
"react": "19.2.5",
|
"react": "19.2.5",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.5",
|
||||||
"react-router-dom": "^7.6.0",
|
"react-router-dom": "^7.6.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
@@ -38,14 +43,15 @@
|
|||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
"@vitejs/plugin-react-oxc": "^0.4.3",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite": "^6.3.5"
|
"vite": "npm:rolldown-vite@latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
frontend/playwright.config.ts
Normal file
52
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
// Demo specs log in with the real dev credentials; pull them from the repo
|
||||||
|
// .env so they never live in the test source.
|
||||||
|
const repoEnv = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env");
|
||||||
|
if (fs.existsSync(repoEnv)) {
|
||||||
|
for (const line of fs.readFileSync(repoEnv, "utf8").split("\n")) {
|
||||||
|
const m = line.match(/^([A-Z_]+)=["']?(.*?)["']?$/);
|
||||||
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
outputDir: "./test-results",
|
||||||
|
fullyParallel: false,
|
||||||
|
// Demo runs synthesize narration and run several ffmpeg passes in save();
|
||||||
|
// that can exceed the 30s default, especially with many steps.
|
||||||
|
timeout: process.env.PW_DEMO ? 120_000 : 30_000,
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report", open: "never" }],
|
||||||
|
],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
// Demo runs (pnpm demo) pace every action so the recorded video is
|
||||||
|
// watchable by a human; functional e2e runs stay at full speed.
|
||||||
|
launchOptions: {
|
||||||
|
slowMo: process.env.PW_DEMO ? 500 : 0,
|
||||||
|
},
|
||||||
|
locale: "es-CR",
|
||||||
|
timezoneId: "America/Costa_Rica",
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
// Retina-density rendering so doc screenshots stay crisp when zoomed.
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
// Full recordings are only useful for the paced demo. Keeping them off
|
||||||
|
// for regular e2e avoids making test success depend on artifact uploads.
|
||||||
|
video: process.env.PW_DEMO ? { mode: "on", size: { width: 1920, height: 1080 } } : "off",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
trace: process.env.PW_DEMO ? "on" : "on-first-retry",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
stdout: "ignore",
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
3100
frontend/pnpm-lock.yaml
generated
3100
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,20 @@
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import {
|
import {
|
||||||
|
AgentRunner,
|
||||||
CopilotRuntime,
|
CopilotRuntime,
|
||||||
ExperimentalEmptyAdapter,
|
createCopilotRuntimeHandler,
|
||||||
copilotRuntimeNextJSAppRouterEndpoint,
|
} from "@copilotkit/runtime/v2";
|
||||||
} from "@copilotkit/runtime";
|
|
||||||
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
|
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
|
||||||
import { Observable } from "rxjs";
|
import { Observable, ReplaySubject } from "rxjs";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
const AGENT_URL =
|
|
||||||
process.env.AGENT_URL ?? "http://backend:8000/api/v1/agent/agui";
|
|
||||||
const BACKEND_URL =
|
const BACKEND_URL =
|
||||||
process.env.BACKEND_URL ?? "http://localhost:8001";
|
process.env.BACKEND_URL ?? "http://localhost:8001";
|
||||||
|
// Prod sets AGENT_URL explicitly (compose network); the default derives from
|
||||||
|
// BACKEND_URL so local dev reaches the docker backend published on :8001.
|
||||||
|
const AGENT_URL =
|
||||||
|
process.env.AGENT_URL ?? `${BACKEND_URL}/api/v1/agent/agui`;
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
|
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
|
||||||
@@ -118,6 +120,27 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
|
|||||||
const open = new Set<string>(); // tool call IDs currently in progress
|
const open = new Set<string>(); // tool call IDs currently in progress
|
||||||
const closed = new Set<string>(); // tool call IDs that already received END
|
const closed = new Set<string>(); // tool call IDs that already received END
|
||||||
|
|
||||||
|
// Render tools are display-once: after the client returns their result,
|
||||||
|
// the model sometimes re-issues the same call (fresh id) on the
|
||||||
|
// follow-up run, painting a second identical card. Track which render
|
||||||
|
// tools already ran in the current user turn (from the request's own
|
||||||
|
// message history) and suppress repeats — both their streamed events
|
||||||
|
// and their entries in this run's MESSAGES_SNAPSHOT.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : [];
|
||||||
|
let lastUserIdx = -1;
|
||||||
|
inputMessages.forEach((m, i) => { if (m?.role === "user") lastUserIdx = i; });
|
||||||
|
const renderedThisTurn = new Set<string>();
|
||||||
|
for (const m of inputMessages.slice(lastUserIdx + 1)) {
|
||||||
|
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||||
|
if (m?.role !== "assistant" || !Array.isArray(calls)) continue;
|
||||||
|
for (const tc of calls) {
|
||||||
|
const name = tc?.function?.name;
|
||||||
|
if (name && RENDER_TOOLS.has(name)) renderedThisTurn.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const suppressed = new Set<string>(); // toolCallIds of dropped repeats
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return new Observable<any>((observer) => {
|
return new Observable<any>((observer) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -130,13 +153,51 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
|
|||||||
|
|
||||||
if (type === EventType.TOOL_CALL_START) {
|
if (type === EventType.TOOL_CALL_START) {
|
||||||
if (!id || closed.has(id) || open.has(id)) return; // duplicate
|
if (!id || closed.has(id) || open.has(id)) return; // duplicate
|
||||||
|
const name: string | undefined = event?.toolCallName;
|
||||||
|
if (name && RENDER_TOOLS.has(name)) {
|
||||||
|
if (renderedThisTurn.has(name)) {
|
||||||
|
suppressed.add(id);
|
||||||
|
return; // repeat render call — drop the whole call
|
||||||
|
}
|
||||||
|
renderedThisTurn.add(name);
|
||||||
|
}
|
||||||
open.add(id);
|
open.add(id);
|
||||||
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
|
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
|
||||||
|
if (id && suppressed.has(id)) return;
|
||||||
if (id && closed.has(id)) return; // already completed, drop
|
if (id && closed.has(id)) return; // already completed, drop
|
||||||
if (type === EventType.TOOL_CALL_END && id) {
|
if (type === EventType.TOOL_CALL_END && id) {
|
||||||
open.delete(id);
|
open.delete(id);
|
||||||
closed.add(id);
|
closed.add(id);
|
||||||
}
|
}
|
||||||
|
} else if (type === EventType.MESSAGES_SNAPSHOT && suppressed.size > 0) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
||||||
|
const cleaned = msgs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.map((m: any) => {
|
||||||
|
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||||
|
if (m?.role !== "assistant" || !Array.isArray(calls)) return m;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const kept = calls.filter((tc: any) => !suppressed.has(String(tc?.id ?? "")));
|
||||||
|
if (kept.length === calls.length) return m;
|
||||||
|
const { toolCalls: _a, tool_calls: _b, ...rest } = m;
|
||||||
|
return kept.length > 0 ? { ...rest, toolCalls: kept } : rest;
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.filter((m: any) => {
|
||||||
|
if (m?.role === "tool" && suppressed.has(String(m?.toolCallId ?? m?.tool_call_id ?? ""))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (m?.role === "assistant") {
|
||||||
|
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||||
|
const hasCalls = Array.isArray(calls) && calls.length > 0;
|
||||||
|
const hasText = typeof m?.content === "string" && m.content.length > 0;
|
||||||
|
if (!hasCalls && !hasText) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
observer.next({ ...event, messages: cleaned });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
observer.next(event); // emit raw BaseEvent
|
observer.next(event); // emit raw BaseEvent
|
||||||
@@ -189,24 +250,25 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
|
// ── DropRunMessagesSnapshotMiddleware ────────────────────────────────────────
|
||||||
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
|
// MAF's run-end MESSAGES_SNAPSHOT re-mints ids for post-tool-call text (their
|
||||||
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
|
// #3619) and, with thread persistence on, rebuilds history under STORE ids.
|
||||||
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
|
// ag-ui's apply treats the snapshot as authoritative: client messages absent
|
||||||
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
|
// from it are dropped, matching ids are replaced. Because streamed ids and
|
||||||
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
|
// snapshot ids never agree for tool+text turns, every mid-session snapshot
|
||||||
// by id then APPENDS unknown ids, so the browser ends up with two assistant
|
// either duplicates the current answer (fresh-id append) or wipes earlier
|
||||||
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
|
// ones (store-id replace with empty content) — both observed live. During a
|
||||||
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
|
// normal run the client already built the exact turn from streamed events,
|
||||||
// the assistant-with-toolCalls message in state past the run). The right fix
|
// so the snapshot adds nothing: swallow it. Hydration replays (empty
|
||||||
// is to drop just the orphan text-only assistant message that has no streamed
|
// input.messages) pass through untouched — there the snapshot IS the
|
||||||
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
|
// conversation, and the client converges to store ids on every reload.
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
class DropRunMessagesSnapshotMiddleware extends (Middleware as any) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
run(input: any, next: any): Observable<any> {
|
run(input: any, next: any): Observable<any> {
|
||||||
const streamedTextIds = new Set<string>();
|
const isHydration =
|
||||||
|
!Array.isArray(input?.messages) || input.messages.length === 0;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return new Observable<any>((observer) => {
|
return new Observable<any>((observer) => {
|
||||||
@@ -215,33 +277,9 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
next: (eventWithState: any) => {
|
next: (eventWithState: any) => {
|
||||||
const event = eventWithState.event;
|
const event = eventWithState.event;
|
||||||
const type: string = event?.type ?? "";
|
if (event?.type === EventType.MESSAGES_SNAPSHOT && !isHydration) {
|
||||||
|
return; // client state from streaming is already correct
|
||||||
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);
|
observer.next(event);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -259,7 +297,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
|||||||
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
|
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
|
||||||
// them if any render tool call is detected in the same turn.
|
// them if any render tool call is detected in the same turn.
|
||||||
|
|
||||||
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]);
|
const RENDER_TOOLS = new Set(["render_spending_summary"]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
|
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
|
||||||
@@ -345,60 +383,193 @@ app.use("*", async (c, next) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.all("/api/copilotkit/*", async (c) => {
|
// ── CopilotKit v2 runtime (module scope, built once) ─────────────────────────
|
||||||
const cookieHeader = c.req.header("cookie") ?? "";
|
// Migrated 2026-07-04 off the deprecated copilotRuntimeNextJSAppRouterEndpoint
|
||||||
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
|
// wrapper (which was already the v2 single-route handler underneath) onto
|
||||||
const token = match?.[1];
|
// createCopilotRuntimeHandler directly, with a Postgres-backed AgentRunner:
|
||||||
const agentHeaders: Record<string, string> = token
|
// run() is a pure proxy to the MAF backend (which persists every turn in
|
||||||
? { Authorization: `Bearer ${token}` }
|
// chatthreadsnapshot), and connect() forwards to MAF's snapshot hydration —
|
||||||
: {};
|
// so the ONLY conversation store is the DB. This deletes the in-memory
|
||||||
|
// runner (whose process-local replay resurrected cleared conversations) and
|
||||||
|
// the client-side hydration hack that compensated for it.
|
||||||
|
|
||||||
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
|
// The v2 handler nags about anonymous telemetry unless disabled.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true";
|
||||||
|
|
||||||
|
function makeWealthysmartAgent(headers: Record<string, string> = {}): HttpAgent {
|
||||||
|
const agent = new HttpAgent({ url: AGENT_URL, headers });
|
||||||
|
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these
|
||||||
|
// workarounds are still needed after CopilotKit/MAF upgrades.
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
if (process.env.CK_MIDDLEWARES !== "off") {
|
||||||
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new StripModelArtifactsMiddleware() as any);
|
agent.use(new StripModelArtifactsMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
agent.use(new DropRunMessagesSnapshotMiddleware() as any);
|
||||||
agent.use(new ReconcileSnapshotMiddleware() as any);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new DeduplicateToolCallMiddleware() as any);
|
agent.use(new DeduplicateToolCallMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
}
|
||||||
|
if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {
|
||||||
agent.use(new DebugLogMiddleware() as any);
|
agent.use(new DebugLogMiddleware() as any);
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PostgresAgentRunner ───────────────────────────────────────────────────────
|
||||||
|
// The conversation store is Postgres (chatthreadsnapshot), written by MAF at
|
||||||
|
// every run end. So the runner records NOTHING:
|
||||||
|
// - run() proxies the agent's event stream (the per-request clone the v2
|
||||||
|
// handler prepared, auth headers already forwarded onto it).
|
||||||
|
// - connect() asks MAF for its snapshot hydration — an empty-messages run
|
||||||
|
// returns RUN_STARTED → [STATE_SNAPSHOT] → MESSAGES_SNAPSHOT → RUN_FINISHED
|
||||||
|
// with no LLM call (unknown thread: RUN_STARTED → RUN_FINISHED). The same
|
||||||
|
// stream shape the in-memory runner's replay approximated, but sourced
|
||||||
|
// from the DB, so it is correct across clears, restarts and instances.
|
||||||
|
// - Live runs are tracked in-process only for stop(); the official
|
||||||
|
// sqlite-runner does the same (single-instance limitation acknowledged).
|
||||||
|
// isRunning() has no caller in the v2 SSE path (verified at 1.62.2).
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const liveRuns = new Map<string, any>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function proxyAgentRun(agent: any, input: any, threadId: string): Observable<any> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const subject = new ReplaySubject<any>(Infinity);
|
||||||
|
liveRuns.set(threadId, agent);
|
||||||
|
agent
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.runAgent(input, { onEvent: ({ event }: any) => subject.next(event) })
|
||||||
|
.then(() => subject.complete())
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.catch((err: any) => subject.error(err))
|
||||||
|
.finally(() => {
|
||||||
|
if (liveRuns.get(threadId) === agent) liveRuns.delete(threadId);
|
||||||
|
});
|
||||||
|
return subject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
class PostgresAgentRunner extends (AgentRunner as any) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
run(request: any): Observable<any> {
|
||||||
|
return proxyAgentRun(request.agent, request.input, request.threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
connect(request: any): Observable<any> {
|
||||||
|
// request.headers = authorization + x-* from the incoming browser
|
||||||
|
// request (extractForwardableHeaders) — exactly what MAF needs.
|
||||||
|
const agent = makeWealthysmartAgent(request.headers ?? {});
|
||||||
|
agent.threadId = request.threadId;
|
||||||
|
// No messages + known threadId = MAF's hydration trigger.
|
||||||
|
return proxyAgentRun(agent, {}, `connect:${request.threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
isRunning(request: any): Promise<boolean> {
|
||||||
|
return Promise.resolve(liveRuns.has(request.threadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async stop(request: any): Promise<boolean | undefined> {
|
||||||
|
const agent = liveRuns.get(request.threadId);
|
||||||
|
if (!agent) return false;
|
||||||
|
try {
|
||||||
|
agent.abortRun();
|
||||||
|
liveRuns.delete(request.threadId);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runtime = new CopilotRuntime({
|
const runtime = new CopilotRuntime({
|
||||||
agents: { wealthysmart: agent },
|
agents: { wealthysmart: makeWealthysmartAgent() },
|
||||||
a2ui: { injectA2UITool: true },
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
runner: new PostgresAgentRunner() as any,
|
||||||
|
// a2ui disabled 2026-07-04: CK 1.60+ validates generated ops against a
|
||||||
|
// catalog and our agent's ops fail validation (surface never paints).
|
||||||
|
// Markdown tables cover the use case until the pipeline matures.
|
||||||
|
a2ui: { enabled: false },
|
||||||
beforeRequestMiddleware: async ({ request: outbound }) => {
|
beforeRequestMiddleware: async ({ request: outbound }) => {
|
||||||
if (outbound.method !== "POST") return;
|
if (outbound.method !== "POST") return;
|
||||||
const ct = outbound.headers.get("content-type") ?? "";
|
const ct = outbound.headers.get("content-type") ?? "";
|
||||||
if (!ct.includes("application/json")) return;
|
if (!ct.includes("application/json")) return;
|
||||||
try {
|
try {
|
||||||
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
|
const body = (await outbound.clone().json()) as {
|
||||||
|
messages?: AGUIMessage[];
|
||||||
|
context?: { description?: string; value?: string }[];
|
||||||
|
};
|
||||||
if (!Array.isArray(body.messages)) return;
|
if (!Array.isArray(body.messages)) return;
|
||||||
const paired = pairOrphanToolCalls(body.messages);
|
// An empty messages array is MAF's hydration trigger (known threadId
|
||||||
if (paired.length === body.messages.length) return;
|
// + no messages → replay the stored snapshot without invoking the
|
||||||
|
// LLM). Injecting anything would turn it into a normal run.
|
||||||
|
if (body.messages.length === 0) return;
|
||||||
|
let messages = pairOrphanToolCalls(body.messages);
|
||||||
|
|
||||||
|
// MAF's AG-UI endpoint declares `context` on its request model but
|
||||||
|
// never feeds it to the model (agent_framework_ag_ui, checked at
|
||||||
|
// python-1.10.0). CopilotKit 1.60+ ships the A2UI catalog schema and
|
||||||
|
// guidelines in that context — without them the model emits ops the
|
||||||
|
// A2UI validator rejects and the surface never paints. Inject the
|
||||||
|
// context as a system message ourselves. Remove once MAF consumes
|
||||||
|
// RunAgentInput.context.
|
||||||
|
if (Array.isArray(body.context) && body.context.length > 0) {
|
||||||
|
const contextText = body.context
|
||||||
|
.map((c) => `## ${c.description ?? "Context"}\n${c.value ?? ""}`)
|
||||||
|
.join("\n\n");
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
id: `ctx-${Date.now()}`,
|
||||||
|
role: "system",
|
||||||
|
content: `Additional run context:\n\n${contextText}`,
|
||||||
|
} as AGUIMessage,
|
||||||
|
...messages,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages === body.messages) return;
|
||||||
return new Request(outbound.url, {
|
return new Request(outbound.url, {
|
||||||
method: outbound.method,
|
method: outbound.method,
|
||||||
headers: outbound.headers,
|
headers: outbound.headers,
|
||||||
body: JSON.stringify({ ...body, messages: paired }),
|
body: JSON.stringify({ ...body, messages }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Skipping the repair is survivable; doing it silently is not —
|
// Skipping the repair is survivable; doing it silently is not —
|
||||||
// orphan tool calls then fail downstream with no trace of why.
|
// orphan tool calls then fail downstream with no trace of why.
|
||||||
console.error("[copilotkit] orphan-tool-call repair skipped:", err);
|
console.error("[copilotkit] outbound request repair skipped:", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
const ckHandler = createCopilotRuntimeHandler({
|
||||||
runtime,
|
runtime,
|
||||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
basePath: "/api/copilotkit",
|
||||||
endpoint: "/api/copilotkit",
|
// Single-route mode: same envelope protocol the client already resolved
|
||||||
|
// via auto-detect (its GET /info probe 405s here, so it stays "single").
|
||||||
|
// Multi-route would only add /threads* REST endpoints we don't use and is
|
||||||
|
// exposed to CopilotKit#4953 (frontend tools under multi-route).
|
||||||
|
mode: "single-route",
|
||||||
|
cors: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
|
// The v2 handler forwards only `authorization` and `x-*` headers to agents
|
||||||
});
|
// and to runner.connect — cookies never pass. Translate the auth cookie into
|
||||||
|
// a Bearer header before the handler sees the request.
|
||||||
|
function withBearerFromCookie(req: Request): Request {
|
||||||
|
if (req.headers.get("authorization")) return req;
|
||||||
|
const cookie = req.headers.get("cookie") ?? "";
|
||||||
|
const match = cookie.match(/(?:^|;\s*)ws_token=([^;]+)/);
|
||||||
|
if (!match) return req;
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.set("Authorization", `Bearer ${match[1]}`);
|
||||||
|
return new Request(req, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.all("/api/copilotkit/*", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
|
||||||
|
app.all("/api/copilotkit", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
|
||||||
|
|
||||||
app.get("/api/health", (c) => c.json({ ok: true }));
|
app.get("/api/health", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
import LoginPage from "./pages/Login";
|
import LoginPage from "./pages/Login";
|
||||||
|
import Inicio from "./pages/Inicio";
|
||||||
import Asistente from "./pages/Asistente";
|
import Asistente from "./pages/Asistente";
|
||||||
import Analytics from "./pages/Analytics";
|
import Analytics from "./pages/Analytics";
|
||||||
import Budget from "./pages/Budget";
|
import Budget from "./pages/Budget";
|
||||||
import Salarios from "./pages/Salarios";
|
import Salarios from "./pages/Salarios";
|
||||||
|
import Financiamientos from "./pages/Financiamientos";
|
||||||
import Pensions from "./pages/Pensions";
|
import Pensions from "./pages/Pensions";
|
||||||
import Proyecciones from "./pages/Proyecciones";
|
import Proyecciones from "./pages/Proyecciones";
|
||||||
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
||||||
@@ -41,7 +43,7 @@ function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
|
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@@ -50,12 +52,13 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/asistente" replace />} />
|
<Route index element={<Inicio />} />
|
||||||
<Route path="/asistente" element={<Asistente />} />
|
<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="/financiamientos" element={<Financiamientos />} />
|
||||||
<Route path="/pensions" element={<Pensions />} />
|
<Route path="/pensions" element={<Pensions />} />
|
||||||
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||||
<Route path="/sync" element={<SyncStatus />} />
|
<Route path="/sync" element={<SyncStatus />} />
|
||||||
@@ -74,7 +77,17 @@ export default function App() {
|
|||||||
<PrivacyProvider>
|
<PrivacyProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
|
<CopilotKit
|
||||||
|
runtimeUrl="/api/copilotkit"
|
||||||
|
agent="wealthysmart"
|
||||||
|
headers={{
|
||||||
|
// Lets the agent resolve "hoy"/"este mes" in the user's
|
||||||
|
// local time wherever they are; backend falls back to
|
||||||
|
// Costa Rica when absent.
|
||||||
|
"X-Client-Timezone":
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
<Toaster richColors position="top-right" closeButton />
|
<Toaster richColors position="top-right" closeButton />
|
||||||
</CopilotKit>
|
</CopilotKit>
|
||||||
|
|||||||
@@ -1,237 +1,105 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Eye, EyeOff, Moon, Sun } from "lucide-react";
|
||||||
import {
|
|
||||||
Sparkles,
|
|
||||||
Telescope,
|
|
||||||
Calculator,
|
|
||||||
BarChart3,
|
|
||||||
Landmark,
|
|
||||||
PiggyBank,
|
|
||||||
Droplets,
|
|
||||||
LogOut,
|
|
||||||
TrendingUp,
|
|
||||||
Wallet,
|
|
||||||
Menu,
|
|
||||||
RefreshCw,
|
|
||||||
Sun,
|
|
||||||
Moon,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { getSyncStatus } from "@/lib/api";
|
import { titleFor } from "@/lib/navigation";
|
||||||
import { useTheme } from "@/contexts/theme-context";
|
import { useTheme } from "@/contexts/theme-context";
|
||||||
import { usePrivacy } from "@/contexts/privacy-context";
|
import { usePrivacy } from "@/contexts/privacy-context";
|
||||||
import { useAuth } from "@/AuthContext";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
import { CommandMenu } from "@/components/command-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Breadcrumb,
|
||||||
SheetContent,
|
BreadcrumbItem,
|
||||||
SheetHeader,
|
BreadcrumbLink,
|
||||||
SheetTitle,
|
BreadcrumbList,
|
||||||
SheetClose,
|
BreadcrumbPage,
|
||||||
} from "@/components/ui/sheet";
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
|
SidebarInset,
|
||||||
interface NavSection {
|
SidebarProvider,
|
||||||
label: string;
|
SidebarTrigger,
|
||||||
items: { to: string; icon: LucideIcon; label: string }[];
|
} from "@/components/ui/sidebar";
|
||||||
}
|
|
||||||
|
|
||||||
const navSections: NavSection[] = [
|
|
||||||
{
|
|
||||||
label: "General",
|
|
||||||
items: [
|
|
||||||
{ to: "/asistente", icon: Sparkles, label: "Asistente" },
|
|
||||||
{ to: "/sync", icon: RefreshCw, label: "Sincronización" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Finanzas",
|
|
||||||
items: [
|
|
||||||
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
|
||||||
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
|
||||||
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
|
||||||
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
|
||||||
{ to: "/planificador", icon: Telescope, label: "Planificador" },
|
|
||||||
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Servicios",
|
|
||||||
items: [
|
|
||||||
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const isActive = (to: string) =>
|
|
||||||
pathname === to || pathname.startsWith(`${to}/`);
|
|
||||||
|
|
||||||
// Surface stalled ingestion sources as a dot on the Sincronización item.
|
|
||||||
const syncQ = useQuery({
|
|
||||||
queryKey: ['sync-status'],
|
|
||||||
queryFn: () => getSyncStatus().then((r) => r.data),
|
|
||||||
staleTime: 5 * 60_000,
|
|
||||||
});
|
|
||||||
const syncWarnings = syncQ.data?.warnings ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="flex flex-col gap-0.5 px-3">
|
|
||||||
{navSections.map((section) => (
|
|
||||||
<div key={section.label}>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 pt-4 pb-1">
|
|
||||||
{section.label}
|
|
||||||
</p>
|
|
||||||
{section.items.map(({ to, icon: Icon, label }) => (
|
|
||||||
<Link
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
onClick={onNavigate}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
|
||||||
isActive(to)
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
{label}
|
|
||||||
{to === "/sync" && syncWarnings > 0 && (
|
|
||||||
<span
|
|
||||||
className="ml-auto w-2 h-2 rounded-full bg-amber-500"
|
|
||||||
title={`${syncWarnings} fuente(s) atrasada(s)`}
|
|
||||||
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { privacyMode, togglePrivacy } = usePrivacy();
|
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||||
const { logout } = useAuth();
|
const { pathname } = useLocation();
|
||||||
const navigate = useNavigate();
|
const title = titleFor(pathname);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
// Asistente hosts its own scroll container (the chat), so its route caps
|
||||||
|
// the shell at the viewport; every other page keeps normal body scroll.
|
||||||
const handleLogout = async () => {
|
const fullHeight = pathname === "/asistente";
|
||||||
await logout();
|
|
||||||
navigate("/login", { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<SidebarProvider>
|
||||||
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
|
<AppSidebar />
|
||||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
<SidebarInset className={fullHeight ? "h-svh overflow-hidden" : undefined}>
|
||||||
<div className="flex items-center gap-2.5">
|
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mr-2 data-[orientation=vertical]:h-4"
|
||||||
|
/>
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{pathname === "/" ? (
|
||||||
|
<BreadcrumbPage>Inicio</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink render={<Link to="/" />}>
|
||||||
|
Inicio
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{pathname !== "/" && (
|
||||||
|
<>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>{title}</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<CommandMenu />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setMobileOpen(true)}
|
onClick={togglePrivacy}
|
||||||
title="Open menu"
|
title="Toggle privacy mode"
|
||||||
aria-label="Open menu"
|
aria-label="Toggle privacy mode"
|
||||||
className="md:hidden"
|
aria-pressed={privacyMode}
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
{privacyMode ? (
|
||||||
</Button>
|
<EyeOff className="w-4 h-4" />
|
||||||
<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} />
|
<Eye className="w-4 h-4" />
|
||||||
</div>
|
)}
|
||||||
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
|
|
||||||
Wealthy<span className="text-primary">Smart</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode" aria-pressed={privacyMode}>
|
|
||||||
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme" aria-pressed={theme === "dark"}>
|
|
||||||
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleLogout}
|
onClick={toggleTheme}
|
||||||
title="Sign out"
|
title="Toggle theme"
|
||||||
aria-label="Sign out"
|
aria-label="Toggle theme"
|
||||||
className="hidden md:inline-flex"
|
aria-pressed={theme === "dark"}
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
{theme === "dark" ? (
|
||||||
|
<Sun className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
<main className="flex min-h-0 flex-1 flex-col px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<div className="flex">
|
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col">
|
||||||
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
|
|
||||||
<div className="flex-1">
|
|
||||||
<SidebarNav />
|
|
||||||
</div>
|
|
||||||
<div className="px-3 pb-4">
|
|
||||||
<Separator className="mb-2" />
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Cerrar sesión
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
||||||
<SheetContent side="left" className="p-0 w-64">
|
|
||||||
<SheetHeader className="p-4">
|
|
||||||
<SheetTitle className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
|
||||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
|
||||||
</div>
|
|
||||||
<span style={{ fontFamily: "var(--font-heading)" }}>
|
|
||||||
Wealthy<span className="text-primary">Smart</span>
|
|
||||||
</span>
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex flex-col h-[calc(100%-65px)]">
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<SidebarNav onNavigate={() => setMobileOpen(false)} />
|
|
||||||
</div>
|
|
||||||
<div className="px-3 pb-4">
|
|
||||||
<Separator className="mb-2" />
|
|
||||||
<SheetClose render={<span />}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setMobileOpen(false);
|
|
||||||
void handleLogout();
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Cerrar sesión
|
|
||||||
</button>
|
|
||||||
</SheetClose>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</SidebarInset>
|
||||||
</div>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
177
frontend/src/components/SalaryEntryDialog.tsx
Normal file
177
frontend/src/components/SalaryEntryDialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { formatLocalDatetime } from '@/lib/format';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalaryEntryDialog({ onClose, onSaved }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
amount: '',
|
||||||
|
currency: 'CRC',
|
||||||
|
date: formatLocalDatetime(new Date()),
|
||||||
|
bank: 'BAC',
|
||||||
|
reference: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const amount = Number(form.amount);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
setError('Ingresá un monto mayor que cero.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post('/transactions/', {
|
||||||
|
amount,
|
||||||
|
currency: form.currency,
|
||||||
|
date: form.date,
|
||||||
|
bank: form.bank,
|
||||||
|
merchant: 'Salario',
|
||||||
|
transaction_type: 'SALARY',
|
||||||
|
source: 'TRANSFER',
|
||||||
|
reference: form.reference.trim() || null,
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
});
|
||||||
|
toast.success('Salario registrado');
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const status = err && typeof err === 'object' && 'response' in err
|
||||||
|
? (err as { response: { status: number } }).response.status
|
||||||
|
: null;
|
||||||
|
setError(
|
||||||
|
status === 409
|
||||||
|
? 'Ya existe una transacción con ese comprobante.'
|
||||||
|
: 'No se pudo registrar el salario. Intentá de nuevo.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Registrar salario</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Guardá el depósito recibido mientras configurás una nueva automatización.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="salary-amount">Monto</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-amount"
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Moneda</Label>
|
||||||
|
<Select value={form.currency} onValueChange={(value) => value && setForm({ ...form, currency: value })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="CRC">CRC (₡)</SelectItem>
|
||||||
|
<SelectItem value="USD">USD ($)</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR (€)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label htmlFor="salary-date">Fecha del depósito</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-date"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Banco</Label>
|
||||||
|
<Select value={form.bank} onValueChange={(value) => value && setForm({ ...form, bank: value })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="BAC">BAC</SelectItem>
|
||||||
|
<SelectItem value="BCR">BCR</SelectItem>
|
||||||
|
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="salary-reference">Comprobante</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-reference"
|
||||||
|
value={form.reference}
|
||||||
|
onChange={(e) => setForm({ ...form, reference: e.target.value })}
|
||||||
|
placeholder="Opcional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label htmlFor="salary-notes">Nota</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Ej. Quincena de julio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Registrar salario'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
Banknote,
|
Banknote,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -46,6 +47,12 @@ export interface TransactionListProps {
|
|||||||
showSourceIcon?: boolean;
|
showSourceIcon?: boolean;
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
onToggleDeferred?: (tx: Transaction) => void;
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
|
onConvertTasaCero?: (tx: Transaction) => void;
|
||||||
|
/** Extra toolbar content, e.g. source tabs (left) and the CSV button (right). */
|
||||||
|
toolbarLeft?: React.ReactNode;
|
||||||
|
toolbarRight?: React.ReactNode;
|
||||||
|
/** Period hint shown in the summary strip, e.g. "Ciclo 18 jun – 18 jul". */
|
||||||
|
summaryLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransactionList({
|
export default function TransactionList({
|
||||||
@@ -59,8 +66,12 @@ export default function TransactionList({
|
|||||||
emptyMessage = 'No transactions found',
|
emptyMessage = 'No transactions found',
|
||||||
showCategory = true,
|
showCategory = true,
|
||||||
showSourceIcon = false,
|
showSourceIcon = false,
|
||||||
addLabel = 'Add Transaction',
|
addLabel = 'Agregar transacción',
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
|
toolbarLeft,
|
||||||
|
toolbarRight,
|
||||||
|
summaryLabel,
|
||||||
}: TransactionListProps) {
|
}: TransactionListProps) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||||
@@ -165,6 +176,7 @@ export default function TransactionList({
|
|||||||
onEdit: handleEdit,
|
onEdit: handleEdit,
|
||||||
onDelete: (id) => setDeleteId(id),
|
onDelete: (id) => setDeleteId(id),
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
selection: {
|
selection: {
|
||||||
selected,
|
selected,
|
||||||
allSelected,
|
allSelected,
|
||||||
@@ -182,7 +194,7 @@ export default function TransactionList({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[showCategory, showSourceIcon, onToggleDeferred, selected, allSelected, transactions],
|
[showCategory, showSourceIcon, onToggleDeferred, onConvertTasaCero, selected, allSelected, transactions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleTransactions = pendingDeletes.size
|
const visibleTransactions = pendingDeletes.size
|
||||||
@@ -190,28 +202,76 @@ export default function TransactionList({
|
|||||||
: transactions;
|
: transactions;
|
||||||
const empty = visibleTransactions.length === 0 && !loading;
|
const empty = visibleTransactions.length === 0 && !loading;
|
||||||
|
|
||||||
|
// Net spend of the listed transactions per currency: COMPRA − DEVOLUCION,
|
||||||
|
// excluding Tasa Cero anchors (their cuotas are what count in the budget).
|
||||||
|
const totalsByCurrency = useMemo(() => {
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const tx of visibleTransactions) {
|
||||||
|
if (tx.is_installment_anchor) continue;
|
||||||
|
if (tx.transaction_type !== 'COMPRA' && tx.transaction_type !== 'DEVOLUCION') continue;
|
||||||
|
const sign = tx.transaction_type === 'DEVOLUCION' ? -1 : 1;
|
||||||
|
totals.set(tx.currency, (totals.get(tx.currency) ?? 0) + sign * tx.amount);
|
||||||
|
}
|
||||||
|
// CRC first, then the rest alphabetically
|
||||||
|
return [...totals.entries()].sort(([a], [b]) =>
|
||||||
|
a === 'CRC' ? -1 : b === 'CRC' ? 1 : a.localeCompare(b),
|
||||||
|
);
|
||||||
|
}, [visibleTransactions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Search + Add */}
|
<Card className="overflow-hidden">
|
||||||
<div className="flex items-center gap-3">
|
<CardContent className="p-0">
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
|
{/* Toolbar: source tabs · search · CSV · add */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 p-3 border-b border-border">
|
||||||
|
{toolbarLeft}
|
||||||
|
<div className="relative flex-1 min-w-44 max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
placeholder="Search merchants..."
|
placeholder="Buscar comercio…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{toolbarRight}
|
||||||
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
{addLabel}
|
{addLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary strip: period · count · total */}
|
||||||
|
{visibleTransactions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 px-4 py-2 border-b border-border bg-muted/30 text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{summaryLabel ? `${summaryLabel} · ` : ''}
|
||||||
|
{visibleTransactions.length} transaccion{visibleTransactions.length === 1 ? '' : 'es'}
|
||||||
|
</span>
|
||||||
|
{totalsByCurrency.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-muted-foreground"
|
||||||
|
title="Compras menos devoluciones de las transacciones listadas. Excluye compras convertidas a Tasa Cero (cuentan sus cuotas)."
|
||||||
|
>
|
||||||
|
Total:
|
||||||
|
{totalsByCurrency.map(([currency, net], i) => (
|
||||||
|
<span key={currency} data-sensitive className="font-mono font-semibold text-foreground">
|
||||||
|
{i > 0 && <span className="text-muted-foreground font-normal mr-1.5">+</span>}
|
||||||
|
{net < 0 ? '+' : ''}
|
||||||
|
{formatAmount(net, currency)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div className="hidden md:flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
|
<div className="hidden md:flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{selected.size} seleccionada{selected.size === 1 ? '' : 's'}
|
{selected.size} seleccionada{selected.size === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
@@ -288,8 +348,7 @@ export default function TransactionList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile list */}
|
{/* Mobile list */}
|
||||||
<Card className="md:hidden">
|
<div className="md:hidden divide-y divide-border">
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
|
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
|
||||||
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
|
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
|
||||||
@@ -331,6 +390,11 @@ export default function TransactionList({
|
|||||||
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
|
{tx.is_installment_anchor && (
|
||||||
|
<span className="text-[10px] text-violet-600 border border-violet-300 rounded px-1 shrink-0">
|
||||||
|
Tasa Cero
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
|
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
|
||||||
@@ -343,14 +407,30 @@ export default function TransactionList({
|
|||||||
data-sensitive
|
data-sensitive
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-sm font-medium shrink-0',
|
'font-mono text-sm font-medium shrink-0',
|
||||||
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
|
tx.transaction_type === 'DEVOLUCION' && 'text-primary',
|
||||||
|
(tx.deferred_to_next_cycle || tx.is_installment_anchor) &&
|
||||||
|
'opacity-50 line-through decoration-muted-foreground/60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
||||||
{formatAmount(tx.amount, tx.currency)}
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
{onToggleDeferred && (
|
{onConvertTasaCero &&
|
||||||
|
tx.transaction_type === 'COMPRA' &&
|
||||||
|
tx.installment_plan_id == null && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={tx.is_installment_anchor ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
|
||||||
|
aria-label={tx.is_installment_anchor ? 'Edit Tasa Cero plan' : 'Convert to Tasa Cero'}
|
||||||
|
onClick={() => onConvertTasaCero(tx)}
|
||||||
|
className={cn(tx.is_installment_anchor && 'text-violet-600')}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onToggleDeferred && tx.installment_plan_id == null && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -362,13 +442,13 @@ export default function TransactionList({
|
|||||||
<ArrowRightFromLine className="w-4 h-4" />
|
<ArrowRightFromLine className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
|
<Button variant="ghost" size="icon" title="Editar transacción" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Delete transaction"
|
title="Eliminar transacción"
|
||||||
aria-label="Delete transaction"
|
aria-label="Delete transaction"
|
||||||
onClick={() => setDeleteId(tx.id)}
|
onClick={() => setDeleteId(tx.id)}
|
||||||
className="hover:text-destructive"
|
className="hover:text-destructive"
|
||||||
@@ -379,12 +459,10 @@ export default function TransactionList({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Desktop table */}
|
{/* Desktop table */}
|
||||||
<Card className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<CardContent className="p-0">
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={visibleTransactions}
|
data={visibleTransactions}
|
||||||
@@ -393,6 +471,8 @@ export default function TransactionList({
|
|||||||
initialSorting={[{ id: 'date', desc: true }]}
|
initialSorting={[{ id: 'date', desc: true }]}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
100
frontend/src/components/app-sidebar.tsx
Normal file
100
frontend/src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Wallet } from "lucide-react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { getSyncStatus } from "@/lib/api";
|
||||||
|
import { isNavActive, navSections } from "@/lib/navigation";
|
||||||
|
import { NavUser } from "@/components/nav-user";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarRail,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
|
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
|
// Surface stalled ingestion sources as a dot on the Sincronización item.
|
||||||
|
const syncQ = useQuery({
|
||||||
|
queryKey: ["sync-status"],
|
||||||
|
queryFn: () => getSyncStatus().then((r) => r.data),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
const syncWarnings = syncQ.data?.warnings ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon" {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
render={<Link to="/" aria-label="Ir a Inicio" />}
|
||||||
|
>
|
||||||
|
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||||
|
<Wallet className="size-4" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-base font-bold tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--font-heading)" }}
|
||||||
|
>
|
||||||
|
Wealthy<span className="text-primary">Smart</span>
|
||||||
|
</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
{navSections.map((section) => (
|
||||||
|
<SidebarGroup key={section.label}>
|
||||||
|
<SidebarGroupLabel>{section.label}</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{section.items.map(({ to, icon: Icon, label }) => (
|
||||||
|
<SidebarMenuItem key={to}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={isNavActive(to, pathname)}
|
||||||
|
tooltip={label}
|
||||||
|
render={
|
||||||
|
<Link to={to} onClick={() => setOpenMobile(false)} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
<span>{label}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
{to === "/sync" && syncWarnings > 0 && (
|
||||||
|
<SidebarMenuBadge>
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-amber-500"
|
||||||
|
title={`${syncWarnings} fuente(s) atrasada(s)`}
|
||||||
|
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
|
||||||
|
/>
|
||||||
|
</SidebarMenuBadge>
|
||||||
|
)}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser />
|
||||||
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { PieChart, Pie, Cell } from 'recharts';
|
import { PieChart, Pie, Cell } from 'recharts';
|
||||||
|
|
||||||
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
|
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { categoricalColor, rampColor, MAX_SLICES } from '@/lib/charts';
|
||||||
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 { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -23,29 +22,6 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type PaletteMode = 'chatgpt' | 'gemini';
|
|
||||||
|
|
||||||
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
|
|
||||||
chatgpt: {
|
|
||||||
// Pure green scale, darkest → lightest (assigned by rank)
|
|
||||||
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
|
|
||||||
// Pure amber scale, darkest → lightest (assigned by rank)
|
|
||||||
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
|
|
||||||
// Warm-to-cool alternating for CC categories
|
|
||||||
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
|
|
||||||
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
|
|
||||||
},
|
|
||||||
gemini: {
|
|
||||||
// Qualitative greens: dark green, mint, pale green, forest
|
|
||||||
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
|
|
||||||
// Terracotta, slate blue, sage, sand — diverse hues
|
|
||||||
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
|
|
||||||
// Pastel/muted diverse for CC categories
|
|
||||||
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
|
|
||||||
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
|
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
|
||||||
CASH: { label: 'Efectivo', icon: Banknote },
|
CASH: { label: 'Efectivo', icon: Banknote },
|
||||||
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
|
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
|
||||||
@@ -91,16 +67,9 @@ function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof Tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
|
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
|
||||||
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
|
|
||||||
|
|
||||||
if (loading || !detail) {
|
if (loading || !detail) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
|
|
||||||
<Skeleton className="h-6 w-16" />
|
|
||||||
<Skeleton className="h-6 w-16" />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
|
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
|
||||||
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
|
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
|
||||||
@@ -172,32 +141,20 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
|
|
||||||
|
|
||||||
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
|
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
|
||||||
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
|
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
|
||||||
|
|
||||||
// For ChatGPT mode: assign colors by rank (largest = darkest)
|
// Sequential ramps: one hue per concept, largest slice = full token,
|
||||||
// For Gemini mode: assign colors by position (qualitative)
|
// smaller slices fade toward the surface (rank-ordered).
|
||||||
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
|
function buildRampMap(data: { name: string; value: number }[], baseVar: string): Map<string, string> {
|
||||||
if (paletteMode === 'chatgpt') {
|
|
||||||
const sorted = [...data].sort((a, b) => b.value - a.value);
|
const sorted = [...data].sort((a, b) => b.value - a.value);
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
sorted.forEach((item, i) => {
|
sorted.forEach((item, i) => map.set(item.name, rampColor(baseVar, i, sorted.length)));
|
||||||
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
// Gemini: positional
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
data.forEach((item, i) => {
|
|
||||||
map.set(item.name, colors[i % colors.length]);
|
|
||||||
});
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomeColorMap = buildColorMap(incomeData, incomeColors);
|
const incomeColorMap = buildRampMap(incomeData, 'var(--chart-1)');
|
||||||
const expenseColorMap = buildColorMap(expenseData, expenseColors);
|
const expenseColorMap = buildRampMap(expenseData, 'var(--chart-2)');
|
||||||
|
|
||||||
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
|
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
|
||||||
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
|
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
|
||||||
@@ -209,16 +166,28 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// CC spending by category
|
// CC by category: identity → categorical slots; past the 5th fold into "Otros".
|
||||||
const ccData = (detail.cc_by_category ?? []).map((item) => ({
|
const ccRaw = (detail.cc_by_category ?? []).map((item) => ({
|
||||||
name: item.category_name,
|
name: item.category_name,
|
||||||
value: item.amount,
|
value: item.amount,
|
||||||
}));
|
}));
|
||||||
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
|
const ccData =
|
||||||
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
|
ccRaw.length <= MAX_SLICES
|
||||||
|
? ccRaw
|
||||||
|
: [
|
||||||
|
...ccRaw.slice(0, MAX_SLICES),
|
||||||
|
{
|
||||||
|
name: 'Otros',
|
||||||
|
value: ccRaw.slice(MAX_SLICES).reduce((s, c) => s + c.value, 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const ccColorOf = (name: string) =>
|
||||||
|
categoricalColor(ccData.findIndex((c) => c.name === name), name);
|
||||||
|
const ccConfig = ccData.reduce<ChartConfig>((acc, item) => {
|
||||||
|
acc[item.name] = { label: item.name, color: ccColorOf(item.name) };
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0);
|
const ccTotal = ccRaw.reduce((sum, item) => sum + item.value, 0);
|
||||||
|
|
||||||
// Filter actuals to only cash and transfer (no credit card)
|
// Filter actuals to only cash and transfer (no credit card)
|
||||||
const cashTransferActuals = detail.actuals_by_source.filter(
|
const cashTransferActuals = detail.actuals_by_source.filter(
|
||||||
@@ -227,27 +196,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Palette Toggle */}
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
|
|
||||||
<Button
|
|
||||||
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="h-6 text-xs px-2"
|
|
||||||
onClick={() => setPaletteMode('chatgpt')}
|
|
||||||
>
|
|
||||||
ChatGPT
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="h-6 text-xs px-2"
|
|
||||||
onClick={() => setPaletteMode('gemini')}
|
|
||||||
>
|
|
||||||
Gemini
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pie Charts */}
|
{/* Pie Charts */}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* Income Pie */}
|
{/* Income Pie */}
|
||||||
@@ -274,6 +222,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
stroke="var(--card)"
|
stroke="var(--card)"
|
||||||
|
isAnimationActive={false}
|
||||||
>
|
>
|
||||||
{incomeData.map((item, i) => (
|
{incomeData.map((item, i) => (
|
||||||
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
|
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
|
||||||
@@ -340,6 +289,7 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
stroke="var(--card)"
|
stroke="var(--card)"
|
||||||
|
isAnimationActive={false}
|
||||||
>
|
>
|
||||||
{expenseData.map((item, i) => (
|
{expenseData.map((item, i) => (
|
||||||
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
|
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
|
||||||
@@ -406,9 +356,10 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
stroke="var(--card)"
|
stroke="var(--card)"
|
||||||
|
isAnimationActive={false}
|
||||||
>
|
>
|
||||||
{ccData.map((_, i) => (
|
{ccData.map((item) => (
|
||||||
<Cell key={i} fill={ccColors[i % ccColors.length]} />
|
<Cell key={item.name} fill={ccColorOf(item.name)} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -424,11 +375,11 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
|
||||||
{ccData.map((item, i) => (
|
{ccData.map((item) => (
|
||||||
<div key={item.name} className="flex items-center gap-1.5 text-xs">
|
<div key={item.name} className="flex items-center gap-1.5 text-xs">
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full shrink-0"
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
style={{ background: ccColors[i % ccColors.length] }}
|
style={{ background: ccColorOf(item.name) }}
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-muted-foreground">{item.name}</span>
|
<span className="truncate text-muted-foreground">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
|
||||||
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
|
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
|
||||||
{ value: 'INCOME', label: 'Ingreso' },
|
{ value: 'INCOME', label: 'Ingreso' },
|
||||||
@@ -73,11 +74,13 @@ export default function RecurringItemDialog({
|
|||||||
const snapshot = () =>
|
const snapshot = () =>
|
||||||
JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]);
|
JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]);
|
||||||
|
|
||||||
|
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||||
|
|
||||||
const handleOpenChange = (next: boolean) => {
|
const handleOpenChange = (next: boolean) => {
|
||||||
if (!next && !saving && snapshot() !== baseline) {
|
if (!next && !saving && snapshot() !== baseline) {
|
||||||
// Closing with unsaved edits — confirm before discarding (UX-06).
|
// Closing with unsaved edits — confirm before discarding (UX-06).
|
||||||
const discard = window.confirm('Hay cambios sin guardar. ¿Descartarlos?');
|
setConfirmDiscard(true);
|
||||||
if (!discard) return;
|
return;
|
||||||
}
|
}
|
||||||
onOpenChange(next);
|
onOpenChange(next);
|
||||||
};
|
};
|
||||||
@@ -338,6 +341,19 @@ export default function RecurringItemDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{confirmDiscard && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Cambios sin guardar"
|
||||||
|
message="Hay cambios sin guardar. ¿Descartarlos?"
|
||||||
|
confirmLabel="Descartar"
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmDiscard(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmDiscard(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
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';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
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';
|
||||||
import { Pencil, Plus, Trash2 } from 'lucide-react';
|
import { Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
@@ -153,6 +154,8 @@ export default function RecurringItemsManager({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -161,6 +164,8 @@ export default function RecurringItemsManager({
|
|||||||
initialSorting={[{ id: 'item_type', desc: false }]}
|
initialSorting={[{ id: 'item_type', desc: false }]}
|
||||||
emptyMessage="No hay items recurrentes."
|
emptyMessage="No hay items recurrentes."
|
||||||
/>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<RecurringItemDialog
|
<RecurringItemDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
@@ -76,7 +76,10 @@ export function SpendingSummaryCard({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
<div
|
||||||
|
data-testid="spending-summary-card"
|
||||||
|
className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
143
frontend/src/components/command-menu.tsx
Normal file
143
frontend/src/components/command-menu.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Eye, EyeOff, Moon, Search, Sparkles, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
import { navSections } from "@/lib/navigation";
|
||||||
|
import { useTheme } from "@/contexts/theme-context";
|
||||||
|
import { usePrivacy } from "@/contexts/privacy-context";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Kbd } from "@/components/ui/kbd";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
|
||||||
|
/** Global ⌘K palette: jump to any page, quick-toggle privacy/theme, or send
|
||||||
|
* what you typed straight to the Asistente (reuses the Inicio ask flow). */
|
||||||
|
export function CommandMenu() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", down);
|
||||||
|
return () => document.removeEventListener("keydown", down);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const run = (fn: () => void) => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ask = query.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="hidden sm:flex w-44 justify-start gap-2 text-muted-foreground font-normal"
|
||||||
|
aria-label="Abrir buscador (Cmd+K)"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" aria-hidden />
|
||||||
|
Buscar…
|
||||||
|
<Kbd className="ml-auto">⌘K</Kbd>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="sm:hidden"
|
||||||
|
title="Buscar"
|
||||||
|
aria-label="Abrir buscador"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) setQuery("");
|
||||||
|
}}
|
||||||
|
title="Buscador"
|
||||||
|
description="Ir a una página o preguntarle al asistente"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Ir a una página o preguntar…"
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Sin resultados.</CommandEmpty>
|
||||||
|
{navSections.map((section) => (
|
||||||
|
<CommandGroup key={section.label} heading={section.label}>
|
||||||
|
{section.items.map(({ to, icon: Icon, label }) => (
|
||||||
|
<CommandItem
|
||||||
|
key={to}
|
||||||
|
value={`${section.label} ${label}`}
|
||||||
|
onSelect={() => run(() => navigate(to))}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden />
|
||||||
|
{label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Acciones">
|
||||||
|
<CommandItem
|
||||||
|
value="modo privado privacidad"
|
||||||
|
onSelect={() => run(togglePrivacy)}
|
||||||
|
>
|
||||||
|
{privacyMode ? <EyeOff aria-hidden /> : <Eye aria-hidden />}
|
||||||
|
{privacyMode ? "Desactivar modo privado" : "Activar modo privado"}
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
value="tema oscuro claro"
|
||||||
|
onSelect={() => run(toggleTheme)}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Sun aria-hidden /> : <Moon aria-hidden />}
|
||||||
|
{theme === "dark" ? "Tema claro" : "Tema oscuro"}
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
{ask.length > 2 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Asistente">
|
||||||
|
{/* value includes the query so cmdk never filters it out */}
|
||||||
|
<CommandItem
|
||||||
|
value={`preguntar ${ask}`}
|
||||||
|
onSelect={() =>
|
||||||
|
run(() => navigate("/asistente", { state: { ask } }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="text-primary" aria-hidden />
|
||||||
|
Preguntar: “{ask}”
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</CommandDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
frontend/src/components/nav-user.tsx
Normal file
108
frontend/src/components/nav-user.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { ChevronsUpDown, Eye, EyeOff, LogOut, Moon, Sun } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
|
import { useTheme } from "@/contexts/theme-context";
|
||||||
|
import { usePrivacy } from "@/contexts/privacy-context";
|
||||||
|
import { useAuth } from "@/AuthContext";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
|
export function NavUser() {
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const meQ = useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => getMe().then((r) => r.data),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
const username = meQ.data?.username ?? "Usuario";
|
||||||
|
const initials = username.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-medium">{username}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
WealthySmart
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-fit min-w-48"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-medium">{username}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
WealthySmart
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={togglePrivacy}>
|
||||||
|
{privacyMode ? <EyeOff /> : <Eye />}
|
||||||
|
{privacyMode ? "Desactivar modo privado" : "Modo privado"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={toggleTheme}>
|
||||||
|
{theme === "dark" ? <Sun /> : <Moon />}
|
||||||
|
{theme === "dark" ? "Tema claro" : "Tema oscuro"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => void handleLogout()}>
|
||||||
|
<LogOut />
|
||||||
|
Cerrar sesión
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/page-header.tsx
Normal file
41
frontend/src/components/page-header.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
/** Standard page header: icon tile + title + optional subtitle on the left,
|
||||||
|
* page-level actions (period navigators, CSV, refresh…) on the right.
|
||||||
|
* Every page uses this — the breadcrumb in the shell header stays the only
|
||||||
|
* other place a page identifies itself. */
|
||||||
|
export function PageHeader({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--font-heading)" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-2">{actions}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/src/components/stat-tile.tsx
Normal file
115
frontend/src/components/stat-tile.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
/** Stat-tile contract (dataviz skill): label · value · optional delta ·
|
||||||
|
* optional context/detail rows. Values are pre-formatted by the caller
|
||||||
|
* (formatAmount etc.) and blur under privacy mode via data-sensitive. */
|
||||||
|
export interface StatTileProps {
|
||||||
|
label: string;
|
||||||
|
/** Small line under the label, e.g. "Ciclo al 18 jul". */
|
||||||
|
description?: string;
|
||||||
|
/** Pre-formatted headline value. null renders an em dash. */
|
||||||
|
value: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
/** Signed, pre-formatted delta plus whether it's good news (colors it). */
|
||||||
|
delta?: { text: string; positive: boolean };
|
||||||
|
/** Muted line(s) or detail rows under the value. */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
/** Makes the whole tile a link (adds hover border + focus ring). */
|
||||||
|
to?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Extra detail revealed on hover (HoverCard). */
|
||||||
|
hoverContent?: React.ReactNode;
|
||||||
|
/** Set false for non-monetary values that shouldn't blur (counts, dates). */
|
||||||
|
sensitive?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatTile({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
loading = false,
|
||||||
|
delta,
|
||||||
|
children,
|
||||||
|
to,
|
||||||
|
ariaLabel,
|
||||||
|
hoverContent,
|
||||||
|
sensitive = true,
|
||||||
|
className,
|
||||||
|
}: StatTileProps) {
|
||||||
|
const card = (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"h-full",
|
||||||
|
to && "transition-colors hover:border-primary/40 cursor-pointer",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</CardTitle>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground/80">{description}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-32" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
{...(sensitive ? { "data-sensitive": true } : {})}
|
||||||
|
className="text-2xl font-bold font-mono"
|
||||||
|
>
|
||||||
|
{value ?? "—"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!loading && delta && (
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-mono font-medium",
|
||||||
|
delta.positive ? "text-primary" : "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{delta.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapped = to ? (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
aria-label={ariaLabel ?? label}
|
||||||
|
className="block h-full rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
card
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hoverContent) return wrapped;
|
||||||
|
return (
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger render={<div className="h-full" />}>
|
||||||
|
{wrapped}
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-80">{hoverContent}</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createInstallmentPlan,
|
||||||
|
deleteInstallmentPlan,
|
||||||
|
getInstallmentPlans,
|
||||||
|
updateInstallmentPlan,
|
||||||
|
type InstallmentPlan,
|
||||||
|
type Transaction,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
/** Mirror of the backend cuota split (services/installments.py): truncate to
|
||||||
|
* 0.10, last cuota absorbs the remainder. Preview only — the backend is the
|
||||||
|
* source of truth. */
|
||||||
|
function computeAmounts(total: number, n: number): number[] {
|
||||||
|
const per = Math.floor((total / n) * 10) / 10;
|
||||||
|
const last = Math.round((total - per * (n - 1)) * 100) / 100;
|
||||||
|
return [...Array<number>(n - 1).fill(per), last];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of the backend schedule: cuota 1 on the chosen date, then the 19th
|
||||||
|
* of consecutive billing-cycle months (cycles cut on the 18th). */
|
||||||
|
function computeDates(first: Date, n: number): Date[] {
|
||||||
|
let cy = first.getFullYear();
|
||||||
|
let cm = first.getMonth();
|
||||||
|
if (first.getDate() < 18) {
|
||||||
|
cm -= 1;
|
||||||
|
if (cm < 0) {
|
||||||
|
cm = 11;
|
||||||
|
cy -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dates = [first];
|
||||||
|
for (let i = 1; i < n; i++) dates.push(new Date(cy, cm + i, 19));
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInputValue(iso: string): string {
|
||||||
|
return iso.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConvertToInstallmentsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
/** Create mode: the transaction to convert. If it is already an anchor,
|
||||||
|
* its plan is looked up and the dialog switches to edit mode. */
|
||||||
|
tx?: Transaction | null;
|
||||||
|
/** Edit mode: the plan to edit (e.g. from the Financiamientos page). */
|
||||||
|
plan?: InstallmentPlan | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConvertToInstallmentsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
|
tx,
|
||||||
|
plan,
|
||||||
|
}: ConvertToInstallmentsDialogProps) {
|
||||||
|
// An anchor transaction without an explicit plan -> find it in the list.
|
||||||
|
const needsLookup = open && !plan && !!tx?.is_installment_anchor;
|
||||||
|
const plansQ = useQuery({
|
||||||
|
queryKey: ['installment-plans'],
|
||||||
|
queryFn: ({ signal: _s }) => getInstallmentPlans().then((r) => r.data),
|
||||||
|
enabled: needsLookup,
|
||||||
|
});
|
||||||
|
const effectivePlan =
|
||||||
|
plan ??
|
||||||
|
(needsLookup
|
||||||
|
? plansQ.data?.plans.find((p) => p.anchor_transaction_id === tx?.id) ?? null
|
||||||
|
: null);
|
||||||
|
const isEdit = !!effectivePlan;
|
||||||
|
|
||||||
|
const total = effectivePlan ? effectivePlan.total_amount : tx?.amount ?? 0;
|
||||||
|
const currency = effectivePlan ? effectivePlan.currency : tx?.currency ?? 'CRC';
|
||||||
|
const merchant = effectivePlan ? effectivePlan.merchant : tx?.merchant ?? '';
|
||||||
|
const purchaseDate = effectivePlan
|
||||||
|
? effectivePlan.purchase_date
|
||||||
|
: tx?.date ?? new Date().toISOString();
|
||||||
|
|
||||||
|
const [numStr, setNumStr] = useState('3');
|
||||||
|
const [confirmUnconvert, setConfirmUnconvert] = useState(false);
|
||||||
|
const [firstDate, setFirstDate] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setNumStr(String(effectivePlan?.num_installments ?? 3));
|
||||||
|
setFirstDate(
|
||||||
|
toDateInputValue(effectivePlan?.first_installment_date ?? purchaseDate),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, effectivePlan?.id, tx?.id]);
|
||||||
|
|
||||||
|
const n = parseInt(numStr, 10);
|
||||||
|
const validN = Number.isFinite(n) && n >= 2 && n <= 48;
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
if (!validN || !firstDate) return [];
|
||||||
|
const amounts = computeAmounts(total, n);
|
||||||
|
const dates = computeDates(new Date(`${firstDate}T00:00:00`), n);
|
||||||
|
return amounts.map((amount, i) => ({ amount, date: dates[i] }));
|
||||||
|
}, [validN, n, firstDate, total]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!validN) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (isEdit && effectivePlan) {
|
||||||
|
await updateInstallmentPlan(effectivePlan.id, {
|
||||||
|
num_installments: n,
|
||||||
|
first_installment_date: firstDate,
|
||||||
|
});
|
||||||
|
toast.success('Plan Tasa Cero actualizado');
|
||||||
|
} else if (tx) {
|
||||||
|
await createInstallmentPlan({
|
||||||
|
transaction_id: tx.id,
|
||||||
|
num_installments: n,
|
||||||
|
first_installment_date: firstDate,
|
||||||
|
});
|
||||||
|
toast.success(`Convertida a Tasa Cero (${n} cuotas)`);
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo guardar el plan Tasa Cero');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnconvert = async () => {
|
||||||
|
if (!effectivePlan) return;
|
||||||
|
setConfirmUnconvert(false);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await deleteInstallmentPlan(effectivePlan.id);
|
||||||
|
toast.success('Plan Tasa Cero eliminado');
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo eliminar el plan');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !saving && onOpenChange(next)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="rounded-lg border bg-muted/40 px-3 py-2 text-sm">
|
||||||
|
<p className="font-medium truncate">{merchant}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<span data-sensitive className="font-mono">
|
||||||
|
{formatAmount(total, currency)}
|
||||||
|
</span>
|
||||||
|
{' — '}
|
||||||
|
{new Date(purchaseDate).toLocaleDateString('es-CR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsLookup && plansQ.isPending ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Cargando plan…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tc-cuotas">Número de cuotas</Label>
|
||||||
|
<Input
|
||||||
|
id="tc-cuotas"
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={48}
|
||||||
|
value={numStr}
|
||||||
|
onChange={(e) => setNumStr(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tc-first">Primera cuota</Label>
|
||||||
|
<Input
|
||||||
|
id="tc-first"
|
||||||
|
type="date"
|
||||||
|
value={firstDate}
|
||||||
|
onChange={(e) => setFirstDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Cuotas proyectadas
|
||||||
|
</Label>
|
||||||
|
<div className="rounded-lg border divide-y divide-border max-h-44 overflow-y-auto">
|
||||||
|
{preview.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{String(i + 1).padStart(2, '0')}/
|
||||||
|
{String(n).padStart(2, '0')} —{' '}
|
||||||
|
{c.date.toLocaleDateString('es-CR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span data-sensitive className="font-mono">
|
||||||
|
{formatAmount(c.amount, currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
{isEdit ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setConfirmUnconvert(true)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Deshacer Tasa Cero
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !validN || !firstDate || (needsLookup && !effectivePlan)}
|
||||||
|
>
|
||||||
|
{saving ? 'Guardando…' : isEdit ? 'Guardar' : 'Convertir'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{confirmUnconvert && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Deshacer Tasa Cero"
|
||||||
|
message="Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. Esta acción no se puede deshacer."
|
||||||
|
confirmLabel="Deshacer plan"
|
||||||
|
loading={saving}
|
||||||
|
onConfirm={handleUnconvert}
|
||||||
|
onCancel={() => setConfirmUnconvert(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
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, Layers, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
import { type Transaction } from '@/lib/api';
|
import { type Transaction } from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
@@ -21,6 +21,7 @@ interface TransactionColumnOptions {
|
|||||||
onEdit: (tx: Transaction) => void;
|
onEdit: (tx: Transaction) => void;
|
||||||
onDelete: (txId: number) => void;
|
onDelete: (txId: number) => void;
|
||||||
onToggleDeferred?: (tx: Transaction) => void;
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
|
onConvertTasaCero?: (tx: Transaction) => void;
|
||||||
selection?: RowSelection;
|
selection?: RowSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export function getTransactionColumns({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
selection,
|
selection,
|
||||||
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
||||||
const columns: ColumnDef<Transaction, unknown>[] = [];
|
const columns: ColumnDef<Transaction, unknown>[] = [];
|
||||||
@@ -63,7 +65,7 @@ export function getTransactionColumns({
|
|||||||
columns.push(
|
columns.push(
|
||||||
{
|
{
|
||||||
accessorKey: 'date',
|
accessorKey: 'date',
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Fecha" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
|
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
|
||||||
{new Date(row.original.date).toLocaleDateString('es-CR', {
|
{new Date(row.original.date).toLocaleDateString('es-CR', {
|
||||||
@@ -76,7 +78,7 @@ export function getTransactionColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'merchant',
|
accessorKey: 'merchant',
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Merchant" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Comercio" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -107,6 +109,16 @@ export function getTransactionColumns({
|
|||||||
Diferida
|
Diferida
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{tx.is_installment_anchor && (
|
||||||
|
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-violet-600 border-violet-300">
|
||||||
|
Tasa Cero
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{tx.installment_plan_id != null && (
|
||||||
|
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-violet-500/80 border-violet-200">
|
||||||
|
Cuota
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -117,7 +129,7 @@ export function getTransactionColumns({
|
|||||||
columns.push({
|
columns.push({
|
||||||
accessorFn: (row) => row.category?.name ?? '',
|
accessorFn: (row) => row.category?.name ?? '',
|
||||||
id: 'category',
|
id: 'category',
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Categoría" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const category = row.original.category;
|
const category = row.original.category;
|
||||||
return category ? (
|
return category ? (
|
||||||
@@ -134,7 +146,7 @@ export function getTransactionColumns({
|
|||||||
accessorKey: 'amount',
|
accessorKey: 'amount',
|
||||||
meta: { className: 'text-right' },
|
meta: { className: 'text-right' },
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Amount" className="justify-end" />
|
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
@@ -144,7 +156,8 @@ export function getTransactionColumns({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'font-mono font-medium',
|
'font-mono font-medium',
|
||||||
tx.transaction_type !== 'COMPRA' && 'text-primary',
|
tx.transaction_type !== 'COMPRA' && 'text-primary',
|
||||||
tx.deferred_to_next_cycle && 'opacity-50 line-through decoration-muted-foreground/60',
|
(tx.deferred_to_next_cycle || tx.is_installment_anchor) &&
|
||||||
|
'opacity-50 line-through decoration-muted-foreground/60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
|
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
|
||||||
@@ -162,7 +175,21 @@ export function getTransactionColumns({
|
|||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{onToggleDeferred && (
|
{onConvertTasaCero &&
|
||||||
|
tx.transaction_type === 'COMPRA' &&
|
||||||
|
tx.installment_plan_id == null && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={tx.is_installment_anchor ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
|
||||||
|
aria-label={tx.is_installment_anchor ? 'Edit Tasa Cero plan' : 'Convert to Tasa Cero'}
|
||||||
|
onClick={() => onConvertTasaCero(tx)}
|
||||||
|
className={cn(tx.is_installment_anchor && 'text-violet-600')}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onToggleDeferred && tx.installment_plan_id == null && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -177,7 +204,7 @@ export function getTransactionColumns({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Edit transaction"
|
title="Editar transacción"
|
||||||
aria-label="Edit transaction"
|
aria-label="Edit transaction"
|
||||||
onClick={() => onEdit(tx)}
|
onClick={() => onEdit(tx)}
|
||||||
>
|
>
|
||||||
@@ -186,7 +213,7 @@ export function getTransactionColumns({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Delete transaction"
|
title="Eliminar transacción"
|
||||||
aria-label="Delete transaction"
|
aria-label="Delete transaction"
|
||||||
onClick={() => onDelete(tx.id)}
|
onClick={() => onDelete(tx.id)}
|
||||||
className="hover:text-destructive"
|
className="hover:text-destructive"
|
||||||
|
|||||||
109
frontend/src/components/ui/avatar.tsx
Normal file
109
frontend/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Root.Props & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Fallback.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
125
frontend/src/components/ui/breadcrumb.tsx
Normal file
125
frontend/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"a">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "a",
|
||||||
|
props: mergeProps<"a">(
|
||||||
|
{
|
||||||
|
className: cn("transition-colors hover:text-foreground", className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "breadcrumb-link",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
221
frontend/src/components/ui/calendar.tsx
Normal file
221
frontend/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
type Locale,
|
||||||
|
} from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
locale,
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
locale={locale}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString(locale?.code, { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative rounded-(--cell-radius)",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute inset-0 bg-popover opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"font-medium select-none",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-(--cell-size) select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] text-muted-foreground select-none",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn(
|
||||||
|
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_end
|
||||||
|
),
|
||||||
|
today: cn(
|
||||||
|
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: ({ ...props }) => (
|
||||||
|
<CalendarDayButton locale={locale} {...props} />
|
||||||
|
),
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
locale,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
19
frontend/src/components/ui/collapsible.tsx
Normal file
19
frontend/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||||
|
|
||||||
|
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
194
frontend/src/components/ui/command.tsx
Normal file
194
frontend/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
104
frontend/src/components/ui/empty.tsx
Normal file
104
frontend/src/components/ui/empty.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-header"
|
||||||
|
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-sm font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-content"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyMedia,
|
||||||
|
}
|
||||||
236
frontend/src/components/ui/field.tsx
Normal file
236
frontend/src/components/ui/field.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
49
frontend/src/components/ui/hover-card.tsx
Normal file
49
frontend/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
|
||||||
|
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 4,
|
||||||
|
...props
|
||||||
|
}: PreviewCardPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PreviewCardPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<PreviewCardPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PreviewCardPrimitive.Popup
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PreviewCardPrimitive.Positioner>
|
||||||
|
</PreviewCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
156
frontend/src/components/ui/input-group.tsx
Normal file
156
frontend/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"flex items-center gap-2 text-sm shadow-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants> & {
|
||||||
|
type?: "button" | "submit" | "reset"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
201
frontend/src/components/ui/item.tsx
Normal file
201
frontend/src/components/ui/item.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn(
|
||||||
|
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn("my-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent",
|
||||||
|
outline: "border-border",
|
||||||
|
muted: "border-transparent bg-muted/50",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "gap-2.5 px-3 py-2.5",
|
||||||
|
sm: "gap-2.5 px-3 py-2.5",
|
||||||
|
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps<"div">(
|
||||||
|
{
|
||||||
|
className: cn(itemVariants({ variant, size, className })),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "item",
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/kbd.tsx
Normal file
26
frontend/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
90
frontend/src/components/ui/popover.tsx
Normal file
90
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PopoverPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Title
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Description
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
81
frontend/src/components/ui/progress.tsx
Normal file
81
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: ProgressPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
value={value}
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn("flex flex-wrap gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ProgressTrack>
|
||||||
|
<ProgressIndicator />
|
||||||
|
</ProgressTrack>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Track
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-track"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ProgressPrimitive.Indicator.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className={cn("h-full bg-primary transition-all", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Label
|
||||||
|
className={cn("text-sm font-medium", className)}
|
||||||
|
data-slot="progress-label"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Value
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-value"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Progress,
|
||||||
|
ProgressTrack,
|
||||||
|
ProgressIndicator,
|
||||||
|
ProgressLabel,
|
||||||
|
ProgressValue,
|
||||||
|
}
|
||||||
723
frontend/src/components/ui/sidebar.tsx
Normal file
723
frontend/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: "expanded" | "collapsed"
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
dir,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
dir={dir}
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer hidden text-sidebar-foreground md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps<"div">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-group-label",
|
||||||
|
sidebar: "group-label",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
props: mergeProps<"button">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-group-action",
|
||||||
|
sidebar: "group-action",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
render,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"button"> &
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
const comp = useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
props: mergeProps<"button">(
|
||||||
|
{
|
||||||
|
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-menu-button",
|
||||||
|
sidebar: "menu-button",
|
||||||
|
size,
|
||||||
|
active: isActive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return comp
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
{comp}
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"button"> &
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
showOnHover?: boolean
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
props: mergeProps<"button">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
showOnHover &&
|
||||||
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-menu-action",
|
||||||
|
sidebar: "menu-action",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const [width] = React.useState(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
render,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"a"> &
|
||||||
|
React.ComponentProps<"a"> & {
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "a",
|
||||||
|
props: mergeProps<"a">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-menu-sub-button",
|
||||||
|
sidebar: "menu-sub-button",
|
||||||
|
size,
|
||||||
|
active: isActive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
||||||
10
frontend/src/components/ui/spinner.tsx
Normal file
10
frontend/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
89
frontend/src/components/ui/toggle-group.tsx
Normal file
89
frontend/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||||
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle"
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
spacing: 2,
|
||||||
|
orientation: "horizontal",
|
||||||
|
})
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
spacing = 2,
|
||||||
|
orientation = "horizontal",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ToggleGroupPrimitive.Props &
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-spacing={spacing}
|
||||||
|
data-orientation={orientation}
|
||||||
|
style={{ "--gap": spacing } as React.CSSProperties}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider
|
||||||
|
value={{ variant, size, spacing, orientation }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
data-spacing={context.spacing}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TogglePrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
43
frontend/src/components/ui/toggle.tsx
Normal file
43
frontend/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border border-input bg-transparent hover:bg-muted",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
64
frontend/src/components/ui/tooltip.tsx
Normal file
64
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
19
frontend/src/hooks/use-mobile.ts
Normal file
19
frontend/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
@@ -16,11 +16,11 @@ import {
|
|||||||
|
|
||||||
/** All budget data hangs off the ['budget', ...] key family; every mutation
|
/** All budget data hangs off the ['budget', ...] key family; every mutation
|
||||||
* invalidates the family so projection, month detail and items stay in sync. */
|
* invalidates the family so projection, month detail and items stay in sync. */
|
||||||
export function useBudget(initialYear: number) {
|
export function useBudget(initialYear: number, initialMonth?: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [year, setYear] = useState(initialYear);
|
const [year, setYear] = useState(initialYear);
|
||||||
const [selectedMonth, setSelectedMonth] = useState<number>(
|
const [selectedMonth, setSelectedMonth] = useState<number>(
|
||||||
new Date().getMonth() + 1,
|
initialMonth ?? new Date().getMonth() + 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectionQ = useQuery({
|
const projectionQ = useQuery({
|
||||||
|
|||||||
@@ -28,11 +28,11 @@
|
|||||||
--border: oklch(0.92 0.004 286.32);
|
--border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
--chart-1: oklch(0.55 0.16 145);
|
--chart-1: oklch(0.55 0.15 187);
|
||||||
--chart-2: oklch(0.62 0.19 25);
|
--chart-2: oklch(0.62 0.14 78);
|
||||||
--chart-3: oklch(0.58 0.14 250);
|
--chart-3: oklch(0.50 0.14 300);
|
||||||
--chart-4: oklch(0.68 0.15 80);
|
--chart-4: oklch(0.56 0.18 27);
|
||||||
--chart-5: oklch(0.52 0.13 320);
|
--chart-5: oklch(0.55 0.13 252);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -65,11 +65,11 @@
|
|||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
--chart-1: oklch(0.60 0.16 145);
|
--chart-1: oklch(0.62 0.11 187);
|
||||||
--chart-2: oklch(0.67 0.19 25);
|
--chart-2: oklch(0.66 0.13 78);
|
||||||
--chart-3: oklch(0.63 0.14 250);
|
--chart-3: oklch(0.60 0.14 300);
|
||||||
--chart-4: oklch(0.73 0.15 80);
|
--chart-4: oklch(0.64 0.16 27);
|
||||||
--chart-5: oklch(0.57 0.13 320);
|
--chart-5: oklch(0.64 0.13 252);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.704 0.14 182.503);
|
--sidebar-primary: oklch(0.704 0.14 182.503);
|
||||||
|
|||||||
@@ -345,6 +345,23 @@ export interface paths {
|
|||||||
patch: operations["update_category_api_v1_categories__category_id__patch"];
|
patch: operations["update_category_api_v1_categories__category_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/chat/thread": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Clear Thread */
|
||||||
|
delete: operations["clear_thread_api_v1_chat_thread_delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/exchange-rate/": {
|
"/api/v1/exchange-rate/": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -396,6 +413,53 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/installment-plans/": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Installment Plans */
|
||||||
|
get: operations["list_installment_plans_api_v1_installment_plans__get"];
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Create Installment Plan
|
||||||
|
* @description Convert an existing credit-card purchase into a Tasa Cero plan.
|
||||||
|
*/
|
||||||
|
post: operations["create_installment_plan_api_v1_installment_plans__post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/installment-plans/{plan_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Installment Plan */
|
||||||
|
get: operations["get_installment_plan_api_v1_installment_plans__plan_id__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Delete Installment Plan
|
||||||
|
* @description Unconvert: remove the plan and its cuotas; the anchor becomes a normal
|
||||||
|
* transaction again.
|
||||||
|
*/
|
||||||
|
delete: operations["delete_installment_plan_api_v1_installment_plans__plan_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/**
|
||||||
|
* Update Installment Plan
|
||||||
|
* @description Edit a plan; cuota rows are regenerated (per-cuota edits are lost).
|
||||||
|
*/
|
||||||
|
patch: operations["update_installment_plan_api_v1_installment_plans__plan_id__patch"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/municipal-receipts/": {
|
"/api/v1/municipal-receipts/": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -851,11 +915,9 @@ export interface components {
|
|||||||
AGUIRequest: {
|
AGUIRequest: {
|
||||||
/**
|
/**
|
||||||
* Availableinterrupts
|
* Availableinterrupts
|
||||||
* @description List of interrupts that can be resumed by the server
|
* @description Canonical AG-UI interrupts that can be resumed by the server
|
||||||
*/
|
*/
|
||||||
availableInterrupts?: {
|
availableInterrupts?: components["schemas"]["Interrupt"][] | null;
|
||||||
[key: string]: unknown;
|
|
||||||
}[] | null;
|
|
||||||
/**
|
/**
|
||||||
* Context
|
* Context
|
||||||
* @description List of context objects provided to the agent
|
* @description List of context objects provided to the agent
|
||||||
@@ -884,11 +946,9 @@ export interface components {
|
|||||||
parent_run_id?: string | null;
|
parent_run_id?: string | null;
|
||||||
/**
|
/**
|
||||||
* Resume
|
* Resume
|
||||||
* @description Resume payload containing interrupt responses
|
* @description Resume payload for continuing interrupted runs
|
||||||
*/
|
*/
|
||||||
resume?: {
|
resume?: components["schemas"]["ResumeEntry"][] | null;
|
||||||
[key: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
/**
|
/**
|
||||||
* Run Id
|
* Run Id
|
||||||
* @description Optional run identifier for tracking
|
* @description Optional run identifier for tracking
|
||||||
@@ -1153,6 +1213,11 @@ export interface components {
|
|||||||
/** Name */
|
/** Name */
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
};
|
};
|
||||||
|
/** ClearThreadResponse */
|
||||||
|
ClearThreadResponse: {
|
||||||
|
/** Cleared */
|
||||||
|
cleared: boolean;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Currency
|
* Currency
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -1189,6 +1254,149 @@ export interface components {
|
|||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: components["schemas"]["ValidationError"][];
|
detail?: components["schemas"]["ValidationError"][];
|
||||||
};
|
};
|
||||||
|
/** InstallmentPlanCreate */
|
||||||
|
InstallmentPlanCreate: {
|
||||||
|
/** First Installment Date */
|
||||||
|
first_installment_date?: string | null;
|
||||||
|
/**
|
||||||
|
* Num Installments
|
||||||
|
* @default 3
|
||||||
|
*/
|
||||||
|
num_installments: number;
|
||||||
|
/** Transaction Id */
|
||||||
|
transaction_id: number;
|
||||||
|
};
|
||||||
|
/** InstallmentPlanDetail */
|
||||||
|
InstallmentPlanDetail: {
|
||||||
|
/** Anchor Transaction Id */
|
||||||
|
anchor_transaction_id: number;
|
||||||
|
/**
|
||||||
|
* Cuotas
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
|
cuotas: components["schemas"]["TransactionRead"][];
|
||||||
|
/** Cuotas Billed */
|
||||||
|
cuotas_billed: number;
|
||||||
|
/** Currency */
|
||||||
|
currency: string;
|
||||||
|
/**
|
||||||
|
* First Installment Date
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
first_installment_date: string;
|
||||||
|
/** Id */
|
||||||
|
id: number;
|
||||||
|
/** Installment Amount */
|
||||||
|
installment_amount: number;
|
||||||
|
/** Is Completed */
|
||||||
|
is_completed: boolean;
|
||||||
|
/** Last Installment Amount */
|
||||||
|
last_installment_amount: number;
|
||||||
|
/** Merchant */
|
||||||
|
merchant: string;
|
||||||
|
/** Next Cuota Date */
|
||||||
|
next_cuota_date?: string | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
/** Num Installments */
|
||||||
|
num_installments: number;
|
||||||
|
/** Paid Amount */
|
||||||
|
paid_amount: number;
|
||||||
|
/**
|
||||||
|
* Purchase Date
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
purchase_date: string;
|
||||||
|
/** Remaining Amount */
|
||||||
|
remaining_amount: number;
|
||||||
|
/** Total Amount */
|
||||||
|
total_amount: number;
|
||||||
|
};
|
||||||
|
/** InstallmentPlanListResponse */
|
||||||
|
InstallmentPlanListResponse: {
|
||||||
|
/** Plans */
|
||||||
|
plans: components["schemas"]["InstallmentPlanRead"][];
|
||||||
|
/** Total Remaining */
|
||||||
|
total_remaining: number;
|
||||||
|
};
|
||||||
|
/** InstallmentPlanRead */
|
||||||
|
InstallmentPlanRead: {
|
||||||
|
/** Anchor Transaction Id */
|
||||||
|
anchor_transaction_id: number;
|
||||||
|
/** Cuotas Billed */
|
||||||
|
cuotas_billed: number;
|
||||||
|
/** Currency */
|
||||||
|
currency: string;
|
||||||
|
/**
|
||||||
|
* First Installment Date
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
first_installment_date: string;
|
||||||
|
/** Id */
|
||||||
|
id: number;
|
||||||
|
/** Installment Amount */
|
||||||
|
installment_amount: number;
|
||||||
|
/** Is Completed */
|
||||||
|
is_completed: boolean;
|
||||||
|
/** Last Installment Amount */
|
||||||
|
last_installment_amount: number;
|
||||||
|
/** Merchant */
|
||||||
|
merchant: string;
|
||||||
|
/** Next Cuota Date */
|
||||||
|
next_cuota_date?: string | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
/** Num Installments */
|
||||||
|
num_installments: number;
|
||||||
|
/** Paid Amount */
|
||||||
|
paid_amount: number;
|
||||||
|
/**
|
||||||
|
* Purchase Date
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
purchase_date: string;
|
||||||
|
/** Remaining Amount */
|
||||||
|
remaining_amount: number;
|
||||||
|
/** Total Amount */
|
||||||
|
total_amount: number;
|
||||||
|
};
|
||||||
|
/** InstallmentPlanUpdate */
|
||||||
|
InstallmentPlanUpdate: {
|
||||||
|
/** First Installment Date */
|
||||||
|
first_installment_date?: string | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
/** Num Installments */
|
||||||
|
num_installments?: number | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Interrupt
|
||||||
|
* @description A pause carried inside ``RunFinishedEvent.outcome`` when the outcome is
|
||||||
|
* ``RunFinishedInterruptOutcome``. The client resumes
|
||||||
|
* by addressing this interrupt in the resume array of the next RunAgentInput.
|
||||||
|
*/
|
||||||
|
Interrupt: {
|
||||||
|
/** Expiresat */
|
||||||
|
expiresAt?: string | null;
|
||||||
|
/** Id */
|
||||||
|
id: string;
|
||||||
|
/** Message */
|
||||||
|
message?: string | null;
|
||||||
|
/** Metadata */
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
/** Reason */
|
||||||
|
reason: string;
|
||||||
|
/** Responseschema */
|
||||||
|
responseSchema?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
/** Toolcallid */
|
||||||
|
toolCallId?: string | null;
|
||||||
|
} & {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
/** LoginRequest */
|
/** LoginRequest */
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
/** Password */
|
/** Password */
|
||||||
@@ -1647,6 +1855,23 @@ export interface components {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ResumeEntry
|
||||||
|
* @description A per-interrupt response in the resume array of a RunAgentInput.
|
||||||
|
*/
|
||||||
|
ResumeEntry: {
|
||||||
|
/** Interruptid */
|
||||||
|
interruptId: string;
|
||||||
|
/** Payload */
|
||||||
|
payload?: unknown | null;
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
status: "resolved" | "cancelled";
|
||||||
|
} & {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
/** SalariosSummary */
|
/** SalariosSummary */
|
||||||
SalariosSummary: {
|
SalariosSummary: {
|
||||||
/** Count */
|
/** Count */
|
||||||
@@ -1857,6 +2082,13 @@ export interface components {
|
|||||||
deferred_to_next_cycle: boolean;
|
deferred_to_next_cycle: boolean;
|
||||||
/** Id */
|
/** Id */
|
||||||
id: number;
|
id: number;
|
||||||
|
/** Installment Plan Id */
|
||||||
|
installment_plan_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Is Installment Anchor
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
is_installment_anchor: boolean;
|
||||||
/** Merchant */
|
/** Merchant */
|
||||||
merchant: string;
|
merchant: string;
|
||||||
/** Notes */
|
/** Notes */
|
||||||
@@ -2250,6 +2482,8 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
cycle_year?: number | null;
|
cycle_year?: number | null;
|
||||||
cycle_month?: number | null;
|
cycle_month?: number | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
end_date?: string | null;
|
||||||
};
|
};
|
||||||
header?: {
|
header?: {
|
||||||
authorization?: string | null;
|
authorization?: string | null;
|
||||||
@@ -2286,6 +2520,8 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
cycle_year?: number | null;
|
cycle_year?: number | null;
|
||||||
cycle_month?: number | null;
|
cycle_month?: number | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
end_date?: string | null;
|
||||||
};
|
};
|
||||||
header?: {
|
header?: {
|
||||||
authorization?: string | null;
|
authorization?: string | null;
|
||||||
@@ -2850,6 +3086,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
clear_thread_api_v1_chat_thread_delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
authorization?: string | null;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
cookie?: {
|
||||||
|
ws_token?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ClearThreadResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
current_rate_api_v1_exchange_rate__get: {
|
current_rate_api_v1_exchange_rate__get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2955,6 +3224,183 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_installment_plans_api_v1_installment_plans__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
authorization?: string | null;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
cookie?: {
|
||||||
|
ws_token?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InstallmentPlanListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_installment_plan_api_v1_installment_plans__post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
authorization?: string | null;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
cookie?: {
|
||||||
|
ws_token?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InstallmentPlanCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InstallmentPlanRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_installment_plan_api_v1_installment_plans__plan_id__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
authorization?: string | null;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
plan_id: number;
|
||||||
|
};
|
||||||
|
cookie?: {
|
||||||
|
ws_token?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InstallmentPlanDetail"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_installment_plan_api_v1_installment_plans__plan_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
authorization?: string | null;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
plan_id: number;
|
||||||
|
};
|
||||||
|
cookie?: {
|
||||||
|
ws_token?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update_installment_plan_api_v1_installment_plans__plan_id__patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
authorization?: string | null;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
plan_id: number;
|
||||||
|
};
|
||||||
|
cookie?: {
|
||||||
|
ws_token?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InstallmentPlanUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InstallmentPlanRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_receipts_api_v1_municipal_receipts__get: {
|
list_receipts_api_v1_municipal_receipts__get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export async function logout() {
|
|||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
|
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getMe = () => api.get<{ username: string }>("/auth/me");
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type Account = Schema<'AccountRead'>;
|
export type Account = Schema<'AccountRead'>;
|
||||||
@@ -240,6 +242,40 @@ export const upsertBalanceOverride = (
|
|||||||
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}`);
|
||||||
|
|
||||||
|
// --- Transactions ---
|
||||||
|
|
||||||
|
export const getRecentTransactions = (limit = 5) =>
|
||||||
|
api.get<Transaction[]>("/transactions/recent", { params: { limit } });
|
||||||
|
|
||||||
|
// --- Installment Plans (Tasa Cero) ---
|
||||||
|
|
||||||
|
export type InstallmentPlan = Schema<'InstallmentPlanRead'>;
|
||||||
|
export type InstallmentPlanDetail = Schema<'InstallmentPlanDetail'>;
|
||||||
|
export type InstallmentPlanListResponse = Schema<'InstallmentPlanListResponse'>;
|
||||||
|
|
||||||
|
export interface InstallmentPlanCreate {
|
||||||
|
transaction_id: number;
|
||||||
|
num_installments?: number;
|
||||||
|
first_installment_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallmentPlanUpdate {
|
||||||
|
num_installments?: number;
|
||||||
|
first_installment_date?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getInstallmentPlans = () =>
|
||||||
|
api.get<InstallmentPlanListResponse>("/installment-plans/");
|
||||||
|
export const getInstallmentPlanDetail = (id: number) =>
|
||||||
|
api.get<InstallmentPlanDetail>(`/installment-plans/${id}`);
|
||||||
|
export const createInstallmentPlan = (data: InstallmentPlanCreate) =>
|
||||||
|
api.post<InstallmentPlan>("/installment-plans/", data);
|
||||||
|
export const updateInstallmentPlan = (id: number, data: InstallmentPlanUpdate) =>
|
||||||
|
api.patch<InstallmentPlan>(`/installment-plans/${id}`, data);
|
||||||
|
export const deleteInstallmentPlan = (id: number) =>
|
||||||
|
api.delete(`/installment-plans/${id}`);
|
||||||
|
|
||||||
// --- Salarios ---
|
// --- Salarios ---
|
||||||
|
|
||||||
export type SalariosSummary = Schema<'SalariosSummary'>;
|
export type SalariosSummary = Schema<'SalariosSummary'>;
|
||||||
|
|||||||
32
frontend/src/lib/charts.ts
Normal file
32
frontend/src/lib/charts.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/** Shared chart color system (see dataviz standard).
|
||||||
|
*
|
||||||
|
* - Categorical (identity): the five validated --chart-N slots, assigned in
|
||||||
|
* fixed order, NEVER cycled — anything past the 5th folds into "Otros".
|
||||||
|
* - Sequential (magnitude within one concept, e.g. income sources ranked by
|
||||||
|
* size): one hue ramped toward the surface via color-mix, which stays
|
||||||
|
* correct in both light and dark mode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SERIES_VARS = [
|
||||||
|
"var(--chart-1)",
|
||||||
|
"var(--chart-2)",
|
||||||
|
"var(--chart-3)",
|
||||||
|
"var(--chart-4)",
|
||||||
|
"var(--chart-5)",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const OTROS_COLOR = "var(--muted-foreground)";
|
||||||
|
export const MAX_SLICES = SERIES_VARS.length;
|
||||||
|
|
||||||
|
export function categoricalColor(index: number, name?: string): string {
|
||||||
|
if (name === "Otros" || name === "Sin categoría") return OTROS_COLOR;
|
||||||
|
return SERIES_VARS[Math.min(index, SERIES_VARS.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rank-ordered shade of one hue: rank 0 (largest) is the full token, later
|
||||||
|
* ranks fade toward the surface but never below 35% so they keep chroma. */
|
||||||
|
export function rampColor(baseVar: string, rank: number, count: number): string {
|
||||||
|
if (count <= 1 || rank <= 0) return baseVar;
|
||||||
|
const pct = Math.round(100 - (rank / Math.max(count - 1, 1)) * 65);
|
||||||
|
return `color-mix(in oklab, ${baseVar} ${pct}%, var(--background))`;
|
||||||
|
}
|
||||||
@@ -18,6 +18,29 @@ export function formatShortDate(dateStr: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Budget month M = the credit-card cycle ENDING on the 18th of M
|
||||||
|
* (matches get_cycle_range in the backend). After the 18th we are already
|
||||||
|
* in next month's cycle — including the Dec 19 → January-next-year wrap. */
|
||||||
|
export function currentBudgetCycle(today = new Date()): {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
} {
|
||||||
|
let year = today.getFullYear();
|
||||||
|
let month = today.getMonth() + 1;
|
||||||
|
if (today.getDate() > 18) {
|
||||||
|
month += 1;
|
||||||
|
if (month === 13) {
|
||||||
|
month = 1;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const end = new Date(year, month - 1, 18);
|
||||||
|
const start = new Date(year, month - 2, 18);
|
||||||
|
return { year, month, start, end };
|
||||||
|
}
|
||||||
|
|
||||||
/** "hace 3 días" / "hace 2 horas" / "hace un momento" */
|
/** "hace 3 días" / "hace 2 horas" / "hace un momento" */
|
||||||
export function formatRelativeAge(iso: string): string {
|
export function formatRelativeAge(iso: string): string {
|
||||||
const ms = Date.now() - new Date(iso).getTime();
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
|||||||
71
frontend/src/lib/navigation.ts
Normal file
71
frontend/src/lib/navigation.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Calculator,
|
||||||
|
Droplets,
|
||||||
|
Home,
|
||||||
|
Landmark,
|
||||||
|
Layers,
|
||||||
|
PiggyBank,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
Telescope,
|
||||||
|
TrendingUp,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/** Single source of truth for the sidebar nav and header breadcrumbs. */
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
to: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavSection {
|
||||||
|
label: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navSections: NavSection[] = [
|
||||||
|
{
|
||||||
|
label: "General",
|
||||||
|
items: [
|
||||||
|
{ to: "/", icon: Home, label: "Inicio" },
|
||||||
|
{ to: "/asistente", icon: Sparkles, label: "Asistente" },
|
||||||
|
{ to: "/sync", icon: RefreshCw, label: "Sincronización" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Finanzas",
|
||||||
|
items: [
|
||||||
|
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
||||||
|
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
||||||
|
{ to: "/financiamientos", icon: Layers, label: "Financiamientos" },
|
||||||
|
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
||||||
|
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
||||||
|
{ to: "/planificador", icon: Telescope, label: "Planificador" },
|
||||||
|
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Servicios",
|
||||||
|
items: [
|
||||||
|
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** "/" must match exactly — prefix-matching it would mark Inicio active everywhere. */
|
||||||
|
export function isNavActive(to: string, pathname: string): boolean {
|
||||||
|
if (to === "/") return pathname === "/";
|
||||||
|
return pathname === to || pathname.startsWith(`${to}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleFor(pathname: string): string {
|
||||||
|
for (const section of navSections) {
|
||||||
|
for (const item of section.items) {
|
||||||
|
if (isNavActive(item.to, pathname)) return item.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "WealthySmart";
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { type DateRange } from 'react-day-picker';
|
||||||
import {
|
import {
|
||||||
PieChart,
|
PieChart,
|
||||||
Pie,
|
Pie,
|
||||||
@@ -11,14 +12,32 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { BarChart3 } from 'lucide-react';
|
import { BarChart3, CalendarRange, ChartPie, X } from 'lucide-react';
|
||||||
|
|
||||||
import api, { getRateHistory } from '@/lib/api';
|
import api, { getRateHistory } from '@/lib/api';
|
||||||
|
import { categoricalColor, MAX_SLICES } from '@/lib/charts';
|
||||||
import ErrorState from '@/components/ErrorState';
|
import ErrorState from '@/components/ErrorState';
|
||||||
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '@/components/ui/empty';
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
@@ -47,45 +66,78 @@ interface DailySpending {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = [
|
|
||||||
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
|
|
||||||
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatCRC(value: number) {
|
function formatCRC(value: number) {
|
||||||
return `₡${Math.round(value).toLocaleString('es-CR')}`;
|
return `₡${Math.round(value).toLocaleString('es-CR')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trendChartConfig = {
|
/** Top 5 categories keep their own slice; the rest aggregate into "Otros". */
|
||||||
total_crc: {
|
function foldCategories(cats: CategorySpending[]) {
|
||||||
label: 'Total CRC',
|
if (cats.length <= MAX_SLICES) return cats;
|
||||||
color: 'var(--chart-1)',
|
const head = cats.slice(0, MAX_SLICES);
|
||||||
|
const rest = cats.slice(MAX_SLICES);
|
||||||
|
return [
|
||||||
|
...head,
|
||||||
|
{
|
||||||
|
category_id: null,
|
||||||
|
category_name: 'Otros',
|
||||||
|
total: rest.reduce((s, c) => s + c.total, 0),
|
||||||
|
count: rest.reduce((s, c) => s + c.count, 0),
|
||||||
|
percentage: +rest.reduce((s, c) => s + c.percentage, 0).toFixed(1),
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function ChartEmpty({ description }: { description: string }) {
|
||||||
|
return (
|
||||||
|
<Empty className="h-56">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<ChartPie />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>Sin datos</EmptyTitle>
|
||||||
|
<EmptyDescription>{description}</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendChartConfig = {
|
||||||
|
total_crc: { label: 'Total CRC', color: 'var(--chart-1)' },
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const rateChartConfig = {
|
const rateChartConfig = {
|
||||||
sell_rate: { label: 'Venta', color: 'var(--chart-1)' },
|
sell_rate: { label: 'Venta', color: 'var(--chart-1)' },
|
||||||
buy_rate: { label: 'Compra', color: 'var(--chart-2)' },
|
buy_rate: { label: 'Compra', color: 'var(--chart-5)' },
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const dailyChartConfig = {
|
const dailyChartConfig = {
|
||||||
total: {
|
total: { label: 'Gasto diario', color: 'var(--chart-1)' },
|
||||||
label: 'Gasto Diario',
|
|
||||||
color: 'var(--chart-2)',
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export default function Analytics() {
|
export default function Analytics() {
|
||||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
||||||
const cycleParams: Record<string, string> = cycle
|
const [range, setRange] = useState<DateRange | undefined>();
|
||||||
|
|
||||||
|
const toISODate = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
const nextDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
|
||||||
|
const rangeActive = !!(range?.from && range?.to);
|
||||||
|
// Range takes precedence over the billing-cycle filter (end is exclusive).
|
||||||
|
const periodParams: Record<string, string> = rangeActive
|
||||||
|
? {
|
||||||
|
start_date: toISODate(range!.from!),
|
||||||
|
end_date: toISODate(nextDay(range!.to!)),
|
||||||
|
}
|
||||||
|
: cycle
|
||||||
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
|
? { cycle_year: String(cycle.year), cycle_month: String(cycle.month) }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const byCategoryQ = useQuery({
|
const byCategoryQ = useQuery({
|
||||||
queryKey: ['analytics', 'by-category', cycle],
|
queryKey: ['analytics', 'by-category', cycle, range],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
api
|
api
|
||||||
.get<CategorySpending[]>('/analytics/by-category', { params: cycleParams, signal })
|
.get<CategorySpending[]>('/analytics/by-category', { params: periodParams, signal })
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
@@ -97,15 +149,16 @@ export default function Analytics() {
|
|||||||
api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data),
|
api.get<MonthlyTrend[]>('/analytics/monthly-trend', { signal }).then((r) => r.data),
|
||||||
});
|
});
|
||||||
const dailyQ = useQuery({
|
const dailyQ = useQuery({
|
||||||
queryKey: ['analytics', 'daily', cycle],
|
queryKey: ['analytics', 'daily', cycle, range],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
api
|
api
|
||||||
.get<DailySpending[]>('/analytics/daily-spending', { params: cycleParams, signal })
|
.get<DailySpending[]>('/analytics/daily-spending', { params: periodParams, signal })
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
|
|
||||||
const byCategory = byCategoryQ.data ?? [];
|
const byCategory = byCategoryQ.data ?? [];
|
||||||
|
const folded = foldCategories(byCategory);
|
||||||
const trend = trendQ.data ?? [];
|
const trend = trendQ.data ?? [];
|
||||||
const daily = dailyQ.data ?? [];
|
const daily = dailyQ.data ?? [];
|
||||||
const ratesQ = useQuery({
|
const ratesQ = useQuery({
|
||||||
@@ -125,27 +178,70 @@ export default function Analytics() {
|
|||||||
dailyQ.refetch();
|
dailyQ.refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build dynamic chart config for pie chart
|
const pieChartConfig = folded.reduce<ChartConfig>((acc, cat, i) => {
|
||||||
const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => {
|
|
||||||
acc[cat.category_name] = {
|
acc[cat.category_name] = {
|
||||||
label: cat.category_name,
|
label: cat.category_name,
|
||||||
color: COLORS[i % COLORS.length],
|
color: categoricalColor(i, cat.category_name),
|
||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<PageHeader
|
||||||
<div>
|
icon={BarChart3}
|
||||||
<div className="flex items-center gap-2">
|
title="Analytics"
|
||||||
<BarChart3 className="w-5 h-5 text-primary" />
|
subtitle="Desglose y tendencias de gasto"
|
||||||
<h1 className="text-2xl font-bold font-heading">Analytics</h1>
|
actions={
|
||||||
</div>
|
<>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Desglose y tendencias de gasto</p>
|
<Popover>
|
||||||
</div>
|
<PopoverTrigger
|
||||||
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
render={
|
||||||
</div>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={rangeActive ? 'text-foreground' : 'text-muted-foreground'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CalendarRange className="w-4 h-4 mr-2" aria-hidden />
|
||||||
|
{rangeActive
|
||||||
|
? `${range!.from!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })} – ${range!.to!.toLocaleDateString('es-CR', { day: 'numeric', month: 'short' })}`
|
||||||
|
: 'Rango'}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="end">
|
||||||
|
<Calendar
|
||||||
|
mode="range"
|
||||||
|
selected={range}
|
||||||
|
onSelect={(r) => {
|
||||||
|
setRange(r);
|
||||||
|
if (r?.from && r?.to) setCycle(null);
|
||||||
|
}}
|
||||||
|
numberOfMonths={2}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{rangeActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setRange(undefined)}
|
||||||
|
title="Quitar rango"
|
||||||
|
aria-label="Quitar rango de fechas"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<BillingCycleSelector
|
||||||
|
value={cycle}
|
||||||
|
onChange={(c) => {
|
||||||
|
setCycle(c);
|
||||||
|
if (c) setRange(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{anyError && (
|
{anyError && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
@@ -155,24 +251,22 @@ export default function Analytics() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Spending by Category - Donut */}
|
{/* Gasto por categoría — donut */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Spending by Category
|
Gasto por categoría
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{byCategory.length === 0 ? (
|
{folded.length === 0 ? (
|
||||||
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
|
<ChartEmpty description="Sin gastos en este período." />
|
||||||
Sin datos para este período
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
|
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={byCategory}
|
data={folded}
|
||||||
dataKey="total"
|
dataKey="total"
|
||||||
nameKey="category_name"
|
nameKey="category_name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
@@ -180,10 +274,12 @@ export default function Analytics() {
|
|||||||
innerRadius={60}
|
innerRadius={60}
|
||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
strokeWidth={0}
|
strokeWidth={2}
|
||||||
|
stroke="var(--background)"
|
||||||
|
isAnimationActive={false}
|
||||||
>
|
>
|
||||||
{byCategory.map((_, i) => (
|
{folded.map((cat, i) => (
|
||||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
<Cell key={cat.category_name} fill={categoricalColor(i, cat.category_name)} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -198,11 +294,11 @@ export default function Analytics() {
|
|||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
|
||||||
{byCategory.slice(0, 10).map((cat, i) => (
|
{folded.map((cat, i) => (
|
||||||
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
|
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
|
||||||
<div
|
<div
|
||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
style={{ background: COLORS[i % COLORS.length] }}
|
style={{ background: categoricalColor(i, cat.category_name) }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground truncate">{cat.category_name}</span>
|
<span className="text-muted-foreground truncate">{cat.category_name}</span>
|
||||||
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
|
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
|
||||||
@@ -214,26 +310,20 @@ export default function Analytics() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Monthly Trend - Bar */}
|
{/* Gasto mensual — barras por ciclo */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Monthly Spending (CRC)
|
Gasto mensual por ciclo (CRC)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{trend.length === 0 ? (
|
{trend.length === 0 ? (
|
||||||
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
|
<ChartEmpty description="Todavía no hay ciclos con datos." />
|
||||||
No data
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
|
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
|
||||||
<BarChart data={trend}>
|
<BarChart data={trend}>
|
||||||
<XAxis
|
<XAxis dataKey="label" axisLine={false} tickLine={false} />
|
||||||
dataKey="label"
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -246,36 +336,39 @@ export default function Analytics() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="total_crc" fill="var(--color-total_crc)" radius={[4, 4, 0, 0]} />
|
<Bar
|
||||||
|
dataKey="total_crc"
|
||||||
|
fill="var(--color-total_crc)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Daily Spending - Line */}
|
{/* Gasto diario — barras */}
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Daily Spending
|
Gasto diario
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{daily.length === 0 ? (
|
{daily.length === 0 ? (
|
||||||
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm">
|
<ChartEmpty description="Sin gastos en este período." />
|
||||||
Sin datos para este período
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
|
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
|
||||||
<LineChart data={daily}>
|
<BarChart data={daily}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={(v) => {
|
minTickGap={24}
|
||||||
const d = new Date(v);
|
tickFormatter={(v) =>
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
new Date(v).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -292,36 +385,34 @@ export default function Analytics() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Bar
|
||||||
type="monotone"
|
|
||||||
dataKey="total"
|
dataKey="total"
|
||||||
stroke="var(--color-total)"
|
fill="var(--color-total)"
|
||||||
strokeWidth={2}
|
radius={[2, 2, 0, 0]}
|
||||||
dot={{ fill: 'var(--color-total)', r: 3 }}
|
isAnimationActive={false}
|
||||||
activeDot={{ r: 5 }}
|
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top categories summary */}
|
{/* Categorías principales */}
|
||||||
{byCategory.length > 0 && (
|
{byCategory.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Top Categories
|
Categorías principales
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{byCategory.slice(0, 8).map((cat, i) => (
|
{folded.map((cat, i) => (
|
||||||
<div key={cat.category_name} className="flex items-center gap-3">
|
<div key={cat.category_name} className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
style={{ background: COLORS[i % COLORS.length] }}
|
style={{ background: categoricalColor(i, cat.category_name) }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm flex-1">{cat.category_name}</span>
|
<span className="text-sm flex-1">{cat.category_name}</span>
|
||||||
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
|
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
|
||||||
@@ -332,8 +423,8 @@ export default function Analytics() {
|
|||||||
<div
|
<div
|
||||||
className="h-1.5 rounded-full"
|
className="h-1.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${cat.percentage}%`,
|
width: `${Math.min(100, cat.percentage)}%`,
|
||||||
background: COLORS[i % COLORS.length],
|
background: categoricalColor(i, cat.category_name),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,28 +434,25 @@ export default function Analytics() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{/* Exchange rate history */}
|
|
||||||
|
{/* Tipo de cambio */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Tipo de Cambio USD/CRC (90 días)
|
Tipo de cambio USD/CRC (90 días)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{(ratesQ.data?.length ?? 0) < 2 ? (
|
{(ratesQ.data?.length ?? 0) < 2 ? (
|
||||||
<div className="h-40 flex items-center justify-center text-muted-foreground text-sm">
|
<ChartEmpty description="Sin historial suficiente." />
|
||||||
Sin historial suficiente
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer
|
<ChartContainer config={rateChartConfig} className="h-[200px] w-full">
|
||||||
config={rateChartConfig}
|
|
||||||
className="h-[200px] w-full"
|
|
||||||
>
|
|
||||||
<LineChart data={ratesQ.data}>
|
<LineChart data={ratesQ.data}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="day"
|
dataKey="day"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
|
minTickGap={24}
|
||||||
tickFormatter={(v) =>
|
tickFormatter={(v) =>
|
||||||
new Date(v).toLocaleDateString('es-CR', {
|
new Date(v).toLocaleDateString('es-CR', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -385,19 +473,22 @@ export default function Analytics() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="sell_rate"
|
dataKey="sell_rate"
|
||||||
stroke="var(--chart-1)"
|
stroke="var(--color-sell_rate)"
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="buy_rate"
|
dataKey="buy_rate"
|
||||||
stroke="var(--chart-2)"
|
stroke="var(--color-buy_rate)"
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
import { CopilotChat, useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
CopilotChat,
|
||||||
|
useAgent,
|
||||||
|
useConfigureSuggestions,
|
||||||
|
useCopilotKit,
|
||||||
|
} from "@copilotkit/react-core/v2";
|
||||||
import { useCopilotAction } from "@copilotkit/react-core";
|
import { useCopilotAction } from "@copilotkit/react-core";
|
||||||
import { Sparkles } from "lucide-react";
|
import { MessageSquarePlus, Sparkles } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
||||||
|
|
||||||
|
// Single persistent conversation: the backend snapshot store keys on this
|
||||||
|
// same id (backend/app/api/v1/endpoints/chat.py). An explicit, stable
|
||||||
|
// threadId also stops CopilotChat from wiping messages on remount.
|
||||||
|
const CHAT_THREAD_ID = "main";
|
||||||
|
|
||||||
const STATIC_SUGGESTIONS = {
|
const STATIC_SUGGESTIONS = {
|
||||||
available: "before-first-message" as const,
|
available: "before-first-message" as const,
|
||||||
suggestions: [
|
suggestions: [
|
||||||
@@ -16,6 +32,74 @@ const STATIC_SUGGESTIONS = {
|
|||||||
export default function Asistente() {
|
export default function Asistente() {
|
||||||
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { agent } = useAgent({ agentId: "wealthysmart" });
|
||||||
|
const { copilotkit } = useCopilotKit();
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
|
||||||
|
// Ask-box flow: a question typed on Inicio arrives via router state.
|
||||||
|
// History hydration is no longer done here — CopilotChat's mount connect
|
||||||
|
// replays the persisted thread through the BFF's PostgresAgentRunner. We
|
||||||
|
// still await our own connect before sending so the run's input carries
|
||||||
|
// the full client-known history (idempotent with CopilotChat's connect:
|
||||||
|
// the MESSAGES_SNAPSHOT merge is id-based).
|
||||||
|
const bootRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const ask = (location.state as { ask?: string } | null)?.ask;
|
||||||
|
if (!ask || !agent || bootRef.current) return;
|
||||||
|
bootRef.current = true;
|
||||||
|
// Clear the state so back/refresh doesn't re-send the question.
|
||||||
|
navigate(location.pathname, { replace: true, state: null });
|
||||||
|
void (async () => {
|
||||||
|
agent.threadId = CHAT_THREAD_ID;
|
||||||
|
if (agent.messages.length === 0) {
|
||||||
|
try {
|
||||||
|
await copilotkit.connectAgent({ agent });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[asistente] connect before ask failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agent.addMessage({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: "user",
|
||||||
|
content: ask,
|
||||||
|
});
|
||||||
|
void copilotkit.runAgent({ agent });
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [agent]);
|
||||||
|
|
||||||
|
const clearThread = async () => {
|
||||||
|
setClearing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/chat/thread", {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`DELETE /chat/thread → ${res.status}`);
|
||||||
|
}
|
||||||
|
// NOTE: there is no official flush for the runtime's in-memory thread
|
||||||
|
// store at CK 1.62 — the single-route endpoint we mount only accepts
|
||||||
|
// agent/run|connect|stop, info, transcribe; the threads/* router
|
||||||
|
// (incl. threads/clear) is a separate entrypoint this adapter doesn't
|
||||||
|
// serve. The BFF's connect intercept (server.ts) is what keeps that
|
||||||
|
// process memory from ever reaching the UI.
|
||||||
|
// Full reload is the reliable reset: CopilotKit keeps chat state in
|
||||||
|
// more places than agent.messages (clearing it in place left the
|
||||||
|
// conversation rendered), while a fresh boot hydrates from the
|
||||||
|
// now-empty store and shows the welcome screen + suggestions.
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[asistente] clear thread failed:", err);
|
||||||
|
toast.error("No se pudo borrar la conversación. Intenta de nuevo.");
|
||||||
|
setClearing(false);
|
||||||
|
setConfirmClear(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useCopilotAction({
|
useCopilotAction({
|
||||||
name: "render_spending_summary",
|
name: "render_spending_summary",
|
||||||
description:
|
description:
|
||||||
@@ -54,20 +138,51 @@ export default function Asistente() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-105px)]">
|
// Fills the viewport via Layout's flex chain (SidebarInset > main >
|
||||||
|
// max-w wrapper are all min-h-0 flex columns) — no vh math.
|
||||||
|
<div className="flex flex-1 min-h-0 flex-col">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
|
<PageHeader
|
||||||
<Sparkles className="w-5 h-5 text-primary" />
|
icon={Sparkles}
|
||||||
Asistente
|
title="Asistente"
|
||||||
</h1>
|
subtitle="Pregúntale a WealthySmart sobre tus finanzas."
|
||||||
<p className="text-sm text-muted-foreground">
|
actions={
|
||||||
Pregúntale a WealthySmart sobre tus finanzas.
|
<Button
|
||||||
</p>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
// Also disabled mid-run: MAF saves the thread at run END, so
|
||||||
|
// clearing while an answer streams would delete the row only
|
||||||
|
// to have the finishing run write it right back.
|
||||||
|
disabled={!agent || agent.messages.length === 0 || agent.isRunning}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus />
|
||||||
|
Nueva conversación
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{confirmClear && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="¿Nueva conversación?"
|
||||||
|
message="Se borrará el historial del chat guardado. Esta acción no se puede deshacer."
|
||||||
|
confirmLabel="Borrar historial"
|
||||||
|
onConfirm={clearThread}
|
||||||
|
onCancel={() => setConfirmClear(false)}
|
||||||
|
loading={clearing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
||||||
<CopilotChat
|
<CopilotChat
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
// Without an explicit agentId, CopilotChat resolves DEFAULT_AGENT_ID
|
||||||
|
// ("default") — a DIFFERENT client agent instance than
|
||||||
|
// useAgent({agentId:"wealthysmart"}); clearing one leaves the other
|
||||||
|
// rendering its own copy of the conversation.
|
||||||
|
agentId="wealthysmart"
|
||||||
|
threadId={CHAT_THREAD_ID}
|
||||||
labels={{
|
labels={{
|
||||||
modalHeaderTitle: "WealthySmart",
|
modalHeaderTitle: "WealthySmart",
|
||||||
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
|
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import api, { type Transaction } from '@/lib/api';
|
import api, { type Transaction } from '@/lib/api';
|
||||||
import { MONTH_NAMES_ES as MONTH_NAMES } from '@/lib/dates';
|
import {
|
||||||
|
MONTH_NAMES_ES as MONTH_NAMES,
|
||||||
|
MONTH_NAMES_ES_SHORT as MONTH_NAMES_SHORT,
|
||||||
|
} from '@/lib/dates';
|
||||||
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';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import ErrorState from '@/components/ErrorState';
|
import ErrorState from '@/components/ErrorState';
|
||||||
import PeriodNavigator from '@/components/PeriodNavigator';
|
import PeriodNavigator from '@/components/PeriodNavigator';
|
||||||
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
||||||
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
||||||
import TransactionList from '@/components/TransactionList';
|
import TransactionList from '@/components/TransactionList';
|
||||||
|
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
|
||||||
|
|
||||||
|
|
||||||
const MIN_YEAR = 2026;
|
const MIN_YEAR = 2026;
|
||||||
@@ -21,6 +26,17 @@ const MAX_YEAR = 2030;
|
|||||||
|
|
||||||
export default function Budget() {
|
export default function Budget() {
|
||||||
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
|
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// Proyecciones row clicks land here with the clicked period in the state.
|
||||||
|
const navState = location.state as
|
||||||
|
| { from?: string; year?: number; month?: number }
|
||||||
|
| null;
|
||||||
|
const cameFromProyecciones = navState?.from === 'proyecciones';
|
||||||
|
const initialYear =
|
||||||
|
navState?.year && navState.year >= MIN_YEAR && navState.year <= MAX_YEAR
|
||||||
|
? navState.year
|
||||||
|
: currentYear;
|
||||||
const {
|
const {
|
||||||
year,
|
year,
|
||||||
setYear,
|
setYear,
|
||||||
@@ -34,16 +50,13 @@ export default function Budget() {
|
|||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
refresh,
|
refresh,
|
||||||
} = useBudget(currentYear);
|
} = useBudget(initialYear, navState?.month);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const cameFromProyecciones =
|
|
||||||
(location.state as { from?: string } | null)?.from === 'proyecciones';
|
|
||||||
|
|
||||||
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
|
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
|
||||||
const [txSearch, setTxSearch] = useState('');
|
const [txSearch, setTxSearch] = useState('');
|
||||||
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
|
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
|
||||||
|
const [convertTx, setConvertTx] = useState<Transaction | null>(null);
|
||||||
|
|
||||||
const txQuery = useQuery({
|
const txQuery = useQuery({
|
||||||
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
|
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
|
||||||
@@ -77,8 +90,17 @@ export default function Budget() {
|
|||||||
});
|
});
|
||||||
const transactions = txQuery.data ?? [];
|
const transactions = txQuery.data ?? [];
|
||||||
|
|
||||||
|
// Make the period explicit: budget month M on the card tab = the billing
|
||||||
|
// cycle 18/(M-1) → 18/M; cash/transfers use the calendar month.
|
||||||
|
const cyclePrevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
|
||||||
|
const cycleLabel =
|
||||||
|
txSource === 'CREDIT_CARD'
|
||||||
|
? `Ciclo 18 ${MONTH_NAMES_SHORT[cyclePrevMonth].toLowerCase()} – 18 ${MONTH_NAMES_SHORT[selectedMonth].toLowerCase()}`
|
||||||
|
: `Mes de ${MONTH_NAMES[selectedMonth].toLowerCase()}`;
|
||||||
|
|
||||||
const invalidateTransactionsAndBudget = useCallback(() => {
|
const invalidateTransactionsAndBudget = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
|
||||||
refresh();
|
refresh();
|
||||||
}, [queryClient, refresh]);
|
}, [queryClient, refresh]);
|
||||||
|
|
||||||
@@ -107,12 +129,11 @@ export default function Budget() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{cameFromProyecciones && (
|
{cameFromProyecciones && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="-mb-4 text-muted-foreground"
|
||||||
onClick={() => navigate('/proyecciones')}
|
onClick={() => navigate('/proyecciones')}
|
||||||
aria-label="Volver a Proyecciones"
|
aria-label="Volver a Proyecciones"
|
||||||
>
|
>
|
||||||
@@ -120,9 +141,11 @@ export default function Budget() {
|
|||||||
Proyecciones
|
Proyecciones
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Calculator className="w-6 h-6 text-primary" />
|
<PageHeader
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
|
icon={Calculator}
|
||||||
</div>
|
title="Presupuesto"
|
||||||
|
subtitle="Proyección y transacciones del mes"
|
||||||
|
actions={
|
||||||
<PeriodNavigator
|
<PeriodNavigator
|
||||||
label={String(year)}
|
label={String(year)}
|
||||||
onPrev={() => setYear(year - 1)}
|
onPrev={() => setYear(year - 1)}
|
||||||
@@ -130,7 +153,8 @@ export default function Budget() {
|
|||||||
prevDisabled={year <= MIN_YEAR}
|
prevDisabled={year <= MIN_YEAR}
|
||||||
nextDisabled={year >= MAX_YEAR}
|
nextDisabled={year >= MAX_YEAR}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
<Tabs defaultValue="overview">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -171,27 +195,7 @@ export default function Budget() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="transactions" className="space-y-3 mt-4">
|
<TabsContent value="transactions" className="mt-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<Tabs
|
|
||||||
value={txSource}
|
|
||||||
onValueChange={(v) => setTxSource(v as typeof txSource)}
|
|
||||||
>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
|
|
||||||
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => window.open('/api/v1/transactions/export', '_blank')}
|
|
||||||
title="Descargar todas las transacciones como CSV"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{txQuery.isError ? (
|
{txQuery.isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="No se pudieron cargar las transacciones"
|
message="No se pudieron cargar las transacciones"
|
||||||
@@ -209,6 +213,30 @@ export default function Budget() {
|
|||||||
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
|
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
|
||||||
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
||||||
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
||||||
|
onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined}
|
||||||
|
summaryLabel={cycleLabel}
|
||||||
|
toolbarLeft={
|
||||||
|
<Tabs
|
||||||
|
value={txSource}
|
||||||
|
onValueChange={(v) => setTxSource(v as typeof txSource)}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
|
||||||
|
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
}
|
||||||
|
toolbarRight={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open('/api/v1/transactions/export', '_blank')}
|
||||||
|
title="Descargar todas las transacciones como CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -224,6 +252,13 @@ export default function Budget() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<ConvertToInstallmentsDialog
|
||||||
|
open={convertTx !== null}
|
||||||
|
onOpenChange={(open) => !open && setConvertTx(null)}
|
||||||
|
onSaved={invalidateTransactionsAndBudget}
|
||||||
|
tx={convertTx}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
220
frontend/src/pages/Financiamientos.tsx
Normal file
220
frontend/src/pages/Financiamientos.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Layers } from 'lucide-react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getInstallmentPlans,
|
||||||
|
type InstallmentPlan,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import ErrorState from '@/components/ErrorState';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('es-CR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function CuotasProgress({ plan }: { plan: InstallmentPlan }) {
|
||||||
|
const pct = (plan.cuotas_billed / plan.num_installments) * 100;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
value={pct}
|
||||||
|
className="w-16"
|
||||||
|
aria-label={`${plan.cuotas_billed} de ${plan.num_installments} cuotas pagadas`}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{plan.cuotas_billed}/{plan.num_installments}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Financiamientos() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState<InstallmentPlan | null>(null);
|
||||||
|
|
||||||
|
const plansQ = useQuery({
|
||||||
|
queryKey: ['installment-plans'],
|
||||||
|
queryFn: () => getInstallmentPlans().then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const plans = plansQ.data?.plans ?? [];
|
||||||
|
const active = plans.filter((p) => !p.is_completed);
|
||||||
|
const completed = plans.filter((p) => p.is_completed);
|
||||||
|
const totalRemaining = plansQ.data?.total_remaining ?? 0;
|
||||||
|
const totalCuotaMes = active.reduce((sum, p) => sum + p.installment_amount, 0);
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRows = (rows: InstallmentPlan[]) =>
|
||||||
|
rows.map((plan) => (
|
||||||
|
<TableRow
|
||||||
|
key={plan.id}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Editar plan de ${plan.merchant}`}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer focus-visible:outline-2 focus-visible:outline-ring',
|
||||||
|
plan.is_completed && 'opacity-60',
|
||||||
|
)}
|
||||||
|
onClick={() => setEditing(plan)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setEditing(plan);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate">{plan.merchant}</span>
|
||||||
|
{plan.is_completed && (
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
|
||||||
|
Completado
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(plan.purchase_date)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<CuotasProgress plan={plan} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono" data-sensitive>
|
||||||
|
{formatAmount(plan.installment_amount, plan.currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono" data-sensitive>
|
||||||
|
{formatAmount(plan.total_amount, plan.currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-medium" data-sensitive>
|
||||||
|
{formatAmount(plan.remaining_amount, plan.currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||||
|
{plan.next_cuota_date ? formatDate(plan.next_cuota_date) : '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Layers}
|
||||||
|
title="Financiamientos"
|
||||||
|
subtitle="Compras Tasa Cero en cuotas sin intereses — las cuotas futuras ya cuentan en el presupuesto de sus meses."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Saldo faltante total
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span data-sensitive className="text-2xl font-bold font-mono">
|
||||||
|
{formatAmount(totalRemaining, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Cuotas por mes (activos)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span data-sensitive className="text-2xl font-bold font-mono">
|
||||||
|
{formatAmount(totalCuotaMes, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Planes activos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="text-2xl font-bold font-mono">{active.length}</span>
|
||||||
|
{completed.length > 0 && (
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
(+{completed.length} completado{completed.length === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plans table */}
|
||||||
|
{plansQ.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
message="No se pudieron cargar los financiamientos"
|
||||||
|
onRetry={() => plansQ.refetch()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0 overflow-x-auto">
|
||||||
|
{plans.length === 0 && !plansQ.isPending ? (
|
||||||
|
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
|
||||||
|
<Layers className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />
|
||||||
|
<p>
|
||||||
|
Sin financiamientos. Convertí una compra de tarjeta a Tasa
|
||||||
|
Cero desde la lista de transacciones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Comercio</TableHead>
|
||||||
|
<TableHead>Fecha compra</TableHead>
|
||||||
|
<TableHead>Cuotas</TableHead>
|
||||||
|
<TableHead className="text-right">Monto cuota</TableHead>
|
||||||
|
<TableHead className="text-right">Saldo inicial</TableHead>
|
||||||
|
<TableHead className="text-right">Saldo faltante</TableHead>
|
||||||
|
<TableHead>Próxima cuota</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{renderRows(active)}
|
||||||
|
{renderRows(completed)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConvertToInstallmentsDialog
|
||||||
|
open={editing !== null}
|
||||||
|
onOpenChange={(open) => !open && setEditing(null)}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
plan={editing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
frontend/src/pages/Inicio.tsx
Normal file
349
frontend/src/pages/Inicio.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowRight, Home, Sparkles } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getInstallmentPlans,
|
||||||
|
getMonthlyDetail,
|
||||||
|
getPensionFundSummary,
|
||||||
|
getRecentTransactions,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { formatAmount } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
MONTH_NAMES_ES_SHORT,
|
||||||
|
currentBudgetCycle,
|
||||||
|
formatShortDate,
|
||||||
|
} from "@/lib/dates";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { StatTile } from "@/components/stat-tile";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
const CYCLE = currentBudgetCycle();
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
CREDIT_CARD: "Tarjeta",
|
||||||
|
CASH: "Efectivo",
|
||||||
|
TRANSFER: "Transferencias",
|
||||||
|
};
|
||||||
|
|
||||||
|
function HoverRows({
|
||||||
|
title,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
rows: { label: string; value: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-sm font-medium">{title}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.label} className="flex justify-between gap-3 text-xs">
|
||||||
|
<span className="text-muted-foreground truncate">{r.label}</span>
|
||||||
|
<span data-sensitive className="font-mono shrink-0">
|
||||||
|
{r.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GastoDelCicloCard() {
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["budget", "month", CYCLE.year, CYCLE.month],
|
||||||
|
queryFn: () =>
|
||||||
|
getMonthlyDetail(CYCLE.year, CYCLE.month).then((r) => r.data),
|
||||||
|
});
|
||||||
|
const spend = q.data
|
||||||
|
? q.data.actuals_by_source.reduce((sum, a) => sum + a.net, 0)
|
||||||
|
: null;
|
||||||
|
const balance = q.data?.net_balance ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatTile
|
||||||
|
label="Gasto del ciclo"
|
||||||
|
description={`Ciclo al 18 ${MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}`}
|
||||||
|
value={q.isError || spend === null ? null : formatAmount(spend, "CRC")}
|
||||||
|
loading={q.isPending}
|
||||||
|
to="/budget"
|
||||||
|
ariaLabel="Ver presupuesto"
|
||||||
|
hoverContent={
|
||||||
|
q.data ? (
|
||||||
|
<HoverRows
|
||||||
|
title="Desglose por fuente"
|
||||||
|
rows={[
|
||||||
|
...q.data.actuals_by_source
|
||||||
|
.filter((s) => s.count > 0)
|
||||||
|
.map((s) => ({
|
||||||
|
label: `${SOURCE_LABELS[s.source] ?? s.source} (${s.count})`,
|
||||||
|
value: formatAmount(s.net, "CRC"),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
label: "Gran total egresos",
|
||||||
|
value: formatAmount(q.data.gran_total_egresos, "CRC"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{balance !== null && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Balance proyectado:{" "}
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
"font-mono font-medium",
|
||||||
|
balance >= 0 ? "text-primary" : "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{balance >= 0 ? "+" : "-"}
|
||||||
|
{formatAmount(balance, "CRC")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{q.isError && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No se pudo cargar el ciclo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</StatTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProximasCuotasCard() {
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["installment-plans"],
|
||||||
|
queryFn: () => getInstallmentPlans().then((r) => r.data),
|
||||||
|
});
|
||||||
|
const active = (q.data?.plans ?? []).filter(
|
||||||
|
(p) => !p.is_completed && p.next_cuota_date,
|
||||||
|
);
|
||||||
|
// One cuota per plan per cycle, so the sum of every active plan's next
|
||||||
|
// cuota is the load of the upcoming billing batch (next 19th). The last
|
||||||
|
// cuota absorbs BAC's rounding, so use it when it's the one pending.
|
||||||
|
const nextCuotaOf = (p: (typeof active)[number]) =>
|
||||||
|
p.cuotas_billed === p.num_installments - 1
|
||||||
|
? p.last_installment_amount
|
||||||
|
: p.installment_amount;
|
||||||
|
const nextBatchTotal = active.reduce((s, p) => s + nextCuotaOf(p), 0);
|
||||||
|
const upcoming = [...active].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.next_cuota_date!).getTime() -
|
||||||
|
new Date(b.next_cuota_date!).getTime(),
|
||||||
|
);
|
||||||
|
const nextDate = upcoming[0]?.next_cuota_date;
|
||||||
|
const topThree = upcoming.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatTile
|
||||||
|
label="Próximas cuotas"
|
||||||
|
description={
|
||||||
|
nextDate
|
||||||
|
? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}`
|
||||||
|
: "Tasa Cero"
|
||||||
|
}
|
||||||
|
value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")}
|
||||||
|
loading={q.isPending}
|
||||||
|
to="/financiamientos"
|
||||||
|
ariaLabel="Ver financiamientos"
|
||||||
|
hoverContent={
|
||||||
|
upcoming.length > 0 ? (
|
||||||
|
<HoverRows
|
||||||
|
title="Planes activos"
|
||||||
|
rows={upcoming.map((p) => ({
|
||||||
|
label: `${p.merchant} (${p.cuotas_billed}/${p.num_installments})`,
|
||||||
|
value: `${formatShortDate(p.next_cuota_date!)} · ${formatAmount(nextCuotaOf(p), p.currency)}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{topThree.length > 0 && (
|
||||||
|
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{topThree.map((p) => (
|
||||||
|
<li key={p.id} className="flex justify-between gap-2">
|
||||||
|
<span className="truncate">{p.merchant}</span>
|
||||||
|
<span data-sensitive className="font-mono shrink-0">
|
||||||
|
{formatShortDate(p.next_cuota_date!)} ·{" "}
|
||||||
|
{formatAmount(nextCuotaOf(p), p.currency)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{q.data && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Restante total:{" "}
|
||||||
|
<span data-sensitive className="font-mono font-medium">
|
||||||
|
{formatAmount(q.data.total_remaining, "CRC")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</StatTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PensionCard() {
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["pension-fund-summary"],
|
||||||
|
queryFn: () => getPensionFundSummary().then((r) => r.data),
|
||||||
|
});
|
||||||
|
const total = q.data ? q.data.reduce((s, f) => s + f.saldo_final, 0) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatTile
|
||||||
|
label="Pensión"
|
||||||
|
description="Saldo total de fondos"
|
||||||
|
value={q.isError || total === null ? null : formatAmount(total, "CRC")}
|
||||||
|
loading={q.isPending}
|
||||||
|
to="/pensions"
|
||||||
|
ariaLabel="Ver pensiones"
|
||||||
|
hoverContent={
|
||||||
|
q.data && q.data.length > 0 ? (
|
||||||
|
<HoverRows
|
||||||
|
title="Último período por fondo"
|
||||||
|
rows={q.data.map((f) => ({
|
||||||
|
label: `${f.fund} · rendimientos`,
|
||||||
|
value: `+${formatAmount(f.rendimientos, "CRC")}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{q.data && q.data.length > 0 && (
|
||||||
|
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{q.data.map((f) => (
|
||||||
|
<li key={f.fund} className="flex justify-between gap-2">
|
||||||
|
<span>{f.fund}</span>
|
||||||
|
<span data-sensitive className="font-mono">
|
||||||
|
{formatAmount(f.saldo_final, "CRC")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</StatTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AskAssistantCard() {
|
||||||
|
const [question, setQuestion] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const ask = question.trim();
|
||||||
|
navigate("/asistente", ask ? { state: { ask } } : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
||||||
|
<Sparkles className="w-5 h-5 text-primary shrink-0" aria-hidden />
|
||||||
|
<Input
|
||||||
|
value={question}
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
placeholder="¿Cuánto gasté este ciclo?"
|
||||||
|
aria-label="Pregúntale al asistente"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit">Preguntar</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentTransactionsCard() {
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["transactions", "recent"],
|
||||||
|
queryFn: () => getRecentTransactions(5).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Últimas transacciones
|
||||||
|
</CardTitle>
|
||||||
|
<Link
|
||||||
|
to="/budget"
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||||
|
>
|
||||||
|
Ver todo
|
||||||
|
<ArrowRight className="w-3 h-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{q.isPending ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-6 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : q.isError ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No se pudieron cargar las transacciones.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{(q.data ?? []).map((tx) => (
|
||||||
|
<li
|
||||||
|
key={tx.id}
|
||||||
|
className="flex items-center justify-between gap-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">{tx.merchant}</span>
|
||||||
|
<span className="flex items-center gap-3 shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatShortDate(tx.date)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
"font-mono",
|
||||||
|
tx.transaction_type !== "COMPRA" && "text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tx.transaction_type === "COMPRA" ? "-" : "+"}
|
||||||
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Inicio() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Home}
|
||||||
|
title="Inicio"
|
||||||
|
subtitle="Tu panorama financiero de hoy."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<GastoDelCicloCard />
|
||||||
|
<ProximasCuotasCard />
|
||||||
|
<PensionCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AskAssistantCard />
|
||||||
|
|
||||||
|
<RecentTransactionsCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
navigate("/asistente", { replace: true });
|
navigate("/", { replace: true });
|
||||||
} catch {
|
} catch {
|
||||||
setError("Invalid credentials");
|
setError("Invalid credentials");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import ErrorState from '@/components/ErrorState';
|
import ErrorState from '@/components/ErrorState';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog';
|
import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -84,7 +85,17 @@ interface TooltipEntry {
|
|||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CURRENT_AGE = 30;
|
const BIRTH_DATE = new Date(1988, 7, 29); // 29 de agosto de 1988
|
||||||
|
|
||||||
|
function computeCurrentAge(): number {
|
||||||
|
const now = new Date();
|
||||||
|
const hadBirthdayThisYear =
|
||||||
|
now.getMonth() > BIRTH_DATE.getMonth() ||
|
||||||
|
(now.getMonth() === BIRTH_DATE.getMonth() && now.getDate() >= BIRTH_DATE.getDate());
|
||||||
|
return now.getFullYear() - BIRTH_DATE.getFullYear() - (hadBirthdayThisYear ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT_AGE = computeCurrentAge();
|
||||||
|
|
||||||
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'VOL'];
|
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'VOL'];
|
||||||
|
|
||||||
@@ -93,19 +104,19 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
|
|||||||
key: 'FCL',
|
key: 'FCL',
|
||||||
name: 'FCL',
|
name: 'FCL',
|
||||||
fullName: 'Fondo de Capitalización Laboral',
|
fullName: 'Fondo de Capitalización Laboral',
|
||||||
color: '#3b82f6',
|
color: 'var(--chart-5)',
|
||||||
startBalance: 650_468,
|
startBalance: 650_468,
|
||||||
monthlyContribution: 150_000,
|
monthlyContribution: 150_000,
|
||||||
annualRate: 7.5,
|
annualRate: 7.5,
|
||||||
isDividend: false,
|
isDividend: false,
|
||||||
withdrawalRule: 'Retirable cada 5 años o al cambio de empleo',
|
withdrawalRule: 'Retirable cada 5 años o al cambio de empleo',
|
||||||
defaultTargetAge: 35,
|
defaultTargetAge: CURRENT_AGE + 5,
|
||||||
},
|
},
|
||||||
ROP: {
|
ROP: {
|
||||||
key: 'ROP',
|
key: 'ROP',
|
||||||
name: 'ROP',
|
name: 'ROP',
|
||||||
fullName: 'Régimen Obligatorio de Pensiones',
|
fullName: 'Régimen Obligatorio de Pensiones',
|
||||||
color: '#10b981',
|
color: 'var(--chart-1)',
|
||||||
startBalance: 18_684_765,
|
startBalance: 18_684_765,
|
||||||
monthlyContribution: 120_000,
|
monthlyContribution: 120_000,
|
||||||
annualRate: 6.0,
|
annualRate: 6.0,
|
||||||
@@ -117,7 +128,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
|
|||||||
key: 'VOL',
|
key: 'VOL',
|
||||||
name: 'VOL',
|
name: 'VOL',
|
||||||
fullName: 'Fondo Voluntario',
|
fullName: 'Fondo Voluntario',
|
||||||
color: '#f43f5e',
|
color: 'var(--chart-4)',
|
||||||
startBalance: 2_500_381,
|
startBalance: 2_500_381,
|
||||||
monthlyContribution: 400_000,
|
monthlyContribution: 400_000,
|
||||||
annualRate: 8.0,
|
annualRate: 8.0,
|
||||||
@@ -255,7 +266,7 @@ const EMPTY_SNAPSHOTS: PensionSnapshot[] = [];
|
|||||||
export default function Pensions() {
|
export default function Pensions() {
|
||||||
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
|
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
|
||||||
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
|
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
|
||||||
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
|
FCL: { contribution: 150_000, rate: 7.5, targetAge: CURRENT_AGE + 5 },
|
||||||
ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 },
|
ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 },
|
||||||
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
|
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
|
||||||
});
|
});
|
||||||
@@ -470,17 +481,11 @@ export default function Pensions() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center gap-3">
|
<PageHeader
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
icon={PiggyBank}
|
||||||
<PiggyBank className="w-5 h-5 text-primary" />
|
title="Pensiones"
|
||||||
</div>
|
subtitle="Seguimiento de aportes, rendimientos y proyecciones"
|
||||||
<div>
|
/>
|
||||||
<h1 className="text-2xl font-bold font-heading">Pensiones</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Seguimiento de aportes, rendimientos y proyecciones
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
|
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -596,7 +601,7 @@ export default function Pensions() {
|
|||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
style={{
|
style={{
|
||||||
borderColor: fund.color,
|
borderColor: fund.color,
|
||||||
background: active ? fund.color + '22' : 'transparent',
|
background: active ? `color-mix(in oklab, ${fund.color} 13%, transparent)` : 'transparent',
|
||||||
color: active ? fund.color : 'var(--muted-foreground)',
|
color: active ? fund.color : 'var(--muted-foreground)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -642,6 +647,7 @@ export default function Pensions() {
|
|||||||
stroke={FUNDS[key].color}
|
stroke={FUNDS[key].color}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
activeDot={{ r: 4 }}
|
activeDot={{ r: 4 }}
|
||||||
/>
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Line, LineChart, XAxis, YAxis } from 'recharts';
|
|||||||
import { Telescope } from 'lucide-react';
|
import { Telescope } from 'lucide-react';
|
||||||
|
|
||||||
import { getYearlyProjection } from '@/lib/api';
|
import { getYearlyProjection } from '@/lib/api';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { StatTile } from '@/components/stat-tile';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -78,15 +80,11 @@ export default function Planificador() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<PageHeader
|
||||||
<Telescope className="w-6 h-6 text-primary" aria-hidden="true" />
|
icon={Telescope}
|
||||||
<div>
|
title="Planificador"
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Planificador</h1>
|
subtitle="¿Qué pasa si ahorrás un poco más cada mes?"
|
||||||
<p className="text-sm text-muted-foreground">
|
/>
|
||||||
¿Qué pasa si ahorrás un poco más cada mes?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
@@ -138,41 +136,18 @@ export default function Planificador() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<Card>
|
<StatTile label="Ritmo actual" value={formatCRC(baselineEnd)} />
|
||||||
<CardContent className="p-4">
|
<StatTile label="Escenario" value={formatCRC(scenarioEnd)} />
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
<StatTile
|
||||||
Ritmo actual
|
label="Diferencia"
|
||||||
</p>
|
value={`+${formatCRC(scenarioEnd - baselineEnd)}`}
|
||||||
<p data-sensitive className="text-xl font-bold font-mono">
|
className="[&_span[data-sensitive]]:text-primary"
|
||||||
{formatCRC(baselineEnd)}
|
/>
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
|
||||||
Escenario
|
|
||||||
</p>
|
|
||||||
<p data-sensitive className="text-xl font-bold font-mono text-primary">
|
|
||||||
{formatCRC(scenarioEnd)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
|
||||||
Diferencia
|
|
||||||
</p>
|
|
||||||
<p data-sensitive className="text-xl font-bold font-mono text-emerald-500">
|
|
||||||
+{formatCRC(scenarioEnd - baselineEnd)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Crecimiento comparado
|
Crecimiento comparado
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -191,8 +166,8 @@ export default function Planificador() {
|
|||||||
<ChartTooltipContent formatter={(value) => formatCRC(Number(value))} />
|
<ChartTooltipContent formatter={(value) => formatCRC(Number(value))} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} />
|
<Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||||
<Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} />
|
<Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useBudget } from '@/hooks/useBudget';
|
|||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
import ErrorState from '@/components/ErrorState';
|
import ErrorState from '@/components/ErrorState';
|
||||||
import PeriodNavigator from '@/components/PeriodNavigator';
|
import PeriodNavigator from '@/components/PeriodNavigator';
|
||||||
import YearlyOverview from '@/components/budget/YearlyOverview';
|
import YearlyOverview from '@/components/budget/YearlyOverview';
|
||||||
@@ -30,12 +31,11 @@ export default function Proyecciones() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
icon={TrendingUp}
|
||||||
<div className="flex items-center gap-3">
|
title="Proyecciones"
|
||||||
<TrendingUp className="w-6 h-6 text-primary" />
|
subtitle="Presupuesto anual mes a mes"
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1>
|
actions={
|
||||||
</div>
|
|
||||||
<PeriodNavigator
|
<PeriodNavigator
|
||||||
label={String(year)}
|
label={String(year)}
|
||||||
onPrev={() => setYear(year - 1)}
|
onPrev={() => setYear(year - 1)}
|
||||||
@@ -43,7 +43,8 @@ export default function Proyecciones() {
|
|||||||
prevDisabled={year <= MIN_YEAR}
|
prevDisabled={year <= MIN_YEAR}
|
||||||
nextDisabled={year >= MAX_YEAR}
|
nextDisabled={year >= MAX_YEAR}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Annual summary cards */}
|
{/* Annual summary cards */}
|
||||||
{projection && (
|
{projection && (
|
||||||
@@ -99,7 +100,9 @@ export default function Proyecciones() {
|
|||||||
year={year}
|
year={year}
|
||||||
onSelectMonth={(m) => {
|
onSelectMonth={(m) => {
|
||||||
setSelectedMonth(m);
|
setSelectedMonth(m);
|
||||||
navigate('/budget', { state: { from: 'proyecciones' } });
|
navigate('/budget', {
|
||||||
|
state: { from: 'proyecciones', year, month: m },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onSaveOverride={async (month, value) => {
|
onSaveOverride={async (month, value) => {
|
||||||
await saveBalanceOverride(year, month, value);
|
await saveBalanceOverride(year, month, value);
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote, Download } from 'lucide-react';
|
import { Landmark, RefreshCw, Download, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
|
import { type Transaction, getSalarios, getSalariosSummary } from '@/lib/api';
|
||||||
import { formatAmount, formatDate } from '@/lib/format';
|
import { formatAmount, formatDate } from '@/lib/format';
|
||||||
import ErrorState from '@/components/ErrorState';
|
import ErrorState from '@/components/ErrorState';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { StatTile } from '@/components/stat-tile';
|
||||||
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';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import SalaryEntryDialog from '@/components/SalaryEntryDialog';
|
||||||
|
|
||||||
export default function Salarios() {
|
export default function Salarios() {
|
||||||
|
const [entryOpen, setEntryOpen] = useState(false);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['salarios'],
|
queryKey: ['salarios'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -87,17 +91,16 @@ export default function Salarios() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
icon={Landmark}
|
||||||
<div className="flex items-center gap-3">
|
title="Salarios"
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
subtitle="Historial de depósitos salariales"
|
||||||
<Landmark className="w-5 h-5 text-primary" />
|
actions={
|
||||||
</div>
|
<>
|
||||||
<div>
|
<Button size="sm" onClick={() => setEntryOpen(true)}>
|
||||||
<h1 className="text-2xl font-bold font-heading">Salarios</h1>
|
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
|
Registrar salario
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -110,44 +113,30 @@ export default function Salarios() {
|
|||||||
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
|
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
|
||||||
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary tiles */}
|
||||||
{summary && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<Card>
|
<StatTile
|
||||||
<CardContent className="p-4">
|
label="Depósitos"
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
value={summary ? String(summary.count) : null}
|
||||||
<Hash className="w-4 h-4" />
|
loading={query.isPending}
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">Depósitos</span>
|
sensitive={false}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Total acumulado"
|
||||||
|
value={summary ? formatAmount(summary.total_amount, 'CRC') : null}
|
||||||
|
loading={query.isPending}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Último depósito"
|
||||||
|
value={summary?.latest_date ? formatDate(summary.latest_date) : null}
|
||||||
|
loading={query.isPending}
|
||||||
|
sensitive={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold font-mono">{summary.count}</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
|
||||||
<Banknote className="w-4 h-4" />
|
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">Total acumulado</span>
|
|
||||||
</div>
|
|
||||||
<span data-sensitive className="text-2xl font-bold font-mono text-primary">
|
|
||||||
{formatAmount(summary.total_amount, 'CRC')}
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
|
||||||
<CalendarDays className="w-4 h-4" />
|
|
||||||
<span className="text-xs font-medium uppercase tracking-wider">Último depósito</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold font-mono">
|
|
||||||
{summary.latest_date ? formatDate(summary.latest_date) : '—'}
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data table */}
|
{/* Data table */}
|
||||||
{query.isError ? (
|
{query.isError ? (
|
||||||
@@ -169,6 +158,12 @@ export default function Salarios() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{entryOpen && (
|
||||||
|
<SalaryEntryDialog
|
||||||
|
onClose={() => setEntryOpen(false)}
|
||||||
|
onSaved={() => { void query.refetch(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user