mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:28:48 +02:00
Compare commits
6 Commits
3ea1ef0fa0
...
248417dbab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
248417dbab | ||
|
|
fc3461c377 | ||
|
|
c3c14f54c5 | ||
|
|
84f0256b7c | ||
|
|
fe62e50a85 | ||
|
|
7dfe2da2a9 |
10
.github/workflows/deploy.yml
vendored
10
.github/workflows/deploy.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -15,3 +15,4 @@ tech_docs/
|
|||||||
# Claude Code local state
|
# Claude Code local state
|
||||||
.claude/
|
.claude/
|
||||||
.venv/
|
.venv/
|
||||||
|
frontend/openapi.json
|
||||||
|
|||||||
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,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}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
191
frontend/pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4058
frontend/src/lib/api-types.gen.ts
Normal file
4058
frontend/src/lib/api-types.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 } });
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
size="icon"
|
label={`${MONTH_NAMES[selectedMonth]} ${year}`}
|
||||||
className="h-7 w-7"
|
labelClassName="text-sm font-medium w-28 text-center"
|
||||||
disabled={selectedMonth <= 1}
|
onPrev={() => setSelectedMonth(selectedMonth - 1)}
|
||||||
onClick={() => setSelectedMonth(selectedMonth - 1)}
|
onNext={() => setSelectedMonth(selectedMonth + 1)}
|
||||||
>
|
prevDisabled={selectedMonth <= 1}
|
||||||
<ChevronLeft className="w-4 h-4" />
|
nextDisabled={selectedMonth >= 12}
|
||||||
</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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
207
frontend/src/pages/Planificador.tsx
Normal file
207
frontend/src/pages/Planificador.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
<Droplets className="w-4 h-4" />
|
<Droplets className="w-4 h-4" />
|
||||||
Consumo de Agua (m³)
|
Consumo de Agua (m³)
|
||||||
</h2>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user