mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 13:28:46 +02:00
Compare commits
72 Commits
5612f69f11
...
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 | ||
|
|
248417dbab | ||
|
|
fc3461c377 | ||
|
|
c3c14f54c5 | ||
|
|
84f0256b7c | ||
|
|
fe62e50a85 | ||
|
|
7dfe2da2a9 | ||
|
|
3ea1ef0fa0 | ||
|
|
8713eaa4d8 | ||
|
|
0dfac6a285 | ||
|
|
ce3cc69846 |
@@ -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
|
||||||
|
|||||||
12
.github/workflows/deploy.yml
vendored
12
.github/workflows/deploy.yml
vendored
@@ -15,14 +15,20 @@ 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
|
- name: Frontend typecheck + API type drift gate
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd backend
|
||||||
|
DATABASE_URL='sqlite://' SECRET_KEY='ci-only-0123456789abcdef0123456789abcdef0123456789abcdef' \
|
||||||
|
ADMIN_USERNAME=ci ADMIN_PASSWORD=ci-not-admin OPENAI_API_KEY=dummy \
|
||||||
|
/tmp/test-venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
|
||||||
|
cd ../frontend
|
||||||
corepack enable
|
corepack enable
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm exec openapi-typescript openapi.json -o src/lib/api-types.gen.ts
|
||||||
|
git diff --exit-code src/lib/api-types.gen.ts
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -15,3 +15,9 @@ tech_docs/
|
|||||||
# Claude Code local state
|
# Claude Code local state
|
||||||
.claude/
|
.claude/
|
||||||
.venv/
|
.venv/
|
||||||
|
frontend/openapi.json
|
||||||
|
|
||||||
|
# playwright (demo artifacts are generated, not committed)
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-report/
|
||||||
|
frontend/e2e-docs/
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -68,3 +68,17 @@ Four automated flows on old-vps feed data into WealthySmart:
|
|||||||
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
|
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
|
||||||
|
|
||||||
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
|
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
|
||||||
|
|
||||||
|
## API Type Generation
|
||||||
|
|
||||||
|
Frontend read types are generated from the backend OpenAPI spec. After changing
|
||||||
|
backend response models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && DATABASE_URL='sqlite://' SECRET_KEY=$(openssl rand -hex 32) \
|
||||||
|
ADMIN_USERNAME=x ADMIN_PASSWORD=x-x OPENAI_API_KEY=dummy \
|
||||||
|
.venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
|
||||||
|
cd ../frontend && pnpm gen:api # regenerates src/lib/api-types.gen.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
CI regenerates and fails the deploy if `api-types.gen.ts` is stale.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
from app.auth import (
|
from app.auth import (
|
||||||
@@ -11,7 +12,16 @@ from app.auth import (
|
|||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
||||||
check_login_rate_limit(request)
|
check_login_rate_limit(request)
|
||||||
verify_admin_credentials(form_data.username, form_data.password)
|
verify_admin_credentials(form_data.username, form_data.password)
|
||||||
@@ -19,6 +29,6 @@ def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
|||||||
return {"access_token": token, "token_type": "bearer"}
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me", response_model=MeResponse)
|
||||||
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||||
return {"username": username}
|
return {"username": username}
|
||||||
|
|||||||
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}
|
||||||
@@ -26,12 +26,29 @@ class PasteImportRequest(BaseModel):
|
|||||||
text: str = Field(max_length=1_000_000)
|
text: str = Field(max_length=1_000_000)
|
||||||
bank: Bank = Bank.BAC
|
bank: Bank = Bank.BAC
|
||||||
source: TransactionSource = TransactionSource.CREDIT_CARD
|
source: TransactionSource = TransactionSource.CREDIT_CARD
|
||||||
|
# Override for false-positive dedup (legitimate same-day, same-amount
|
||||||
|
# repeat purchases hash identically) — review UX-20/BE-08.
|
||||||
|
allow_duplicates: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SkippedDuplicate(BaseModel):
|
||||||
|
line: int
|
||||||
|
merchant: str
|
||||||
|
date: str
|
||||||
|
amount: float
|
||||||
|
currency: str
|
||||||
|
existing_id: int
|
||||||
|
existing_merchant: str
|
||||||
|
existing_date: str
|
||||||
|
existing_amount: float
|
||||||
|
existing_source: str
|
||||||
|
|
||||||
|
|
||||||
class PasteImportResult(BaseModel):
|
class PasteImportResult(BaseModel):
|
||||||
imported: int
|
imported: int
|
||||||
duplicates: int
|
duplicates: int
|
||||||
errors: list[str]
|
errors: list[str]
|
||||||
|
skipped: list[SkippedDuplicate] = []
|
||||||
|
|
||||||
|
|
||||||
def make_reference_hash(date: datetime, merchant: str, amount: float, currency: str) -> str:
|
def make_reference_hash(date: datetime, merchant: str, amount: float, currency: str) -> str:
|
||||||
@@ -103,6 +120,7 @@ def paste_import(
|
|||||||
imported = 0
|
imported = 0
|
||||||
duplicates = 0
|
duplicates = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
skipped: list[SkippedDuplicate] = []
|
||||||
|
|
||||||
lines = req.text.strip().splitlines()
|
lines = req.text.strip().splitlines()
|
||||||
|
|
||||||
@@ -120,13 +138,29 @@ def paste_import(
|
|||||||
parsed["date"], parsed["merchant"], parsed["amount"], parsed["currency"]
|
parsed["date"], parsed["merchant"], parsed["amount"], parsed["currency"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for duplicates
|
# Check for duplicates — and SHOW the user what matched instead of
|
||||||
existing = session.exec(
|
# silently dropping the line (UX-20).
|
||||||
select(Transaction).where(Transaction.reference == ref_hash)
|
if not req.allow_duplicates:
|
||||||
).first()
|
existing = session.exec(
|
||||||
if existing:
|
select(Transaction).where(Transaction.reference == ref_hash)
|
||||||
duplicates += 1
|
).first()
|
||||||
continue
|
if existing:
|
||||||
|
duplicates += 1
|
||||||
|
skipped.append(
|
||||||
|
SkippedDuplicate(
|
||||||
|
line=i,
|
||||||
|
merchant=parsed["merchant"],
|
||||||
|
date=parsed["date"].isoformat(),
|
||||||
|
amount=parsed["amount"],
|
||||||
|
currency=parsed["currency"],
|
||||||
|
existing_id=existing.id or 0,
|
||||||
|
existing_merchant=existing.merchant,
|
||||||
|
existing_date=existing.date.isoformat(),
|
||||||
|
existing_amount=float(existing.amount),
|
||||||
|
existing_source=existing.source.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
tx = Transaction(
|
tx = Transaction(
|
||||||
amount=parsed["amount"],
|
amount=parsed["amount"],
|
||||||
@@ -147,4 +181,6 @@ def paste_import(
|
|||||||
if imported > 0:
|
if imported > 0:
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
return PasteImportResult(imported=imported, duplicates=duplicates, errors=errors)
|
return PasteImportResult(
|
||||||
|
imported=imported, duplicates=duplicates, errors=errors, skipped=skipped
|
||||||
|
)
|
||||||
|
|||||||
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()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pywebpush import WebPushException, webpush
|
from pywebpush import WebPushException, webpush
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -15,14 +16,22 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vapid-public-key")
|
class VapidPublicKeyResponse(BaseModel):
|
||||||
|
publicKey: str
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionStatusResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
|
||||||
def get_vapid_public_key(_user: str = Depends(get_current_user)):
|
def get_vapid_public_key(_user: str = Depends(get_current_user)):
|
||||||
if not settings.VAPID_PUBLIC_KEY:
|
if not settings.VAPID_PUBLIC_KEY:
|
||||||
raise HTTPException(status_code=503, detail="Push notifications not configured")
|
raise HTTPException(status_code=503, detail="Push notifications not configured")
|
||||||
return {"publicKey": settings.VAPID_PUBLIC_KEY}
|
return {"publicKey": settings.VAPID_PUBLIC_KEY}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/subscribe", status_code=201)
|
@router.post("/subscribe", status_code=201, response_model=SubscriptionStatusResponse)
|
||||||
def subscribe(
|
def subscribe(
|
||||||
data: PushSubscriptionCreate,
|
data: PushSubscriptionCreate,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -48,7 +57,7 @@ def subscribe(
|
|||||||
return {"status": "subscribed"}
|
return {"status": "subscribed"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/unsubscribe")
|
@router.delete("/unsubscribe", response_model=SubscriptionStatusResponse)
|
||||||
def unsubscribe(
|
def unsubscribe(
|
||||||
data: PushSubscriptionCreate,
|
data: PushSubscriptionCreate,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
@@ -8,7 +9,22 @@ from app.services.sync_status import compute_sync_status
|
|||||||
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
|
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
class SyncSourceStatus(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
last_received: str | None
|
||||||
|
age_days: float | None
|
||||||
|
warn_after_days: float
|
||||||
|
status: str # 'ok' | 'warning' | 'never'
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStatusResponse(BaseModel):
|
||||||
|
sources: list[SyncSourceStatus]
|
||||||
|
warnings: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=SyncStatusResponse)
|
||||||
def sync_status(
|
def sync_status(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
@@ -99,6 +109,55 @@ def list_transactions(
|
|||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
|
|
||||||
|
class BulkActionRequest(BaseModel):
|
||||||
|
ids: list[int]
|
||||||
|
action: str # 'delete' | 'set_category' | 'set_deferred'
|
||||||
|
category_id: Optional[int] = None # for set_category (None clears)
|
||||||
|
deferred: Optional[bool] = None # for set_deferred
|
||||||
|
|
||||||
|
|
||||||
|
class BulkActionResponse(BaseModel):
|
||||||
|
affected: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk", response_model=BulkActionResponse)
|
||||||
|
def bulk_action(
|
||||||
|
req: BulkActionRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Apply one action to many transactions atomically (review 4.4)."""
|
||||||
|
if not req.ids or len(req.ids) > 500:
|
||||||
|
raise HTTPException(status_code=400, detail="ids must contain 1-500 entries")
|
||||||
|
if req.action not in ("delete", "set_category", "set_deferred"):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown action: {req.action}")
|
||||||
|
if req.action == "set_deferred" and req.deferred is None:
|
||||||
|
raise HTTPException(status_code=400, detail="deferred is required for set_deferred")
|
||||||
|
if req.action == "set_category" and req.category_id is not None:
|
||||||
|
if session.get(Category, req.category_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
|
txs = session.exec(
|
||||||
|
select(Transaction).where(col(Transaction.id).in_(req.ids))
|
||||||
|
).all()
|
||||||
|
affected = 0
|
||||||
|
for tx in txs:
|
||||||
|
if req.action == "delete":
|
||||||
|
teardown_plan_for_anchor(session, tx)
|
||||||
|
session.delete(tx)
|
||||||
|
elif req.action == "set_category":
|
||||||
|
tx.category_id = req.category_id
|
||||||
|
session.add(tx)
|
||||||
|
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)
|
||||||
|
session.add(tx)
|
||||||
|
affected += 1
|
||||||
|
session.commit()
|
||||||
|
return {"affected": affected}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export")
|
@router.get("/export")
|
||||||
def export_transactions_csv(
|
def export_transactions_csv(
|
||||||
source: Optional[TransactionSource] = None,
|
source: Optional[TransactionSource] = None,
|
||||||
@@ -161,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
|
||||||
@@ -192,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)
|
||||||
)
|
)
|
||||||
@@ -224,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)
|
||||||
@@ -234,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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,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)
|
||||||
@@ -273,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
|
||||||
131
backend/tests/test_import_review_and_bulk.py
Normal file
131
backend/tests/test_import_review_and_bulk.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Paste-import dedup transparency + override (UX-20) and bulk actions (4.4).
|
||||||
|
|
||||||
|
These exercise the endpoint functions directly with a session — no HTTP
|
||||||
|
stack needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.import_transactions import (
|
||||||
|
PasteImportRequest,
|
||||||
|
paste_import,
|
||||||
|
)
|
||||||
|
from app.api.v1.endpoints.transactions import BulkActionRequest, bulk_action
|
||||||
|
from app.models.models import Category, Transaction, TransactionSource
|
||||||
|
|
||||||
|
LINE = "15/04/2026\tWALMART\\SAN JOSE\\CR\t25,000.50 CRC"
|
||||||
|
|
||||||
|
|
||||||
|
def do_import(session, text=LINE, **kw):
|
||||||
|
return paste_import(
|
||||||
|
PasteImportRequest(text=text, **kw), session=session, _user="t"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportReview:
|
||||||
|
def test_duplicate_is_reported_with_matched_row(self, session):
|
||||||
|
first = do_import(session)
|
||||||
|
assert first.imported == 1 and first.duplicates == 0
|
||||||
|
|
||||||
|
second = do_import(session)
|
||||||
|
assert second.imported == 0
|
||||||
|
assert second.duplicates == 1
|
||||||
|
assert len(second.skipped) == 1
|
||||||
|
skip = second.skipped[0]
|
||||||
|
assert skip.merchant == "WALMART"
|
||||||
|
assert skip.amount == 25000.50
|
||||||
|
assert skip.existing_merchant == "WALMART"
|
||||||
|
assert skip.existing_amount == 25000.50
|
||||||
|
assert skip.existing_id > 0
|
||||||
|
|
||||||
|
def test_allow_duplicates_overrides_dedup(self, session):
|
||||||
|
do_import(session)
|
||||||
|
forced = do_import(session, allow_duplicates=True)
|
||||||
|
assert forced.imported == 1
|
||||||
|
assert forced.duplicates == 0
|
||||||
|
assert forced.skipped == []
|
||||||
|
|
||||||
|
|
||||||
|
def make_tx(session, merchant="M", category_id=None):
|
||||||
|
tx = Transaction(
|
||||||
|
amount=Decimal("10"),
|
||||||
|
merchant=merchant,
|
||||||
|
date=datetime(2026, 4, 1),
|
||||||
|
category_id=category_id,
|
||||||
|
source=TransactionSource.CREDIT_CARD,
|
||||||
|
)
|
||||||
|
session.add(tx)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(tx)
|
||||||
|
return tx
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkActions:
|
||||||
|
def test_bulk_set_category(self, session):
|
||||||
|
cat = Category(name="Bulk")
|
||||||
|
session.add(cat)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(cat)
|
||||||
|
txs = [make_tx(session, f"m{i}") for i in range(3)]
|
||||||
|
|
||||||
|
result = bulk_action(
|
||||||
|
BulkActionRequest(
|
||||||
|
ids=[t.id for t in txs], action="set_category", category_id=cat.id
|
||||||
|
),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert result["affected"] == 3
|
||||||
|
for t in txs:
|
||||||
|
session.refresh(t)
|
||||||
|
assert t.category_id == cat.id
|
||||||
|
|
||||||
|
def test_bulk_set_deferred_and_clear_category(self, session):
|
||||||
|
cat = Category(name="X")
|
||||||
|
session.add(cat)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(cat)
|
||||||
|
tx = make_tx(session, category_id=cat.id)
|
||||||
|
|
||||||
|
bulk_action(
|
||||||
|
BulkActionRequest(ids=[tx.id], action="set_deferred", deferred=True),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
session.refresh(tx)
|
||||||
|
assert tx.deferred_to_next_cycle is True
|
||||||
|
|
||||||
|
bulk_action(
|
||||||
|
BulkActionRequest(ids=[tx.id], action="set_category", category_id=None),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
session.refresh(tx)
|
||||||
|
assert tx.category_id is None
|
||||||
|
|
||||||
|
def test_bulk_delete(self, session):
|
||||||
|
txs = [make_tx(session, f"d{i}") for i in range(2)]
|
||||||
|
result = bulk_action(
|
||||||
|
BulkActionRequest(ids=[t.id for t in txs], action="delete"),
|
||||||
|
session=session,
|
||||||
|
_user="t",
|
||||||
|
)
|
||||||
|
assert result["affected"] == 2
|
||||||
|
assert session.get(Transaction, txs[0].id) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"req",
|
||||||
|
[
|
||||||
|
BulkActionRequest(ids=[], action="delete"),
|
||||||
|
BulkActionRequest(ids=[1], action="explode"),
|
||||||
|
BulkActionRequest(ids=[1], action="set_deferred"), # missing flag
|
||||||
|
BulkActionRequest(ids=[1], action="set_category", category_id=99999),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_requests_rejected(self, session, req):
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
bulk_action(req, session=session, _user="t")
|
||||||
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
|
||||||
@@ -145,7 +145,39 @@ fallback already guarded `len >= 2`).
|
|||||||
|
|
||||||
## Phase 3 — Frontend Data Layer & Feedback ⏳ NOT STARTED
|
## Phase 3 — Frontend Data Layer & Feedback ⏳ NOT STARTED
|
||||||
|
|
||||||
## Phase 4 — UX, Trust & Features 🔄 ITERATION 1 DONE
|
## Phase 4 — UX, Trust & Features ✅ DONE (iterations 1 + 2)
|
||||||
|
|
||||||
|
### Iteration 2 (2026-06-11)
|
||||||
|
|
||||||
|
| Plan item | Status |
|
||||||
|
|---|---|
|
||||||
|
| 4.2 Import review: skipped duplicates listed with matched row + import-anyway override (re-imports only skipped lines) | ✅ |
|
||||||
|
| 4.4 Bulk operations: POST /transactions/bulk + selection UI (assign category, defer/restore, delete) | ✅ |
|
||||||
|
| 4.7 Editable pension assumptions (UserSettings-backed) | ✅ |
|
||||||
|
| 4.7 Dirty-form guard (UX-06), empty-state CTA (UX-14) | ✅ |
|
||||||
|
| BE-08 hash tightening | superseded — the override solves the practical harm; rehashing would orphan every existing reference |
|
||||||
|
| UX-11 mobile layout | **false positive** — a card layout already existed (two real bugs in it fixed: undo-pending rows showed, en-US dates) |
|
||||||
|
| UX-13 cycle filter | **mostly false positive** — daily-spending already accepts cycle params; the trend is multi-cycle by design |
|
||||||
|
| UX-24 meter labels, UX-22 breadcrumb, shared period navigator (UX-03) | not done — needs dedicated UI; lowest-value remainder |
|
||||||
|
|
||||||
|
**The improvement plan is complete** — and as of 2026-06-12, so is everything
|
||||||
|
that had been deferred:
|
||||||
|
|
||||||
|
### Final batch (2026-06-12)
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|---|---|
|
||||||
|
| 3.4 OpenAPI codegen: response_model sweep + generated api-types.gen.ts + CI drift gate (caught 2 real drifts: missing SAVINGS enum value, untyped raw_charges) | ✅ |
|
||||||
|
| UX-03 shared PeriodNavigator · UX-22 breadcrumb · UX-24 meter labels | ✅ |
|
||||||
|
| Wishlist: rate-history chart (Analytics), contribution calculator (Pensiones), what-if Planificador page, PWA installability | ✅ |
|
||||||
|
| Bonus fix: sw.js was a self-unregistering stub — `serviceWorker.ready` never resolved, so web push had been silently dead; replaced with a working worker | ✅ |
|
||||||
|
|
||||||
|
Tests: **82**. Final false-positive tally across the whole plan: BE-06, BE-09,
|
||||||
|
BE-10, ARCH-03, ARCH-08, BE-22 (accepted), FE-04, UX-04, UX-11, UX-13 — the
|
||||||
|
characterization-tests-first approach caught all of them before any "fix"
|
||||||
|
landed. **Nothing from the review or wishlist remains.**
|
||||||
|
|
||||||
|
### Iteration 1 (2026-06-10)
|
||||||
|
|
||||||
Iteration 1 completed 2026-06-10. The plan marks 4.x items independent; this
|
Iteration 1 completed 2026-06-10. The plan marks 4.x items independent; this
|
||||||
iteration delivered the highest-impact slice.
|
iteration delivered the highest-impact slice.
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<meta name="description" content="WealthySmart — Smart personal finance management" />
|
<meta name="description" content="WealthySmart — Smart personal finance management" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
<title>WealthySmart</title>
|
<title>WealthySmart</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,14 +8,17 @@
|
|||||||
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
|
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "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"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -23,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",
|
||||||
@@ -37,13 +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",
|
||||||
"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,
|
||||||
|
},
|
||||||
|
});
|
||||||
3287
frontend/pnpm-lock.yaml
generated
3287
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
29
frontend/public/manifest.webmanifest
Normal file
29
frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "WealthySmart",
|
||||||
|
"short_name": "WealthySmart",
|
||||||
|
"description": "Smart personal finance management",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f172a",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"lang": "es-CR",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,42 @@
|
|||||||
|
/* WealthySmart service worker: web push + PWA installability.
|
||||||
|
* (Replaces the old self-unregistering cleanup stub, which also meant
|
||||||
|
* `navigator.serviceWorker.ready` never resolved and push silently no-oped.) */
|
||||||
|
|
||||||
self.addEventListener("install", () => self.skipWaiting());
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
self.addEventListener("activate", async () => {
|
|
||||||
await self.registration.unregister();
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
let data = { title: "WealthySmart", body: "", url: "/" };
|
||||||
|
try {
|
||||||
|
data = { ...data, ...event.data.json() };
|
||||||
|
} catch {
|
||||||
|
if (event.data) data.body = event.data.text();
|
||||||
|
}
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, {
|
||||||
|
body: data.body,
|
||||||
|
icon: "/icons/icon-192.png",
|
||||||
|
badge: "/icons/icon-192.png",
|
||||||
|
data: { url: data.url },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data?.url || "/";
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||||
|
for (const client of clients) {
|
||||||
|
if ("focus" in client) {
|
||||||
|
client.navigate(url);
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,61 +383,194 @@ 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";
|
||||||
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new StripModelArtifactsMiddleware() as any);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new ReconcileSnapshotMiddleware() as any);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new DeduplicateToolCallMiddleware() as any);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new DebugLogMiddleware() as any);
|
|
||||||
|
|
||||||
const runtime = new CopilotRuntime({
|
function makeWealthysmartAgent(headers: Record<string, string> = {}): HttpAgent {
|
||||||
agents: { wealthysmart: agent },
|
const agent = new HttpAgent({ url: AGENT_URL, headers });
|
||||||
a2ui: { injectA2UITool: true },
|
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these
|
||||||
beforeRequestMiddleware: async ({ request: outbound }) => {
|
// workarounds are still needed after CopilotKit/MAF upgrades.
|
||||||
if (outbound.method !== "POST") return;
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
const ct = outbound.headers.get("content-type") ?? "";
|
if (process.env.CK_MIDDLEWARES !== "off") {
|
||||||
if (!ct.includes("application/json")) return;
|
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
||||||
try {
|
agent.use(new StripModelArtifactsMiddleware() as any);
|
||||||
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
|
agent.use(new DropRunMessagesSnapshotMiddleware() as any);
|
||||||
if (!Array.isArray(body.messages)) return;
|
agent.use(new DeduplicateToolCallMiddleware() as any);
|
||||||
const paired = pairOrphanToolCalls(body.messages);
|
}
|
||||||
if (paired.length === body.messages.length) return;
|
if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {
|
||||||
return new Request(outbound.url, {
|
agent.use(new DebugLogMiddleware() as any);
|
||||||
method: outbound.method,
|
}
|
||||||
headers: outbound.headers,
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
body: JSON.stringify({ ...body, messages: paired }),
|
return agent;
|
||||||
});
|
}
|
||||||
} catch (err) {
|
|
||||||
// Skipping the repair is survivable; doing it silently is not —
|
// ── PostgresAgentRunner ───────────────────────────────────────────────────────
|
||||||
// orphan tool calls then fail downstream with no trace of why.
|
// The conversation store is Postgres (chatthreadsnapshot), written by MAF at
|
||||||
console.error("[copilotkit] orphan-tool-call repair skipped:", err);
|
// every run end. So the runner records NOTHING:
|
||||||
return;
|
// - 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({
|
||||||
|
agents: { wealthysmart: makeWealthysmartAgent() },
|
||||||
|
// 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 }) => {
|
||||||
|
if (outbound.method !== "POST") return;
|
||||||
|
const ct = outbound.headers.get("content-type") ?? "";
|
||||||
|
if (!ct.includes("application/json")) return;
|
||||||
|
try {
|
||||||
|
const body = (await outbound.clone().json()) as {
|
||||||
|
messages?: AGUIMessage[];
|
||||||
|
context?: { description?: string; value?: string }[];
|
||||||
|
};
|
||||||
|
if (!Array.isArray(body.messages)) return;
|
||||||
|
// An empty messages array is MAF's hydration trigger (known threadId
|
||||||
|
// + 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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
if (messages === body.messages) return;
|
||||||
runtime,
|
return new Request(outbound.url, {
|
||||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
method: outbound.method,
|
||||||
endpoint: "/api/copilotkit",
|
headers: outbound.headers,
|
||||||
});
|
body: JSON.stringify({ ...body, messages }),
|
||||||
|
});
|
||||||
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
|
} catch (err) {
|
||||||
|
// Skipping the repair is survivable; doing it silently is not —
|
||||||
|
// orphan tool calls then fail downstream with no trace of why.
|
||||||
|
console.error("[copilotkit] outbound request repair skipped:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ckHandler = createCopilotRuntimeHandler({
|
||||||
|
runtime,
|
||||||
|
basePath: "/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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 }));
|
||||||
|
|
||||||
// Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly,
|
// Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly,
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ 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";
|
||||||
import SyncStatus from "./pages/SyncStatus";
|
import SyncStatus from "./pages/SyncStatus";
|
||||||
|
import Planificador from "./pages/Planificador";
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
@@ -40,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={
|
||||||
@@ -49,15 +52,17 @@ 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 />} />
|
||||||
|
<Route path="/planificador" element={<Planificador />} />
|
||||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
@@ -72,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,235 +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,
|
|
||||||
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: "/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="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col">
|
||||||
<div className="flex">
|
|
||||||
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
|
|
||||||
<div className="flex-1">
|
|
||||||
<SidebarNav />
|
|
||||||
</div>
|
|
||||||
<div className="px-3 pb-4">
|
|
||||||
<Separator className="mb-2" />
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Cerrar sesión
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
||||||
<SheetContent side="left" className="p-0 w-64">
|
|
||||||
<SheetHeader className="p-4">
|
|
||||||
<SheetTitle className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
|
||||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
|
||||||
</div>
|
|
||||||
<span style={{ fontFamily: "var(--font-heading)" }}>
|
|
||||||
Wealthy<span className="text-primary">Smart</span>
|
|
||||||
</span>
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex flex-col h-[calc(100%-65px)]">
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<SidebarNav onNavigate={() => setMobileOpen(false)} />
|
|
||||||
</div>
|
|
||||||
<div className="px-3 pb-4">
|
|
||||||
<Separator className="mb-2" />
|
|
||||||
<SheetClose render={<span />}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setMobileOpen(false);
|
|
||||||
void handleLogout();
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Cerrar sesión
|
|
||||||
</button>
|
|
||||||
</SheetClose>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</SidebarInset>
|
||||||
</div>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
87
frontend/src/components/MeterLabelsDialog.tsx
Normal file
87
frontend/src/components/MeterLabelsDialog.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import api, { type UserSettingsData } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
meterIds: string[];
|
||||||
|
current: Record<string, string>;
|
||||||
|
settingsData: UserSettingsData;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human names for water meters, persisted in UserSettings (review UX-24). */
|
||||||
|
export default function MeterLabelsDialog({
|
||||||
|
meterIds,
|
||||||
|
current,
|
||||||
|
settingsData,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [labels, setLabels] = useState<Record<string, string>>(() => ({
|
||||||
|
...current,
|
||||||
|
}));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const cleaned = Object.fromEntries(
|
||||||
|
Object.entries(labels).filter(([, v]) => v.trim() !== ''),
|
||||||
|
);
|
||||||
|
await api.patch('/settings/', {
|
||||||
|
data: { ...settingsData, meter_labels: cleaned },
|
||||||
|
});
|
||||||
|
toast.success('Etiquetas guardadas');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudieron guardar las etiquetas');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Etiquetas de medidores</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{meterIds.map((id) => (
|
||||||
|
<div key={id} className="space-y-1">
|
||||||
|
<Label className="text-xs font-mono">Medidor {id}</Label>
|
||||||
|
<Input
|
||||||
|
value={labels[id] ?? ''}
|
||||||
|
placeholder="p. ej. Casa, Cochera…"
|
||||||
|
onChange={(e) =>
|
||||||
|
setLabels((prev) => ({ ...prev, [id]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Guardando...' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import api, { type ImportResult } from '@/lib/api';
|
import api, { type ImportResult } from '@/lib/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { formatShortDate } from '@/lib/dates';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -39,8 +43,40 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
|
|||||||
const { data } = await api.post<ImportResult>('/import/paste', { text, bank, source });
|
const { data } = await api.post<ImportResult>('/import/paste', { text, bank, source });
|
||||||
setResult(data);
|
setResult(data);
|
||||||
if (data.imported > 0) onImported();
|
if (data.imported > 0) onImported();
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error(err);
|
toast.error('No se pudo importar - revisa el formato');
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-import ONLY the skipped lines with the dedup check disabled, so rows
|
||||||
|
// that imported on the first pass are never duplicated.
|
||||||
|
const handleImportDuplicates = async () => {
|
||||||
|
if (!result || result.skipped.length === 0) return;
|
||||||
|
const lines = text.trim().split('\n');
|
||||||
|
const dupText = result.skipped
|
||||||
|
.map((s) => lines[s.line - 1])
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<ImportResult>('/import/paste', {
|
||||||
|
text: dupText,
|
||||||
|
bank,
|
||||||
|
source,
|
||||||
|
allow_duplicates: true,
|
||||||
|
});
|
||||||
|
toast.success(`${data.imported} duplicado(s) importados`);
|
||||||
|
setResult({
|
||||||
|
...result,
|
||||||
|
imported: result.imported + data.imported,
|
||||||
|
skipped: [],
|
||||||
|
duplicates: 0,
|
||||||
|
});
|
||||||
|
onImported();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudieron importar los duplicados');
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
@@ -134,6 +170,39 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{result.skipped.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 space-y-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{result.skipped.length} duplicado(s) omitido(s)
|
||||||
|
</p>
|
||||||
|
<ul className="text-xs max-h-40 overflow-y-auto space-y-1.5">
|
||||||
|
{result.skipped.map((s) => (
|
||||||
|
<li key={`${s.line}-${s.existing_id}`} className="space-y-0.5">
|
||||||
|
<span className="font-mono">
|
||||||
|
{s.merchant} · {formatShortDate(s.date)} ·{' '}
|
||||||
|
{formatAmount(s.amount, s.currency)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-muted-foreground">
|
||||||
|
coincide con #{s.existing_id}: {s.existing_merchant} del{' '}
|
||||||
|
{formatShortDate(s.existing_date)} ({s.existing_source})
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleImportDuplicates}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
|
{importing
|
||||||
|
? 'Importando...'
|
||||||
|
: 'Importar duplicados de todos modos'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button onClick={onClose} className="w-full">
|
<Button onClick={onClose} className="w-full">
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
139
frontend/src/components/PensionAssumptionsDialog.tsx
Normal file
139
frontend/src/components/PensionAssumptionsDialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import api, { type UserSettingsData } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
export interface FundAssumption {
|
||||||
|
monthlyContribution: number;
|
||||||
|
annualRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PensionAssumptions = Partial<
|
||||||
|
Record<'FCL' | 'ROP' | 'VOL', FundAssumption>
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
current: Record<'FCL' | 'ROP' | 'VOL', FundAssumption>;
|
||||||
|
settingsData: UserSettingsData;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FUND_LABELS: Record<'FCL' | 'ROP' | 'VOL', string> = {
|
||||||
|
FCL: 'FCL — Capitalización Laboral',
|
||||||
|
ROP: 'ROP — Régimen Obligatorio',
|
||||||
|
VOL: 'VOL — Fondo Voluntario',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-fund projection assumptions, persisted server-side in UserSettings so
|
||||||
|
* they survive devices and reloads (review UX-12). */
|
||||||
|
export default function PensionAssumptionsDialog({
|
||||||
|
current,
|
||||||
|
settingsData,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form, setForm] = useState(() => ({
|
||||||
|
FCL: { ...current.FCL },
|
||||||
|
ROP: { ...current.ROP },
|
||||||
|
VOL: { ...current.VOL },
|
||||||
|
}));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const setField = (
|
||||||
|
fund: 'FCL' | 'ROP' | 'VOL',
|
||||||
|
field: keyof FundAssumption,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fund]: { ...prev[fund], [field]: isNaN(num) ? 0 : num },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// The settings PATCH replaces the whole blob — merge over what exists.
|
||||||
|
await api.patch('/settings/', {
|
||||||
|
data: { ...settingsData, pension_assumptions: form },
|
||||||
|
});
|
||||||
|
toast.success('Supuestos de pensión guardados');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudieron guardar los supuestos');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Supuestos de proyección</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground -mt-2">
|
||||||
|
Usados para proyectar el crecimiento de cada fondo. El saldo inicial
|
||||||
|
siempre viene del último estado de cuenta.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(Object.keys(FUND_LABELS) as Array<'FCL' | 'ROP' | 'VOL'>).map(
|
||||||
|
(fund) => (
|
||||||
|
<div key={fund} className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{FUND_LABELS[fund]}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Aporte mensual (₡)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1000"
|
||||||
|
value={form[fund].monthlyContribution}
|
||||||
|
onChange={(e) =>
|
||||||
|
setField(fund, 'monthlyContribution', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Rendimiento anual (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="30"
|
||||||
|
step="0.1"
|
||||||
|
value={form[fund].annualRate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setField(fund, 'annualRate', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Guardando...' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/src/components/PeriodNavigator.tsx
Normal file
52
frontend/src/components/PeriodNavigator.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
prevDisabled?: boolean;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
/** 'outline' for page-level (year), 'ghost' for inline (month) */
|
||||||
|
variant?: 'outline' | 'ghost';
|
||||||
|
labelClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one way to step through periods (UX-03) — same chevron pattern for
|
||||||
|
* years and months across Budget and Proyecciones. */
|
||||||
|
export default function PeriodNavigator({
|
||||||
|
label,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
prevDisabled,
|
||||||
|
nextDisabled,
|
||||||
|
variant = 'outline',
|
||||||
|
labelClassName = 'w-16 text-center font-semibold tabular-nums',
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size="icon"
|
||||||
|
className={variant === 'ghost' ? 'h-7 w-7' : undefined}
|
||||||
|
disabled={prevDisabled}
|
||||||
|
onClick={onPrev}
|
||||||
|
aria-label={`Período anterior (${label})`}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className={labelClassName}>{label}</span>
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size="icon"
|
||||||
|
className={variant === 'ghost' ? 'h-7 w-7' : undefined}
|
||||||
|
disabled={nextDisabled}
|
||||||
|
onClick={onNext}
|
||||||
|
aria-label={`Período siguiente (${label})`}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,17 +9,27 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
Banknote,
|
Banknote,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import api, { type Transaction } from '@/lib/api';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import api, { type Category, type Transaction } from '@/lib/api';
|
||||||
import TransactionModal from './TransactionModal';
|
import TransactionModal from './TransactionModal';
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { DataTable } from '@/components/ui/data-table';
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { getTransactionColumns } from '@/components/transactions/transaction-columns';
|
import { getTransactionColumns } from '@/components/transactions/transaction-columns';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -37,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({
|
||||||
@@ -50,14 +66,52 @@ 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);
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
const [pendingDeletes, setPendingDeletes] = useState<Set<number>>(new Set());
|
const [pendingDeletes, setPendingDeletes] = useState<Set<number>>(new Set());
|
||||||
const deleteTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
const deleteTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [bulkConfirm, setBulkConfirm] = useState(false);
|
||||||
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
|
||||||
|
const categoriesQ = useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
api.get<Category[]>('/categories/', { signal }).then((r) =>
|
||||||
|
[...r.data].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
),
|
||||||
|
enabled: selected.size > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearSelection = () => setSelected(new Set());
|
||||||
|
|
||||||
|
const runBulk = async (
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
success: string,
|
||||||
|
) => {
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ affected: number }>(
|
||||||
|
'/transactions/bulk',
|
||||||
|
{ ids: [...selected], ...payload },
|
||||||
|
);
|
||||||
|
toast.success(`${success} (${data.affected})`);
|
||||||
|
clearSelection();
|
||||||
|
onRefresh();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo aplicar la acción masiva');
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (tx: Transaction) => {
|
const handleEdit = (tx: Transaction) => {
|
||||||
setEditing(tx);
|
setEditing(tx);
|
||||||
@@ -111,9 +165,36 @@ export default function TransactionList({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allSelected =
|
||||||
|
transactions.length > 0 && transactions.every((t) => selected.has(t.id));
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
|
() =>
|
||||||
[showCategory, showSourceIcon, onToggleDeferred],
|
getTransactionColumns({
|
||||||
|
showCategory,
|
||||||
|
showSourceIcon,
|
||||||
|
onEdit: handleEdit,
|
||||||
|
onDelete: (id) => setDeleteId(id),
|
||||||
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
|
selection: {
|
||||||
|
selected,
|
||||||
|
allSelected,
|
||||||
|
onToggle: (id) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
onToggleAll: () =>
|
||||||
|
setSelected(
|
||||||
|
allSelected ? new Set() : new Set(transactions.map((t) => t.id)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[showCategory, showSourceIcon, onToggleDeferred, onConvertTasaCero, selected, allSelected, transactions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleTransactions = pendingDeletes.size
|
const visibleTransactions = pendingDeletes.size
|
||||||
@@ -121,35 +202,169 @@ 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>
|
||||||
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Plus className="w-4 h-4" />
|
{toolbarRight}
|
||||||
{addLabel}
|
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
||||||
</Button>
|
<Plus className="w-4 h-4" />
|
||||||
|
{addLabel}
|
||||||
|
</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 */}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<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">
|
||||||
|
{selected.size} seleccionada{selected.size === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (!v) return;
|
||||||
|
void runBulk(
|
||||||
|
{
|
||||||
|
action: 'set_category',
|
||||||
|
category_id: v === '__none__' ? null : Number(v),
|
||||||
|
},
|
||||||
|
'Categoría aplicada',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-44" disabled={bulkBusy}>
|
||||||
|
<SelectValue placeholder="Asignar categoría…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">Sin categoría</SelectItem>
|
||||||
|
{(categoriesQ.data ?? []).map((c) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{onToggleDeferred && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() =>
|
||||||
|
void runBulk(
|
||||||
|
{ action: 'set_deferred', deferred: true },
|
||||||
|
'Transacciones diferidas',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Diferir
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() =>
|
||||||
|
void runBulk(
|
||||||
|
{ action: 'set_deferred', deferred: false },
|
||||||
|
'Diferidas restauradas',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Restaurar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => setBulkConfirm(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 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" />}
|
||||||
{emptyMessage}
|
<p>{emptyMessage}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => { setEditing(null); setModalOpen(true); }}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
transactions.map((tx) => (
|
visibleTransactions.map((tx) => (
|
||||||
<div key={tx.id} className="flex items-center gap-3 px-4 py-3">
|
<div key={tx.id} className="flex items-center gap-3 px-4 py-3">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -175,9 +390,14 @@ 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('en-US', { month: 'short', day: 'numeric' })}
|
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
|
||||||
{showCategory && tx.category && (
|
{showCategory && tx.category && (
|
||||||
<span className="ml-1.5 text-muted-foreground/60">{tx.category.name}</span>
|
<span className="ml-1.5 text-muted-foreground/60">{tx.category.name}</span>
|
||||||
)}
|
)}
|
||||||
@@ -187,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"
|
||||||
@@ -206,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"
|
||||||
@@ -223,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}
|
||||||
@@ -237,7 +471,9 @@ export default function TransactionList({
|
|||||||
initialSorting={[{ id: 'date', desc: true }]}
|
initialSorting={[{ id: 'date', desc: true }]}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
@@ -250,6 +486,20 @@ export default function TransactionList({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{bulkConfirm && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Eliminar ${selected.size} transacciones`}
|
||||||
|
message="Esta acción no se puede deshacer."
|
||||||
|
confirmLabel="Eliminar todas"
|
||||||
|
loading={bulkBusy}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await runBulk({ action: 'delete' }, 'Transacciones eliminadas');
|
||||||
|
setBulkConfirm(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setBulkConfirm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{deleteId !== null && (
|
{deleteId !== null && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete Transaction"
|
title="Delete Transaction"
|
||||||
|
|||||||
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>();
|
|
||||||
sorted.forEach((item, i) => {
|
|
||||||
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
// Gemini: positional
|
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
data.forEach((item, i) => {
|
sorted.forEach((item, i) => map.set(item.name, rampColor(baseVar, i, sorted.length)));
|
||||||
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' },
|
||||||
@@ -68,36 +69,72 @@ export default function RecurringItemDialog({
|
|||||||
const [overrides, setOverrides] = useState<{ month: string; amount: string }[]>([]);
|
const [overrides, setOverrides] = useState<{ month: string; amount: string }[]>([]);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [baseline, setBaseline] = useState('');
|
||||||
|
|
||||||
|
const snapshot = () =>
|
||||||
|
JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]);
|
||||||
|
|
||||||
|
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
if (!next && !saving && snapshot() !== baseline) {
|
||||||
|
// Closing with unsaved edits — confirm before discarding (UX-06).
|
||||||
|
setConfirmDiscard(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open) return;
|
||||||
if (item) {
|
const init = item
|
||||||
setName(item.name);
|
? {
|
||||||
setAmount(String(item.amount));
|
name: item.name,
|
||||||
setItemType(item.item_type);
|
amount: String(item.amount),
|
||||||
setFrequency(item.frequency);
|
itemType: item.item_type,
|
||||||
setDayOfMonth(item.day_of_month != null ? String(item.day_of_month) : '');
|
frequency: item.frequency,
|
||||||
setMonthOfYear(item.month_of_year != null ? String(item.month_of_year) : '');
|
dayOfMonth: item.day_of_month != null ? String(item.day_of_month) : '',
|
||||||
setOverrides(
|
monthOfYear:
|
||||||
item.override_amounts
|
item.month_of_year != null ? String(item.month_of_year) : '',
|
||||||
|
overrides: item.override_amounts
|
||||||
? Object.entries(item.override_amounts).map(([m, a]) => ({
|
? Object.entries(item.override_amounts).map(([m, a]) => ({
|
||||||
month: m,
|
month: m,
|
||||||
amount: String(a),
|
amount: String(a),
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
);
|
notes: item.notes || '',
|
||||||
setNotes(item.notes || '');
|
}
|
||||||
} else {
|
: {
|
||||||
setName('');
|
name: '',
|
||||||
setAmount('');
|
amount: '',
|
||||||
setItemType('EXPENSE');
|
itemType: 'EXPENSE' as RecurringItemType,
|
||||||
setFrequency('MONTHLY');
|
frequency: 'MONTHLY' as RecurringFrequency,
|
||||||
setDayOfMonth('');
|
dayOfMonth: '',
|
||||||
setMonthOfYear('');
|
monthOfYear: '',
|
||||||
setOverrides([]);
|
overrides: [] as { month: string; amount: string }[],
|
||||||
setNotes('');
|
notes: '',
|
||||||
}
|
};
|
||||||
}
|
setName(init.name);
|
||||||
|
setAmount(init.amount);
|
||||||
|
setItemType(init.itemType);
|
||||||
|
setFrequency(init.frequency);
|
||||||
|
setDayOfMonth(init.dayOfMonth);
|
||||||
|
setMonthOfYear(init.monthOfYear);
|
||||||
|
setOverrides(init.overrides);
|
||||||
|
setNotes(init.notes);
|
||||||
|
// Baseline mirrors snapshot()'s field order exactly.
|
||||||
|
setBaseline(
|
||||||
|
JSON.stringify([
|
||||||
|
init.name,
|
||||||
|
init.amount,
|
||||||
|
init.itemType,
|
||||||
|
init.frequency,
|
||||||
|
init.dayOfMonth,
|
||||||
|
init.monthOfYear,
|
||||||
|
init.overrides,
|
||||||
|
init.notes,
|
||||||
|
]),
|
||||||
|
);
|
||||||
}, [open, item]);
|
}, [open, item]);
|
||||||
|
|
||||||
const showDayOfMonth = frequency === 'MONTHLY' || frequency === 'WEEKLY';
|
const showDayOfMonth = frequency === 'MONTHLY' || frequency === 'WEEKLY';
|
||||||
@@ -135,7 +172,7 @@ export default function RecurringItemDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEdit ? 'Editar' : 'Nuevo'} Item Recurrente</DialogTitle>
|
<DialogTitle>{isEdit ? 'Editar' : 'Nuevo'} Item Recurrente</DialogTitle>
|
||||||
@@ -296,7 +333,7 @@ export default function RecurringItemDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={saving || !name || !amount}>
|
<Button onClick={handleSubmit} disabled={saving || !name || !amount}>
|
||||||
@@ -304,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,14 +154,18 @@ export default function RecurringItemsManager({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<Card>
|
||||||
columns={columns}
|
<CardContent className="p-0">
|
||||||
data={items}
|
<DataTable
|
||||||
pagination
|
columns={columns}
|
||||||
pageSize={20}
|
data={items}
|
||||||
initialSorting={[{ id: 'item_type', desc: false }]}
|
pagination
|
||||||
emptyMessage="No hay items recurrentes."
|
pageSize={20}
|
||||||
/>
|
initialSorting={[{ id: 'item_type', desc: false }]}
|
||||||
|
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';
|
||||||
@@ -8,12 +8,21 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
||||||
|
|
||||||
|
export interface RowSelection {
|
||||||
|
selected: Set<number>;
|
||||||
|
allSelected: boolean;
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
onToggleAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface TransactionColumnOptions {
|
interface TransactionColumnOptions {
|
||||||
showCategory: boolean;
|
showCategory: boolean;
|
||||||
showSourceIcon?: boolean;
|
showSourceIcon?: boolean;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTransactionColumns({
|
export function getTransactionColumns({
|
||||||
@@ -22,14 +31,44 @@ export function getTransactionColumns({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
|
selection,
|
||||||
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
||||||
const columns: ColumnDef<Transaction, unknown>[] = [
|
const columns: ColumnDef<Transaction, unknown>[] = [];
|
||||||
|
|
||||||
|
if (selection) {
|
||||||
|
columns.push({
|
||||||
|
id: 'select',
|
||||||
|
size: 36,
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-primary w-4 h-4 cursor-pointer align-middle"
|
||||||
|
aria-label="Seleccionar todas las transacciones"
|
||||||
|
checked={selection.allSelected}
|
||||||
|
onChange={selection.onToggleAll}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-primary w-4 h-4 cursor-pointer align-middle"
|
||||||
|
aria-label={`Seleccionar ${row.original.merchant}`}
|
||||||
|
checked={selection.selected.has(row.original.id)}
|
||||||
|
onChange={() => selection.onToggle(row.original.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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('en-US', {
|
{new Date(row.original.date).toLocaleDateString('es-CR', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -39,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 (
|
||||||
@@ -70,17 +109,27 @@ 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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
|
||||||
if (showCategory) {
|
if (showCategory) {
|
||||||
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 ? (
|
||||||
@@ -97,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;
|
||||||
@@ -107,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' ? '-' : '+'}
|
||||||
@@ -125,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"
|
||||||
@@ -140,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)}
|
||||||
>
|
>
|
||||||
@@ -149,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);
|
||||||
|
|||||||
4504
frontend/src/lib/api-types.gen.ts
Normal file
4504
frontend/src/lib/api-types.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user