Compare commits

...

6 Commits

Author SHA1 Message Date
Carlos Escalante
248417dbab PROGRESS: deferred items and wishlist complete — nothing remains
All checks were successful
Deploy to VPS / test (push) Successful in 1m43s
Deploy to VPS / deploy (push) Successful in 2m20s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:07:31 -06:00
Carlos Escalante
fc3461c377 PWA installability + working service worker (wishlist #8)
manifest.webmanifest with the existing icons, apple-touch-icon, and SW
registration at startup. The old sw.js was a self-unregistering cleanup
stub — meaning serviceWorker.ready never resolved and web push has been
silently dead; the new worker handles push display and notification
clicks, and makes the app installable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:07:02 -06:00
Carlos Escalante
c3c14f54c5 What-if savings planner page (wishlist #7)
New Planificador page: the current savings pace (prefilled from the
live budget projection's monthly net, editable) versus a scenario with
extra monthly savings, both compounded at a configurable annual rate
over a 1-40 year horizon — side-by-side totals and growth curves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:07:02 -06:00
Carlos Escalante
84f0256b7c Rate history chart and pension contribution calculator
Analytics gains a 90-day USD/CRC buy/sell chart from the existing
history endpoint (wishlist: rate history). Pensiones gains a
Calculadora de Aporte: fund + target amount + target age → required
monthly contribution via the annuity future-value formula, using the
fund's live balance and configured rate (wishlist: contribution
calculator).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:59:29 -06:00
Carlos Escalante
fe62e50a85 Cosmetics: shared PeriodNavigator, breadcrumb, meter labels
One chevron component now drives year/month stepping in Budget and
Proyecciones (UX-03). Jumping from Proyecciones to a Budget month shows
a back affordance (UX-22). Water meters can be named (Casa, Cochera…)
via a settings-persisted dialog; the chart legend uses the names
(UX-24).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:27:06 -06:00
Carlos Escalante
7dfe2da2a9 OpenAPI type codegen with CI drift gate (plan 3.4, ARCH-16, FE-14)
Every dict-returning route the SPA consumes now declares a response
model (auth, bulk, sync-status, notifications). api-types.gen.ts is
generated from app.openapi() via openapi-typescript, and the 22 hand-
written read interfaces in api.ts are now aliases onto the generated
schemas — backend schema drift becomes a typecheck failure, and CI
regenerates the file and fails the deploy if it's stale. The aliasing
immediately caught two real drifts: RecurringItemType was missing
SAVINGS, and raw_charges (untyped JSON column) is now explicitly
shaped at the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:53:00 -06:00
26 changed files with 5033 additions and 312 deletions

View File

@@ -18,11 +18,17 @@ jobs:
/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
/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:

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ tech_docs/
# Claude Code local state # Claude Code local state
.claude/ .claude/
.venv/ .venv/
frontend/openapi.json

View File

@@ -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.

View File

@@ -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}

View File

@@ -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),

View File

@@ -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),

View File

@@ -106,7 +106,11 @@ class BulkActionRequest(BaseModel):
deferred: Optional[bool] = None # for set_deferred deferred: Optional[bool] = None # for set_deferred
@router.post("/bulk") class BulkActionResponse(BaseModel):
affected: int
@router.post("/bulk", response_model=BulkActionResponse)
def bulk_action( def bulk_action(
req: BulkActionRequest, req: BulkActionRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),

View File

@@ -160,13 +160,22 @@ fallback already guarded `len >= 2`).
| UX-13 cycle filter | **mostly false positive** — daily-spending already accepts cycle params; the trend is multi-cycle by design | | 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 | | 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** except: 3.4 OpenAPI codegen (deferred until a **The improvement plan is complete** — and as of 2026-06-12, so is everything
response_model sweep), the three smallest 4.7 cosmetic items above, and the that had been deferred:
unscheduled 4.9 wishlist (what-if planner, PWA, rate history, contribution
calculator). Tests: **82**. Final false-positive tally across the whole plan: ### Final batch (2026-06-12)
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 | Item | Status |
"fix" landed. |---|---|
| 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 (2026-06-10)

View File

@@ -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>

View File

@@ -8,7 +8,8 @@
"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",
"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.52",
@@ -42,6 +43,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-swc": "^3.9.0",
"openapi-typescript": "^7.13.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"vite": "^6.3.5" "vite": "^6.3.5"

191
frontend/pnpm-lock.yaml generated
View File

@@ -96,6 +96,9 @@ importers:
'@vitejs/plugin-react-swc': '@vitejs/plugin-react-swc':
specifier: ^3.9.0 specifier: ^3.9.0
version: 3.11.0(@swc/helpers@0.5.21)(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) version: 3.11.0(@swc/helpers@0.5.21)(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
openapi-typescript:
specifier: ^7.13.0
version: 7.13.0(typescript@5.9.3)
tailwindcss: tailwindcss:
specifier: ^4 specifier: ^4
version: 4.2.4 version: 4.2.4
@@ -225,6 +228,14 @@ packages:
'@antfu/install-pkg@1.1.0': '@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.2': '@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -1215,6 +1226,16 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
'@redocly/config@0.22.0':
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
'@redocly/openapi-core@1.34.15':
resolution: {integrity: sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@reduxjs/toolkit@2.11.2': '@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies: peerDependencies:
@@ -1894,6 +1915,10 @@ packages:
ajv@8.20.0: ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-regex@5.0.1: ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1906,6 +1931,9 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6: aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1923,6 +1951,9 @@ packages:
bail@2.0.2: bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -1937,6 +1968,9 @@ packages:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
brace-expansion@2.1.1:
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
buffer-equal-constant-time@1.0.1: buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@@ -1966,6 +2000,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
character-entities-html4@2.1.0: character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
@@ -2024,6 +2061,9 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
colorette@2.0.20: colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -2724,6 +2764,10 @@ packages:
immer@11.1.4: immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -2808,12 +2852,20 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tiktoken@1.0.21: js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
json-bigint@1.0.0: json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
@@ -3305,6 +3357,10 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -3393,6 +3449,12 @@ packages:
zod: zod:
optional: true optional: true
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
p-finally@1.0.0: p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3426,6 +3488,10 @@ packages:
parse-entities@4.0.2: parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
parse5@7.3.0: parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -3483,6 +3549,10 @@ packages:
pkg-types@1.3.1: pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
points-on-curve@0.2.0: points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -3925,6 +3995,10 @@ packages:
stylis@4.4.0: stylis@4.4.0:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3992,6 +4066,10 @@ packages:
tw-animate-css@1.4.0: tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
type-graphql@2.0.0-rc.1: type-graphql@2.0.0-rc.1:
resolution: {integrity: sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q==} resolution: {integrity: sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q==}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
@@ -4077,6 +4155,9 @@ packages:
untruncate-json@0.0.1: untruncate-json@0.0.1:
resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==} resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==}
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
urlpattern-polyfill@10.1.0: urlpattern-polyfill@10.1.0:
resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==}
@@ -4275,6 +4356,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yargs-parser@21.1.1: yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -4464,6 +4548,14 @@ snapshots:
package-manager-detector: 1.6.0 package-manager-detector: 1.6.0
tinyexec: 1.1.1 tinyexec: 1.1.1
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/runtime@7.29.2': {} '@babel/runtime@7.29.2': {}
'@base-ui/react@1.4.1(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': '@base-ui/react@1.4.1(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
@@ -5430,6 +5522,29 @@ snapshots:
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
'@redocly/config@0.22.0': {}
'@redocly/openapi-core@1.34.15(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.11.2
'@redocly/config': 0.22.0
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.1.1
minimatch: 5.1.9
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)': '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -6039,6 +6154,8 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
ansi-colors@4.1.3: {}
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
ansi-styles@4.3.0: ansi-styles@4.3.0:
@@ -6047,6 +6164,8 @@ snapshots:
ansi-styles@5.2.0: {} ansi-styles@5.2.0: {}
argparse@2.0.1: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -6060,6 +6179,8 @@ snapshots:
bail@2.0.2: {} bail@2.0.2: {}
balanced-match@1.0.2: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
bignumber.js@9.3.1: {} bignumber.js@9.3.1: {}
@@ -6085,7 +6206,7 @@ snapshots:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
content-type: 1.0.5 content-type: 1.0.5
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
http-errors: 2.0.1 http-errors: 2.0.1
iconv-lite: 0.7.2 iconv-lite: 0.7.2
on-finished: 2.4.1 on-finished: 2.4.1
@@ -6095,6 +6216,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
brace-expansion@2.1.1:
dependencies:
balanced-match: 1.0.2
buffer-equal-constant-time@1.0.1: {} buffer-equal-constant-time@1.0.1: {}
buffer@6.0.3: buffer@6.0.3:
@@ -6123,6 +6248,8 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
change-case@5.4.4: {}
character-entities-html4@2.1.0: {} character-entities-html4@2.1.0: {}
character-entities-legacy@1.1.4: {} character-entities-legacy@1.1.4: {}
@@ -6178,6 +6305,8 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
colorette@1.4.0: {}
colorette@2.0.20: {} colorette@2.0.20: {}
combined-stream@1.0.8: combined-stream@1.0.8:
@@ -6443,9 +6572,11 @@ snapshots:
dependencies: dependencies:
ms: 2.0.0 ms: 2.0.0
debug@4.4.3: debug@4.4.3(supports-color@10.2.2):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
decamelize@1.2.0: {} decamelize@1.2.0: {}
@@ -6660,7 +6791,7 @@ snapshots:
content-type: 1.0.5 content-type: 1.0.5
cookie: 0.7.2 cookie: 0.7.2
cookie-signature: 1.2.2 cookie-signature: 1.2.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
depd: 2.0.0 depd: 2.0.0
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
@@ -6724,7 +6855,7 @@ snapshots:
finalhandler@2.1.1: finalhandler@2.1.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
on-finished: 2.4.1 on-finished: 2.4.1
@@ -6771,7 +6902,7 @@ snapshots:
gaxios@7.1.4: gaxios@7.1.4:
dependencies: dependencies:
extend: 3.0.2 extend: 3.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6(supports-color@10.2.2)
node-fetch: 3.3.2 node-fetch: 3.3.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7032,10 +7163,10 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
https-proxy-agent@7.0.6: https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7062,6 +7193,8 @@ snapshots:
immer@11.1.4: {} immer@11.1.4: {}
index-to-position@1.2.0: {}
inherits@2.0.4: {} inherits@2.0.4: {}
inline-style-parser@0.1.1: {} inline-style-parser@0.1.1: {}
@@ -7118,12 +7251,18 @@ snapshots:
joycon@3.1.1: {} joycon@3.1.1: {}
js-levenshtein@1.1.6: {}
js-tiktoken@1.0.21: js-tiktoken@1.0.21:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
json-bigint@1.0.0: json-bigint@1.0.0:
dependencies: dependencies:
bignumber.js: 9.3.1 bignumber.js: 9.3.1
@@ -7865,7 +8004,7 @@ snapshots:
micromark@3.2.0: micromark@3.2.0:
dependencies: dependencies:
'@types/debug': 4.1.13 '@types/debug': 4.1.13
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
decode-named-character-reference: 1.3.0 decode-named-character-reference: 1.3.0
micromark-core-commonmark: 1.1.0 micromark-core-commonmark: 1.1.0
micromark-factory-space: 1.1.0 micromark-factory-space: 1.1.0
@@ -7887,7 +8026,7 @@ snapshots:
micromark@4.0.2: micromark@4.0.2:
dependencies: dependencies:
'@types/debug': 4.1.13 '@types/debug': 4.1.13
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
decode-named-character-reference: 1.3.0 decode-named-character-reference: 1.3.0
devlop: 1.1.0 devlop: 1.1.0
micromark-core-commonmark: 2.0.3 micromark-core-commonmark: 2.0.3
@@ -7920,6 +8059,10 @@ snapshots:
mime@1.6.0: {} mime@1.6.0: {}
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.1
minimist@1.2.8: {} minimist@1.2.8: {}
mlly@1.8.2: mlly@1.8.2:
@@ -7993,6 +8136,16 @@ snapshots:
- encoding - encoding
optional: true optional: true
openapi-typescript@7.13.0(typescript@5.9.3):
dependencies:
'@redocly/openapi-core': 1.34.15(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 5.9.3
yargs-parser: 21.1.1
p-finally@1.0.0: {} p-finally@1.0.0: {}
p-queue@6.6.2: p-queue@6.6.2:
@@ -8036,6 +8189,12 @@ snapshots:
is-decimal: 2.0.1 is-decimal: 2.0.1
is-hexadecimal: 2.0.1 is-hexadecimal: 2.0.1
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.29.7
index-to-position: 1.2.0
type-fest: 4.41.0
parse5@7.3.0: parse5@7.3.0:
dependencies: dependencies:
entities: 6.0.1 entities: 6.0.1
@@ -8105,6 +8264,8 @@ snapshots:
mlly: 1.8.2 mlly: 1.8.2
pathe: 2.0.3 pathe: 2.0.3
pluralize@8.0.0: {}
points-on-curve@0.2.0: {} points-on-curve@0.2.0: {}
points-on-path@0.2.1: points-on-path@0.2.1:
@@ -8520,7 +8681,7 @@ snapshots:
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
depd: 2.0.0 depd: 2.0.0
is-promise: 4.0.0 is-promise: 4.0.0
parseurl: 1.3.3 parseurl: 1.3.3
@@ -8574,7 +8735,7 @@ snapshots:
send@1.2.1: send@1.2.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
etag: 1.8.1 etag: 1.8.1
@@ -8743,6 +8904,8 @@ snapshots:
stylis@4.4.0: {} stylis@4.4.0: {}
supports-color@10.2.2: {}
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
@@ -8795,6 +8958,8 @@ snapshots:
tw-animate-css@1.4.0: {} tw-animate-css@1.4.0: {}
type-fest@4.41.0: {}
type-graphql@2.0.0-rc.1(class-validator@0.14.4)(graphql-scalars@1.25.0(graphql@16.13.2))(graphql@16.13.2): type-graphql@2.0.0-rc.1(class-validator@0.14.4)(graphql-scalars@1.25.0(graphql@16.13.2))(graphql@16.13.2):
dependencies: dependencies:
'@graphql-yoga/subscription': 5.0.5 '@graphql-yoga/subscription': 5.0.5
@@ -8910,6 +9075,8 @@ snapshots:
untruncate-json@0.0.1: {} untruncate-json@0.0.1: {}
uri-js-replace@1.0.1: {}
urlpattern-polyfill@10.1.0: {} urlpattern-polyfill@10.1.0: {}
urql@4.2.2(@urql/core@5.2.0(graphql@16.13.2))(react@19.2.5): urql@4.2.2(@urql/core@5.2.0(graphql@16.13.2))(react@19.2.5):
@@ -9070,6 +9237,8 @@ snapshots:
y18n@5.0.8: {} y18n@5.0.8: {}
yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs@17.7.2: yargs@17.7.2:

View 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"
}
]
}

View File

@@ -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);
}),
);
}); });

View File

@@ -24,6 +24,7 @@ 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();
@@ -58,6 +59,7 @@ function AppRoutes() {
<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>

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { import {
Sparkles, Sparkles,
Telescope,
Calculator, Calculator,
BarChart3, BarChart3,
Landmark, Landmark,
@@ -55,6 +56,7 @@ const navSections: NavSection[] = [
{ to: "/salarios", icon: Landmark, label: "Salarios" }, { to: "/salarios", icon: Landmark, label: "Salarios" },
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" }, { to: "/pensions", icon: PiggyBank, label: "Pensiones" },
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" }, { to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
{ to: "/planificador", icon: Telescope, label: "Planificador" },
{ to: "/analytics", icon: BarChart3, label: "Analytics" }, { to: "/analytics", icon: BarChart3, label: "Analytics" },
], ],
}, },

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,10 @@
import type { components } from "./api-types.gen";
/** Schemas generated from the backend OpenAPI spec (pnpm gen:api). Aliasing
* the app's read types to them makes backend schema drift a typecheck error
* instead of a runtime surprise (review ARCH-16 / FE-14 / plan 3.4). */
type Schema<K extends keyof components["schemas"]> = components["schemas"][K];
const BASE_URL = "/api/v1"; const BASE_URL = "/api/v1";
class ApiError extends Error { class ApiError extends Error {
@@ -125,91 +132,22 @@ export async function logout() {
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
export interface Account { export type Account = Schema<'AccountRead'>;
id: number;
bank: string;
currency: string;
label: string;
balance: number;
account_type: string;
next_payment: number | null;
updated_at: string;
}
export interface Category { export type Category = Schema<'CategoryRead'>;
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export interface SkippedDuplicate { export type SkippedDuplicate = Schema<'SkippedDuplicate'>;
line: number;
merchant: string;
date: string;
amount: number;
currency: string;
existing_id: number;
existing_merchant: string;
existing_date: string;
existing_amount: number;
existing_source: string;
}
export interface ImportResult { export type ImportResult = Schema<'PasteImportResult'>;
imported: number;
duplicates: number;
errors: string[];
skipped: SkippedDuplicate[];
}
export interface Transaction { export type Transaction = Schema<'TransactionRead'>;
id: number;
amount: number;
currency: string;
merchant: string;
city: string | null;
date: string;
card_type: string | null;
card_last4: string | null;
authorization_code: string | null;
reference: string | null;
transaction_type: string;
source: string;
bank: string;
notes: string | null;
category_id: number | null;
category: Category | null;
deferred_to_next_cycle: boolean;
created_at: string;
}
// --- Budget / Recurring Items --- // --- Budget / Recurring Items ---
export type RecurringItemType = "INCOME" | "EXPENSE"; export type RecurringItemType = Schema<"RecurringItemType">;
export type RecurringFrequency = export type RecurringFrequency = Schema<"RecurringFrequency">;
| "WEEKLY"
| "MONTHLY"
| "QUARTERLY"
| "BIANNUAL"
| "YEARLY";
export interface RecurringItem { export type RecurringItem = Schema<'RecurringItemRead'>;
id: number;
name: string;
amount: number;
currency: string;
item_type: RecurringItemType;
frequency: RecurringFrequency;
day_of_month: number | null;
month_of_year: number | null;
override_amounts: Record<string, number> | null;
category_id: number | null;
is_active: boolean;
notes: string | null;
created_at: string;
category: Category | null;
}
export interface RecurringItemCreate { export interface RecurringItemCreate {
name: string; name: string;
@@ -239,76 +177,19 @@ export interface RecurringItemUpdate {
notes?: string | null; notes?: string | null;
} }
export interface RecurringItemDetail { export type RecurringItemDetail = Schema<'RecurringItemDetail'>;
id: number;
name: string;
amount: number;
projected_amount: number | null;
used_actual: boolean;
item_type: string;
frequency: string;
category_name: string | null;
category_id: number | null;
}
export interface ActualsBySource { export type ActualsBySource = Schema<'ActualsBySource'>;
source: string;
total_compra: number;
total_devolucion: number;
net: number;
count: number;
}
export interface MonthlyProjection { export type MonthlyProjection = Schema<'MonthlyProjectionResponse'>;
month: number;
year: number;
projected_income: number;
projected_fixed_expenses: number;
actual_credit_card: number;
actual_cash: number;
actual_transfers: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
carryover_balance: number;
cumulative_balance: number;
balance_overridden: boolean;
}
export interface YearlyProjection { export type YearlyProjection = Schema<'YearlyProjectionResponse'>;
year: number;
months: MonthlyProjection[];
annual_income: number;
annual_expenses: number;
annual_net: number;
}
export interface MonthlyDetail { export type MonthlyDetail = Schema<'MonthlyDetailResponse'>;
year: number;
month: number;
income_items: RecurringItemDetail[];
expense_items: RecurringItemDetail[];
actuals_by_source: ActualsBySource[];
total_projected_income: number;
total_projected_expenses: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
cc_by_category: { category_name: string; amount: number }[];
}
// --- Savings Accrual --- // --- Savings Accrual ---
export interface SavingsAccrual { export type SavingsAccrual = Schema<'SavingsAccrualRead'>;
id: number;
year: number;
month: number;
memp_amount: number;
mpat_amount: number;
trigger_transaction_id: number | null;
applied_at: string;
notes: string | null;
}
export interface SavingsAccrualCreate { export interface SavingsAccrualCreate {
year: number; year: number;
@@ -361,11 +242,7 @@ export const deleteBalanceOverride = (year: number, month: number) =>
// --- Salarios --- // --- Salarios ---
export interface SalariosSummary { export type SalariosSummary = Schema<'SalariosSummary'>;
count: number;
total_amount: number;
latest_date: string | null;
}
export const getSalarios = (params?: { limit?: number; offset?: number }) => export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>("/salarios/", { params }); api.get<Transaction[]>("/salarios/", { params });
@@ -374,32 +251,9 @@ export const getSalariosSummary = () =>
// --- Pensions --- // --- Pensions ---
export interface PensionSnapshot { export type PensionSnapshot = Schema<'PensionSnapshotRead'>;
id: number;
fund: string;
contract_number: string;
period_start: string;
period_end: string;
saldo_anterior: number;
aportes: number;
rendimientos: number;
retiros: number;
traslados: number;
comision: number;
correccion: number;
bonificacion: number;
saldo_final: number;
source_filename: string;
created_at: string;
}
export interface PensionUploadResult { export type PensionUploadResult = Schema<'PensionUploadResult'>;
imported: number;
updated: number;
duplicates: number;
errors: string[];
snapshots: PensionSnapshot[];
}
export interface PensionManualEntry { export interface PensionManualEntry {
fund: string; fund: string;
@@ -436,51 +290,19 @@ export interface MunicipalCharge {
amount: number; amount: number;
} }
export interface WaterMeterReading { export type WaterMeterReading = Schema<'WaterMeterReadingRead'>;
id: number;
meter_id: string;
period: string;
reading_previous: number;
reading_current: number;
consumption_m3: number;
agua_potable: number;
serv_ambientales: number;
alcant_sanitario: number;
iva: number;
is_historical: boolean;
receipt_id: number | null;
created_at: string;
}
export interface MunicipalReceipt { export type MunicipalReceipt = Omit<
id: number; Schema<'MunicipalReceiptRead'>,
receipt_date: string; 'raw_charges'
due_date: string; > & { raw_charges: MunicipalCharge[] };
period: string;
account: string;
finca: string;
holder_name: string;
holder_cedula: string;
holder_address: string;
subtotal: number;
interests: number;
iva: number;
total: number;
raw_charges: MunicipalCharge[];
source_filename: string;
created_at: string;
}
export interface MunicipalReceiptDetail extends MunicipalReceipt { export type MunicipalReceiptDetail = Omit<
water_readings: WaterMeterReading[]; Schema<'MunicipalReceiptDetailRead'>,
} 'raw_charges'
> & { raw_charges: MunicipalCharge[] };
export interface MunicipalReceiptUploadResult { export type MunicipalReceiptUploadResult = Schema<'MunicipalReceiptUploadResult'>;
imported: number;
updated: number;
errors: string[];
receipt: MunicipalReceipt | null;
}
export const uploadMunicipalReceipt = (file: File) => { export const uploadMunicipalReceipt = (file: File) => {
const form = new FormData(); const form = new FormData();
@@ -502,20 +324,9 @@ export const getWaterConsumption = (months?: number) =>
// --- Sync Status --- // --- Sync Status ---
export interface SyncSource { export type SyncSource = Schema<'SyncSourceStatus'>;
key: string;
label: string;
description: string;
last_received: string | null;
age_days: number | null;
warn_after_days: number;
status: 'ok' | 'warning' | 'never';
}
export interface SyncStatusResponse { export type SyncStatusResponse = Schema<'SyncStatusResponse'>;
sources: SyncSource[];
warnings: number;
}
export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/'); export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');
@@ -523,10 +334,13 @@ export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');
export type UserSettingsData = Record<string, unknown>; export type UserSettingsData = Record<string, unknown>;
export interface UserSettingsRead { export type UserSettingsRead = Schema<'UserSettingsRead'>;
key: string;
data: UserSettingsData;
updated_at: string;
}
export const getUserSettings = () => api.get<UserSettingsRead>('/settings/'); export const getUserSettings = () => api.get<UserSettingsRead>('/settings/');
// --- Exchange Rate ---
export type ExchangeRatePoint = Schema<'ExchangeRateRead'>;
export const getRateHistory = (days = 90) =>
api.get<ExchangeRatePoint[]>('/exchange-rate/history', { params: { days } });

View File

@@ -3,6 +3,13 @@ import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
if ("serviceWorker" in navigator) {
// Required for both web push and PWA installability.
navigator.serviceWorker.register("/sw.js").catch((err) => {
console.warn("Service worker registration failed:", err);
});
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <App />

View File

@@ -13,7 +13,7 @@ import {
} from 'recharts'; } from 'recharts';
import { BarChart3 } from 'lucide-react'; import { BarChart3 } from 'lucide-react';
import api from '@/lib/api'; import api, { getRateHistory } from '@/lib/api';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import BillingCycleSelector from '@/components/BillingCycleSelector'; import BillingCycleSelector from '@/components/BillingCycleSelector';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -63,6 +63,11 @@ const trendChartConfig = {
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
const rateChartConfig = {
sell_rate: { label: 'Venta', color: 'var(--chart-1)' },
buy_rate: { label: 'Compra', color: 'var(--chart-2)' },
} satisfies ChartConfig;
const dailyChartConfig = { const dailyChartConfig = {
total: { total: {
label: 'Gasto Diario', label: 'Gasto Diario',
@@ -103,6 +108,16 @@ export default function Analytics() {
const byCategory = byCategoryQ.data ?? []; const byCategory = byCategoryQ.data ?? [];
const trend = trendQ.data ?? []; const trend = trendQ.data ?? [];
const daily = dailyQ.data ?? []; const daily = dailyQ.data ?? [];
const ratesQ = useQuery({
queryKey: ['exchange-rate', 'history'],
queryFn: () =>
getRateHistory(90).then((r) =>
[...r.data]
.sort((a, b) => a.date.localeCompare(b.date))
.map((p) => ({ ...p, day: p.date.slice(0, 10) })),
),
staleTime: 60 * 60_000,
});
const anyError = byCategoryQ.isError || trendQ.isError || dailyQ.isError; const anyError = byCategoryQ.isError || trendQ.isError || dailyQ.isError;
const retryAll = () => { const retryAll = () => {
byCategoryQ.refetch(); byCategoryQ.refetch();
@@ -328,6 +343,67 @@ export default function Analytics() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Exchange rate history */}
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
Tipo de Cambio USD/CRC (90 días)
</CardTitle>
</CardHeader>
<CardContent>
{(ratesQ.data?.length ?? 0) < 2 ? (
<div className="h-40 flex items-center justify-center text-muted-foreground text-sm">
Sin historial suficiente
</div>
) : (
<ChartContainer
config={rateChartConfig}
className="h-[200px] w-full"
>
<LineChart data={ratesQ.data}>
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tickFormatter={(v) =>
new Date(v).toLocaleDateString('es-CR', {
month: 'short',
day: 'numeric',
})
}
/>
<YAxis
axisLine={false}
tickLine={false}
domain={['auto', 'auto']}
width={44}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => `${Number(value).toFixed(2)}`}
/>
}
/>
<Line
type="monotone"
dataKey="sell_rate"
stroke="var(--chart-1)"
dot={false}
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="buy_rate"
stroke="var(--chart-2)"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator, Download } from 'lucide-react'; import { ArrowLeft, Calculator, Download } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -9,6 +10,7 @@ import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail'; import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager'; import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList'; import TransactionList from '@/components/TransactionList';
@@ -34,6 +36,10 @@ export default function Budget() {
refresh, refresh,
} = useBudget(currentYear); } = useBudget(currentYear);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const cameFromProyecciones =
(location.state as { from?: string } | null)?.from === 'proyecciones';
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail'); const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
const [txSearch, setTxSearch] = useState(''); const [txSearch, setTxSearch] = useState('');
@@ -103,18 +109,27 @@ export default function Budget() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{cameFromProyecciones && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones"
>
<ArrowLeft className="w-4 h-4 mr-1" aria-hidden="true" />
Proyecciones
</Button>
)}
<Calculator className="w-6 h-6 text-primary" /> <Calculator className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1> <h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div> </div>
<div className="flex items-center gap-1"> <PeriodNavigator
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}> label={String(year)}
<ChevronLeft className="w-4 h-4" /> onPrev={() => setYear(year - 1)}
</Button> onNext={() => setYear(year + 1)}
<span className="w-16 text-center font-semibold tabular-nums">{year}</span> prevDisabled={year <= MIN_YEAR}
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}> nextDisabled={year >= MAX_YEAR}
<ChevronRight className="w-4 h-4" /> />
</Button>
</div>
</div> </div>
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
@@ -133,29 +148,15 @@ export default function Budget() {
<TabsTrigger value="detail">Detalle</TabsTrigger> <TabsTrigger value="detail">Detalle</TabsTrigger>
<TabsTrigger value="transactions">Transacciones</TabsTrigger> <TabsTrigger value="transactions">Transacciones</TabsTrigger>
</TabsList> </TabsList>
<div className="flex items-center gap-1"> <PeriodNavigator
<Button variant="ghost"
variant="ghost" label={`${MONTH_NAMES[selectedMonth]} ${year}`}
size="icon" labelClassName="text-sm font-medium w-28 text-center"
className="h-7 w-7" onPrev={() => setSelectedMonth(selectedMonth - 1)}
disabled={selectedMonth <= 1} onNext={() => setSelectedMonth(selectedMonth + 1)}
onClick={() => setSelectedMonth(selectedMonth - 1)} prevDisabled={selectedMonth <= 1}
> nextDisabled={selectedMonth >= 12}
<ChevronLeft className="w-4 h-4" /> />
</Button>
<span className="text-sm font-medium w-28 text-center">
{MONTH_NAMES[selectedMonth]} {year}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={selectedMonth >= 12}
onClick={() => setSelectedMonth(selectedMonth + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div> </div>
<TabsContent value="detail" className="space-y-6 mt-4"> <TabsContent value="detail" className="space-y-6 mt-4">

View File

@@ -28,6 +28,13 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -40,7 +47,7 @@ import {
type PensionUploadResult, type PensionUploadResult,
} from '@/lib/api'; } from '@/lib/api';
import PensionManualEntryModal from '@/components/PensionManualEntryModal'; import PensionManualEntryModal from '@/components/PensionManualEntryModal';
import { ClipboardPaste, SlidersHorizontal } from 'lucide-react'; import { ClipboardPaste, SlidersHorizontal, Target } from 'lucide-react';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -317,6 +324,26 @@ export default function Pensions() {
); );
const [showAssumptions, setShowAssumptions] = useState(false); const [showAssumptions, setShowAssumptions] = useState(false);
// ── Contribution calculator (wishlist #9): required monthly aporte to
// reach a target balance by a target age, given the fund's annual rate.
const [calcFund, setCalcFund] = useState<FundKey>('ROP');
const [calcTarget, setCalcTarget] = useState('50000000');
const [calcAge, setCalcAge] = useState('65');
const requiredMonthly = useMemo(() => {
const fund = FUNDS[calcFund];
const target = parseFloat(calcTarget);
const targetAge = parseInt(calcAge, 10);
const months = (targetAge - CURRENT_AGE) * 12;
if (!fund || isNaN(target) || isNaN(targetAge) || months <= 0) return null;
const r = fund.annualRate / 100 / 12;
const growth = Math.pow(1 + r, months);
const fromBalance = fund.startBalance * growth;
if (fromBalance >= target) return 0;
// FV of an annuity: PMT * ((1+r)^n - 1) / r
return ((target - fromBalance) * r) / (growth - 1);
}, [FUNDS, calcFund, calcTarget, calcAge]);
// Build a map of fund -> latest snapshot for rendimientos display // Build a map of fund -> latest snapshot for rendimientos display
const snapshotByFund = useMemo(() => { const snapshotByFund = useMemo(() => {
const map: Partial<Record<FundKey, PensionSnapshot>> = {}; const map: Partial<Record<FundKey, PensionSnapshot>> = {};
@@ -773,6 +800,64 @@ export default function Pensions() {
/> />
)} )}
{/* ── Contribution calculator ──────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Target className="w-4 h-4" />
Calculadora de Aporte
</h2>
<Card>
<CardContent className="p-4 grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
<div className="space-y-1">
<Label className="text-xs">Fondo</Label>
<Select value={calcFund} onValueChange={(v) => v && setCalcFund(v as FundKey)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FUND_KEYS.map((k) => (
<SelectItem key={k} value={k}>{FUNDS[k].name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Meta ()</Label>
<Input
type="number"
min="0"
step="1000000"
value={calcTarget}
onChange={(e) => setCalcTarget(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">A los (edad)</Label>
<Input
type="number"
min={CURRENT_AGE + 1}
max="80"
value={calcAge}
onChange={(e) => setCalcAge(e.target.value)}
/>
</div>
<div className="text-right sm:text-left">
<p className="text-xs text-muted-foreground">Aporte mensual requerido</p>
<p data-sensitive className="text-xl font-bold font-mono text-primary">
{requiredMonthly === null
? '—'
: requiredMonthly === 0
? 'Meta ya alcanzable'
: formatCRC(Math.ceil(requiredMonthly / 1000) * 1000)}
</p>
<p className="text-[11px] text-muted-foreground">
al {FUNDS[calcFund].annualRate}% anual, saldo actual incluido
</p>
</div>
</CardContent>
</Card>
</section>
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */} {/* ── Section 5: PDF Upload ────────────────────────────────────────── */}
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -0,0 +1,207 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Line, LineChart, XAxis, YAxis } from 'recharts';
import { Telescope } from 'lucide-react';
import { getYearlyProjection } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
const MIN_YEAR = 2026;
const chartConfig = {
baseline: { label: 'Ritmo actual', color: 'var(--chart-2)' },
scenario: { label: 'Escenario', color: 'var(--chart-1)' },
} satisfies ChartConfig;
const formatCRC = (v: number) =>
`${Math.round(v).toLocaleString('es-CR')}`;
/** What-if planner (wishlist #7): compare the current savings pace against a
* scenario with extra monthly savings, both compounded monthly. The baseline
* pace comes from the live budget projection (annual net / 12) and is
* editable so scenarios can start from any assumption. */
export default function Planificador() {
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
const projectionQ = useQuery({
queryKey: ['budget', 'projection', currentYear],
queryFn: () => getYearlyProjection(currentYear).then((r) => r.data),
});
const projectedMonthlyNet = projectionQ.data
? Math.round(projectionQ.data.annual_net / 12)
: null;
const [baseMonthly, setBaseMonthly] = useState<string | null>(null);
const [extraMonthly, setExtraMonthly] = useState('50000');
const [years, setYears] = useState('5');
const [annualRate, setAnnualRate] = useState('5');
// Until the user edits it, the baseline follows the live projection.
const effectiveBase =
baseMonthly !== null
? parseFloat(baseMonthly) || 0
: projectedMonthlyNet ?? 0;
const { series, baselineEnd, scenarioEnd } = useMemo(() => {
const horizon = Math.min(Math.max(parseInt(years, 10) || 0, 1), 40) * 12;
const extra = parseFloat(extraMonthly) || 0;
const r = (parseFloat(annualRate) || 0) / 100 / 12;
let baseline = 0;
let scenario = 0;
const points: { month: number; label: string; baseline: number; scenario: number }[] = [];
for (let m = 1; m <= horizon; m++) {
baseline = baseline * (1 + r) + effectiveBase;
scenario = scenario * (1 + r) + effectiveBase + extra;
if (m % 3 === 0 || m === horizon) {
points.push({
month: m,
label: m % 12 === 0 ? `${m / 12} a` : `${m} m`,
baseline: Math.round(baseline),
scenario: Math.round(scenario),
});
}
}
return {
series: points,
baselineEnd: Math.round(baseline),
scenarioEnd: Math.round(scenario),
};
}, [effectiveBase, extraMonthly, years, annualRate]);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Telescope className="w-6 h-6 text-primary" aria-hidden="true" />
<div>
<h1 className="text-2xl font-bold tracking-tight">Planificador</h1>
<p className="text-sm text-muted-foreground">
¿Qué pasa si ahorrás un poco más cada mes?
</p>
</div>
</div>
<Card>
<CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-1">
<Label className="text-xs">Ahorro mensual actual ()</Label>
<Input
type="number"
step="10000"
value={baseMonthly ?? (projectedMonthlyNet ?? '')}
placeholder={projectionQ.isPending ? 'Calculando…' : '0'}
onChange={(e) => setBaseMonthly(e.target.value)}
/>
<p className="text-[11px] text-muted-foreground">
Prellenado con tu proyección de {currentYear}
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">Ahorro extra mensual ()</Label>
<Input
type="number"
step="10000"
min="0"
value={extraMonthly}
onChange={(e) => setExtraMonthly(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Horizonte (años)</Label>
<Input
type="number"
min="1"
max="40"
value={years}
onChange={(e) => setYears(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.5"
value={annualRate}
onChange={(e) => setAnnualRate(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Ritmo actual
</p>
<p data-sensitive className="text-xl font-bold font-mono">
{formatCRC(baselineEnd)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Escenario
</p>
<p data-sensitive className="text-xl font-bold font-mono text-primary">
{formatCRC(scenarioEnd)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Diferencia
</p>
<p data-sensitive className="text-xl font-bold font-mono text-emerald-500">
+{formatCRC(scenarioEnd - baselineEnd)}
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
Crecimiento comparado
</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer data-sensitive config={chartConfig} className="h-[300px] w-full">
<LineChart data={series}>
<XAxis dataKey="label" axisLine={false} tickLine={false} />
<YAxis
axisLine={false}
tickLine={false}
width={56}
tickFormatter={(v) => `${(v / 1_000_000).toFixed(1)}M`}
/>
<ChartTooltip
content={
<ChartTooltipContent formatter={(value) => formatCRC(Number(value))} />
}
/>
<Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} />
<Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} />
</LineChart>
</ChartContainer>
<p className="text-xs text-muted-foreground mt-2">
Aporte compuesto mensualmente. El ritmo actual usa el balance neto
proyectado de tu presupuesto; ambos escenarios asumen que lo ahorrado
se invierte al rendimiento indicado.
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,12 +1,12 @@
import { ChevronLeft, ChevronRight, Loader2, TrendingUp } from 'lucide-react'; import { Loader2, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useBudget } from '@/hooks/useBudget'; import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator';
import YearlyOverview from '@/components/budget/YearlyOverview'; import YearlyOverview from '@/components/budget/YearlyOverview';
const MIN_YEAR = 2026; const MIN_YEAR = 2026;
@@ -36,15 +36,13 @@ export default function Proyecciones() {
<TrendingUp className="w-6 h-6 text-primary" /> <TrendingUp className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1> <h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1>
</div> </div>
<div className="flex items-center gap-1"> <PeriodNavigator
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}> label={String(year)}
<ChevronLeft className="w-4 h-4" /> onPrev={() => setYear(year - 1)}
</Button> onNext={() => setYear(year + 1)}
<span className="w-16 text-center font-semibold tabular-nums">{year}</span> prevDisabled={year <= MIN_YEAR}
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}> nextDisabled={year >= MAX_YEAR}
<ChevronRight className="w-4 h-4" /> />
</Button>
</div>
</div> </div>
{/* Annual summary cards */} {/* Annual summary cards */}
@@ -101,7 +99,7 @@ export default function Proyecciones() {
year={year} year={year}
onSelectMonth={(m) => { onSelectMonth={(m) => {
setSelectedMonth(m); setSelectedMonth(m);
navigate('/budget'); navigate('/budget', { state: { from: 'proyecciones' } });
}} }}
onSaveOverride={async (month, value) => { onSaveOverride={async (month, value) => {
await saveBalanceOverride(year, month, value); await saveBalanceOverride(year, month, value);

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useMemo } from 'react'; import { useState, useCallback, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates'; import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates';
import MeterLabelsDialog from '@/components/MeterLabelsDialog';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { import {
BarChart, BarChart,
@@ -44,6 +45,7 @@ import {
type MunicipalReceipt, type MunicipalReceipt,
type MunicipalReceiptUploadResult, type MunicipalReceiptUploadResult,
type WaterMeterReading, type WaterMeterReading,
getUserSettings,
} from '@/lib/api'; } from '@/lib/api';
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
@@ -195,6 +197,14 @@ export default function ServiciosMunicipales() {
const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]); const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const settingsQ = useQuery({
queryKey: ['settings'],
queryFn: () => getUserSettings().then((r) => r.data),
});
const meterLabels = ((settingsQ.data?.data?.meter_labels ?? {}) as Record<string, string>);
const meterLabel = (id: string) => meterLabels[id] || `Medidor ${id}`;
const [showLabelEditor, setShowLabelEditor] = useState(false);
const municipalQ = useQuery({ const municipalQ = useQuery({
queryKey: ['municipal'], queryKey: ['municipal'],
queryFn: async () => { queryFn: async () => {
@@ -396,10 +406,15 @@ export default function ServiciosMunicipales() {
{/* ── Water Consumption Chart ─────────────────────────────────────── */} {/* ── Water Consumption Chart ─────────────────────────────────────── */}
{chartData.length > 0 && ( {chartData.length > 0 && (
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider"> <div className="flex items-center justify-between">
<Droplets className="w-4 h-4" /> <h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
Consumo de Agua (m³) <Droplets className="w-4 h-4" />
</h2> Consumo de Agua (m³)
</h2>
<Button variant="outline" size="sm" onClick={() => setShowLabelEditor(true)}>
Etiquetas
</Button>
</div>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={280}>
@@ -435,7 +450,7 @@ export default function ServiciosMunicipales() {
opacity: hiddenMeters.has(value) ? 0.4 : 1, opacity: hiddenMeters.has(value) ? 0.4 : 1,
cursor: 'pointer', cursor: 'pointer',
}}> }}>
Medidor {value} {meterLabel(value)}
</span> </span>
)} )}
/> />
@@ -806,6 +821,14 @@ export default function ServiciosMunicipales() {
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
{showLabelEditor && (
<MeterLabelsDialog
meterIds={meterIds}
current={meterLabels}
settingsData={settingsQ.data?.data ?? {}}
onClose={() => setShowLabelEditor(false)}
/>
)}
</div> </div>
); );
} }