mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:28:47 +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/python -m pytest tests/ -q
|
||||
|
||||
- name: Frontend typecheck
|
||||
- name: Frontend typecheck + API type drift gate
|
||||
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
|
||||
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
|
||||
|
||||
deploy:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ tech_docs/
|
||||
# Claude Code local state
|
||||
.claude/
|
||||
.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
|
||||
|
||||
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 pydantic import BaseModel
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app.auth import (
|
||||
@@ -11,7 +12,16 @@ from app.auth import (
|
||||
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()):
|
||||
check_login_rate_limit(request)
|
||||
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"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||
return {"username": username}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pywebpush import WebPushException, webpush
|
||||
from sqlmodel import Session, select
|
||||
@@ -15,14 +16,22 @@ logger = logging.getLogger(__name__)
|
||||
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)):
|
||||
if not settings.VAPID_PUBLIC_KEY:
|
||||
raise HTTPException(status_code=503, detail="Push notifications not configured")
|
||||
return {"publicKey": settings.VAPID_PUBLIC_KEY}
|
||||
|
||||
|
||||
@router.post("/subscribe", status_code=201)
|
||||
@router.post("/subscribe", status_code=201, response_model=SubscriptionStatusResponse)
|
||||
def subscribe(
|
||||
data: PushSubscriptionCreate,
|
||||
session: Session = Depends(get_session),
|
||||
@@ -48,7 +57,7 @@ def subscribe(
|
||||
return {"status": "subscribed"}
|
||||
|
||||
|
||||
@router.delete("/unsubscribe")
|
||||
@router.delete("/unsubscribe", response_model=SubscriptionStatusResponse)
|
||||
def unsubscribe(
|
||||
data: PushSubscriptionCreate,
|
||||
session: Session = Depends(get_session),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
|
||||
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.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(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
@@ -106,7 +106,11 @@ class BulkActionRequest(BaseModel):
|
||||
deferred: Optional[bool] = None # for set_deferred
|
||||
|
||||
|
||||
@router.post("/bulk")
|
||||
class BulkActionResponse(BaseModel):
|
||||
affected: int
|
||||
|
||||
|
||||
@router.post("/bulk", response_model=BulkActionResponse)
|
||||
def bulk_action(
|
||||
req: BulkActionRequest,
|
||||
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-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
|
||||
response_model sweep), the three smallest 4.7 cosmetic items above, and the
|
||||
unscheduled 4.9 wishlist (what-if planner, PWA, rate history, contribution
|
||||
calculator). 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.
|
||||
**The improvement plan is complete** — and as of 2026-06-12, so is everything
|
||||
that had been deferred:
|
||||
|
||||
### Final batch (2026-06-12)
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| 3.4 OpenAPI codegen: response_model sweep + generated api-types.gen.ts + CI drift gate (caught 2 real drifts: missing SAVINGS enum value, untyped raw_charges) | ✅ |
|
||||
| UX-03 shared PeriodNavigator · UX-22 breadcrumb · UX-24 meter labels | ✅ |
|
||||
| Wishlist: rate-history chart (Analytics), contribution calculator (Pensiones), what-if Planificador page, PWA installability | ✅ |
|
||||
| Bonus fix: sw.js was a self-unregistering stub — `serviceWorker.ready` never resolved, so web push had been silently dead; replaced with a working worker | ✅ |
|
||||
|
||||
Tests: **82**. Final false-positive tally across the whole plan: BE-06, BE-09,
|
||||
BE-10, ARCH-03, ARCH-08, BE-22 (accepted), FE-04, UX-04, UX-11, UX-13 — the
|
||||
characterization-tests-first approach caught all of them before any "fix"
|
||||
landed. **Nothing from the review or wishlist remains.**
|
||||
|
||||
### Iteration 1 (2026-06-10)
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<meta name="description" content="WealthySmart — Smart personal finance management" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<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>
|
||||
</head>
|
||||
<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\"",
|
||||
"build": "vite build",
|
||||
"preview": "tsx server.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.52",
|
||||
@@ -42,6 +43,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^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':
|
||||
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))
|
||||
openapi-typescript:
|
||||
specifier: ^7.13.0
|
||||
version: 7.13.0(typescript@5.9.3)
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.2.4
|
||||
@@ -225,6 +228,14 @@ packages:
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1215,6 +1226,16 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||
peerDependencies:
|
||||
@@ -1894,6 +1915,10 @@ packages:
|
||||
ajv@8.20.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1906,6 +1931,9 @@ packages:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1923,6 +1951,9 @@ packages:
|
||||
bail@2.0.2:
|
||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -1937,6 +1968,9 @@ packages:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
@@ -1966,6 +2000,9 @@ packages:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
change-case@5.4.4:
|
||||
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
|
||||
|
||||
character-entities-html4@2.1.0:
|
||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||
|
||||
@@ -2024,6 +2061,9 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
colorette@1.4.0:
|
||||
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
@@ -2724,6 +2764,10 @@ packages:
|
||||
immer@11.1.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
@@ -2808,12 +2852,20 @@ packages:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-levenshtein@1.1.6:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
json-bigint@1.0.0:
|
||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||
|
||||
@@ -3305,6 +3357,10 @@ packages:
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
minimatch@5.1.9:
|
||||
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
@@ -3393,6 +3449,12 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openapi-typescript@7.13.0:
|
||||
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.x
|
||||
|
||||
p-finally@1.0.0:
|
||||
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3426,6 +3488,10 @@ packages:
|
||||
parse-entities@4.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
@@ -3483,6 +3549,10 @@ packages:
|
||||
pkg-types@1.3.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
@@ -3925,6 +3995,10 @@ packages:
|
||||
stylis@4.4.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3992,6 +4066,10 @@ packages:
|
||||
tw-animate-css@1.4.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
@@ -4077,6 +4155,9 @@ packages:
|
||||
untruncate-json@0.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==}
|
||||
|
||||
@@ -4275,6 +4356,9 @@ packages:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yaml-ast-parser@0.0.43:
|
||||
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4464,6 +4548,14 @@ snapshots:
|
||||
package-manager-detector: 1.6.0
|
||||
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': {}
|
||||
|
||||
'@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:
|
||||
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)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -6039,6 +6154,8 @@ snapshots:
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-colors@4.1.3: {}
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
@@ -6047,6 +6164,8 @@ snapshots:
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -6060,6 +6179,8 @@ snapshots:
|
||||
|
||||
bail@2.0.2: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
@@ -6085,7 +6206,7 @@ snapshots:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
@@ -6095,6 +6216,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
@@ -6123,6 +6248,8 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
change-case@5.4.4: {}
|
||||
|
||||
character-entities-html4@2.1.0: {}
|
||||
|
||||
character-entities-legacy@1.1.4: {}
|
||||
@@ -6178,6 +6305,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
colorette@1.4.0: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
@@ -6443,9 +6572,11 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
debug@4.4.3:
|
||||
debug@4.4.3(supports-color@10.2.2):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 10.2.2
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
@@ -6660,7 +6791,7 @@ snapshots:
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
@@ -6724,7 +6855,7 @@ snapshots:
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
@@ -6771,7 +6902,7 @@ snapshots:
|
||||
gaxios@7.1.4:
|
||||
dependencies:
|
||||
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
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -7032,10 +7163,10 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
https-proxy-agent@7.0.6(supports-color@10.2.2):
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7062,6 +7193,8 @@ snapshots:
|
||||
|
||||
immer@11.1.4: {}
|
||||
|
||||
index-to-position@1.2.0: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
inline-style-parser@0.1.1: {}
|
||||
@@ -7118,12 +7251,18 @@ snapshots:
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
json-bigint@1.0.0:
|
||||
dependencies:
|
||||
bignumber.js: 9.3.1
|
||||
@@ -7865,7 +8004,7 @@ snapshots:
|
||||
micromark@3.2.0:
|
||||
dependencies:
|
||||
'@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
|
||||
micromark-core-commonmark: 1.1.0
|
||||
micromark-factory-space: 1.1.0
|
||||
@@ -7887,7 +8026,7 @@ snapshots:
|
||||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@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
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
@@ -7920,6 +8059,10 @@ snapshots:
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
mlly@1.8.2:
|
||||
@@ -7993,6 +8136,16 @@ snapshots:
|
||||
- encoding
|
||||
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-queue@6.6.2:
|
||||
@@ -8036,6 +8189,12 @@ snapshots:
|
||||
is-decimal: 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:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
@@ -8105,6 +8264,8 @@ snapshots:
|
||||
mlly: 1.8.2
|
||||
pathe: 2.0.3
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-path@0.2.1:
|
||||
@@ -8520,7 +8681,7 @@ snapshots:
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
@@ -8574,7 +8735,7 @@ snapshots:
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
@@ -8743,6 +8904,8 @@ snapshots:
|
||||
|
||||
stylis@4.4.0: {}
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -8795,6 +8958,8 @@ snapshots:
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
'@graphql-yoga/subscription': 5.0.5
|
||||
@@ -8910,6 +9075,8 @@ snapshots:
|
||||
|
||||
untruncate-json@0.0.1: {}
|
||||
|
||||
uri-js-replace@1.0.1: {}
|
||||
|
||||
urlpattern-polyfill@10.1.0: {}
|
||||
|
||||
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: {}
|
||||
|
||||
yaml-ast-parser@0.0.43: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
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("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 ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
||||
import SyncStatus from "./pages/SyncStatus";
|
||||
import Planificador from "./pages/Planificador";
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -58,6 +59,7 @@ function AppRoutes() {
|
||||
<Route path="/pensions" element={<Pensions />} />
|
||||
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||
<Route path="/sync" element={<SyncStatus />} />
|
||||
<Route path="/planificador" element={<Planificador />} />
|
||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Sparkles,
|
||||
Telescope,
|
||||
Calculator,
|
||||
BarChart3,
|
||||
Landmark,
|
||||
@@ -55,6 +56,7 @@ const navSections: NavSection[] = [
|
||||
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
||||
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
||||
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
||||
{ to: "/planificador", icon: Telescope, label: "Planificador" },
|
||||
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
|
||||
],
|
||||
},
|
||||
|
||||
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";
|
||||
|
||||
class ApiError extends Error {
|
||||
@@ -125,91 +132,22 @@ export async function logout() {
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
bank: string;
|
||||
currency: string;
|
||||
label: string;
|
||||
balance: number;
|
||||
account_type: string;
|
||||
next_payment: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
export type Account = Schema<'AccountRead'>;
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
auto_match_patterns: string | null;
|
||||
}
|
||||
export type Category = Schema<'CategoryRead'>;
|
||||
|
||||
export interface 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 type SkippedDuplicate = Schema<'SkippedDuplicate'>;
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
duplicates: number;
|
||||
errors: string[];
|
||||
skipped: SkippedDuplicate[];
|
||||
}
|
||||
export type ImportResult = Schema<'PasteImportResult'>;
|
||||
|
||||
export interface Transaction {
|
||||
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;
|
||||
}
|
||||
export type Transaction = Schema<'TransactionRead'>;
|
||||
|
||||
// --- Budget / Recurring Items ---
|
||||
|
||||
export type RecurringItemType = "INCOME" | "EXPENSE";
|
||||
export type RecurringFrequency =
|
||||
| "WEEKLY"
|
||||
| "MONTHLY"
|
||||
| "QUARTERLY"
|
||||
| "BIANNUAL"
|
||||
| "YEARLY";
|
||||
export type RecurringItemType = Schema<"RecurringItemType">;
|
||||
export type RecurringFrequency = Schema<"RecurringFrequency">;
|
||||
|
||||
export interface RecurringItem {
|
||||
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 type RecurringItem = Schema<'RecurringItemRead'>;
|
||||
|
||||
export interface RecurringItemCreate {
|
||||
name: string;
|
||||
@@ -239,76 +177,19 @@ export interface RecurringItemUpdate {
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface 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 type RecurringItemDetail = Schema<'RecurringItemDetail'>;
|
||||
|
||||
export interface ActualsBySource {
|
||||
source: string;
|
||||
total_compra: number;
|
||||
total_devolucion: number;
|
||||
net: number;
|
||||
count: number;
|
||||
}
|
||||
export type ActualsBySource = Schema<'ActualsBySource'>;
|
||||
|
||||
export interface MonthlyProjection {
|
||||
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 type MonthlyProjection = Schema<'MonthlyProjectionResponse'>;
|
||||
|
||||
export interface YearlyProjection {
|
||||
year: number;
|
||||
months: MonthlyProjection[];
|
||||
annual_income: number;
|
||||
annual_expenses: number;
|
||||
annual_net: number;
|
||||
}
|
||||
export type YearlyProjection = Schema<'YearlyProjectionResponse'>;
|
||||
|
||||
export interface MonthlyDetail {
|
||||
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 }[];
|
||||
}
|
||||
export type MonthlyDetail = Schema<'MonthlyDetailResponse'>;
|
||||
|
||||
// --- Savings Accrual ---
|
||||
|
||||
export interface SavingsAccrual {
|
||||
id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
memp_amount: number;
|
||||
mpat_amount: number;
|
||||
trigger_transaction_id: number | null;
|
||||
applied_at: string;
|
||||
notes: string | null;
|
||||
}
|
||||
export type SavingsAccrual = Schema<'SavingsAccrualRead'>;
|
||||
|
||||
export interface SavingsAccrualCreate {
|
||||
year: number;
|
||||
@@ -361,11 +242,7 @@ export const deleteBalanceOverride = (year: number, month: number) =>
|
||||
|
||||
// --- Salarios ---
|
||||
|
||||
export interface SalariosSummary {
|
||||
count: number;
|
||||
total_amount: number;
|
||||
latest_date: string | null;
|
||||
}
|
||||
export type SalariosSummary = Schema<'SalariosSummary'>;
|
||||
|
||||
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
|
||||
api.get<Transaction[]>("/salarios/", { params });
|
||||
@@ -374,32 +251,9 @@ export const getSalariosSummary = () =>
|
||||
|
||||
// --- Pensions ---
|
||||
|
||||
export interface PensionSnapshot {
|
||||
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 type PensionSnapshot = Schema<'PensionSnapshotRead'>;
|
||||
|
||||
export interface PensionUploadResult {
|
||||
imported: number;
|
||||
updated: number;
|
||||
duplicates: number;
|
||||
errors: string[];
|
||||
snapshots: PensionSnapshot[];
|
||||
}
|
||||
export type PensionUploadResult = Schema<'PensionUploadResult'>;
|
||||
|
||||
export interface PensionManualEntry {
|
||||
fund: string;
|
||||
@@ -436,51 +290,19 @@ export interface MunicipalCharge {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface WaterMeterReading {
|
||||
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 type WaterMeterReading = Schema<'WaterMeterReadingRead'>;
|
||||
|
||||
export interface MunicipalReceipt {
|
||||
id: number;
|
||||
receipt_date: string;
|
||||
due_date: string;
|
||||
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 type MunicipalReceipt = Omit<
|
||||
Schema<'MunicipalReceiptRead'>,
|
||||
'raw_charges'
|
||||
> & { raw_charges: MunicipalCharge[] };
|
||||
|
||||
export interface MunicipalReceiptDetail extends MunicipalReceipt {
|
||||
water_readings: WaterMeterReading[];
|
||||
}
|
||||
export type MunicipalReceiptDetail = Omit<
|
||||
Schema<'MunicipalReceiptDetailRead'>,
|
||||
'raw_charges'
|
||||
> & { raw_charges: MunicipalCharge[] };
|
||||
|
||||
export interface MunicipalReceiptUploadResult {
|
||||
imported: number;
|
||||
updated: number;
|
||||
errors: string[];
|
||||
receipt: MunicipalReceipt | null;
|
||||
}
|
||||
export type MunicipalReceiptUploadResult = Schema<'MunicipalReceiptUploadResult'>;
|
||||
|
||||
export const uploadMunicipalReceipt = (file: File) => {
|
||||
const form = new FormData();
|
||||
@@ -502,20 +324,9 @@ export const getWaterConsumption = (months?: number) =>
|
||||
|
||||
// --- Sync Status ---
|
||||
|
||||
export interface SyncSource {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
last_received: string | null;
|
||||
age_days: number | null;
|
||||
warn_after_days: number;
|
||||
status: 'ok' | 'warning' | 'never';
|
||||
}
|
||||
export type SyncSource = Schema<'SyncSourceStatus'>;
|
||||
|
||||
export interface SyncStatusResponse {
|
||||
sources: SyncSource[];
|
||||
warnings: number;
|
||||
}
|
||||
export type SyncStatusResponse = Schema<'SyncStatusResponse'>;
|
||||
|
||||
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 interface UserSettingsRead {
|
||||
key: string;
|
||||
data: UserSettingsData;
|
||||
updated_at: string;
|
||||
}
|
||||
export type UserSettingsRead = Schema<'UserSettingsRead'>;
|
||||
|
||||
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 "./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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from 'recharts';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
import api from '@/lib/api';
|
||||
import api, { getRateHistory } from '@/lib/api';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -63,6 +63,11 @@ const trendChartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const rateChartConfig = {
|
||||
sell_rate: { label: 'Venta', color: 'var(--chart-1)' },
|
||||
buy_rate: { label: 'Compra', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const dailyChartConfig = {
|
||||
total: {
|
||||
label: 'Gasto Diario',
|
||||
@@ -103,6 +108,16 @@ export default function Analytics() {
|
||||
const byCategory = byCategoryQ.data ?? [];
|
||||
const trend = trendQ.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 retryAll = () => {
|
||||
byCategoryQ.refetch();
|
||||
@@ -328,6 +343,67 @@ export default function Analytics() {
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { toast } from 'sonner';
|
||||
|
||||
@@ -9,6 +10,7 @@ import { useBudget } from '@/hooks/useBudget';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import PeriodNavigator from '@/components/PeriodNavigator';
|
||||
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
||||
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
||||
import TransactionList from '@/components/TransactionList';
|
||||
@@ -34,6 +36,10 @@ export default function Budget() {
|
||||
refresh,
|
||||
} = useBudget(currentYear);
|
||||
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 [txSearch, setTxSearch] = useState('');
|
||||
@@ -103,18 +109,27 @@ export default function Budget() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
|
||||
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<PeriodNavigator
|
||||
label={String(year)}
|
||||
onPrev={() => setYear(year - 1)}
|
||||
onNext={() => setYear(year + 1)}
|
||||
prevDisabled={year <= MIN_YEAR}
|
||||
nextDisabled={year >= MAX_YEAR}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
@@ -133,29 +148,15 @@ export default function Budget() {
|
||||
<TabsTrigger value="detail">Detalle</TabsTrigger>
|
||||
<TabsTrigger value="transactions">Transacciones</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
<PeriodNavigator
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={selectedMonth <= 1}
|
||||
onClick={() => setSelectedMonth(selectedMonth - 1)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium w-28 text-center">
|
||||
{MONTH_NAMES[selectedMonth]} {year}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={selectedMonth >= 12}
|
||||
onClick={() => setSelectedMonth(selectedMonth + 1)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
label={`${MONTH_NAMES[selectedMonth]} ${year}`}
|
||||
labelClassName="text-sm font-medium w-28 text-center"
|
||||
onPrev={() => setSelectedMonth(selectedMonth - 1)}
|
||||
onNext={() => setSelectedMonth(selectedMonth + 1)}
|
||||
prevDisabled={selectedMonth <= 1}
|
||||
nextDisabled={selectedMonth >= 12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="detail" className="space-y-6 mt-4">
|
||||
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -40,7 +47,7 @@ import {
|
||||
type PensionUploadResult,
|
||||
} from '@/lib/api';
|
||||
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
|
||||
import { ClipboardPaste, SlidersHorizontal } from 'lucide-react';
|
||||
import { ClipboardPaste, SlidersHorizontal, Target } from 'lucide-react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -317,6 +324,26 @@ export default function Pensions() {
|
||||
);
|
||||
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
|
||||
const snapshotByFund = useMemo(() => {
|
||||
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 className="space-y-3">
|
||||
<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 { useBudget } from '@/hooks/useBudget';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import PeriodNavigator from '@/components/PeriodNavigator';
|
||||
import YearlyOverview from '@/components/budget/YearlyOverview';
|
||||
|
||||
const MIN_YEAR = 2026;
|
||||
@@ -36,15 +36,13 @@ export default function Proyecciones() {
|
||||
<TrendingUp className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
|
||||
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<PeriodNavigator
|
||||
label={String(year)}
|
||||
onPrev={() => setYear(year - 1)}
|
||||
onNext={() => setYear(year + 1)}
|
||||
prevDisabled={year <= MIN_YEAR}
|
||||
nextDisabled={year >= MAX_YEAR}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Annual summary cards */}
|
||||
@@ -101,7 +99,7 @@ export default function Proyecciones() {
|
||||
year={year}
|
||||
onSelectMonth={(m) => {
|
||||
setSelectedMonth(m);
|
||||
navigate('/budget');
|
||||
navigate('/budget', { state: { from: 'proyecciones' } });
|
||||
}}
|
||||
onSaveOverride={async (month, value) => {
|
||||
await saveBalanceOverride(year, month, value);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates';
|
||||
import MeterLabelsDialog from '@/components/MeterLabelsDialog';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import {
|
||||
BarChart,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
type MunicipalReceipt,
|
||||
type MunicipalReceiptUploadResult,
|
||||
type WaterMeterReading,
|
||||
getUserSettings,
|
||||
} from '@/lib/api';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
@@ -195,6 +197,14 @@ export default function ServiciosMunicipales() {
|
||||
const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]);
|
||||
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({
|
||||
queryKey: ['municipal'],
|
||||
queryFn: async () => {
|
||||
@@ -396,10 +406,15 @@ export default function ServiciosMunicipales() {
|
||||
{/* ── Water Consumption Chart ─────────────────────────────────────── */}
|
||||
{chartData.length > 0 && (
|
||||
<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">
|
||||
<Droplets className="w-4 h-4" />
|
||||
Consumo de Agua (m³)
|
||||
</h2>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowLabelEditor(true)}>
|
||||
Etiquetas
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
@@ -435,7 +450,7 @@ export default function ServiciosMunicipales() {
|
||||
opacity: hiddenMeters.has(value) ? 0.4 : 1,
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
Medidor {value}
|
||||
{meterLabel(value)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
@@ -806,6 +821,14 @@ export default function ServiciosMunicipales() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
{showLabelEditor && (
|
||||
<MeterLabelsDialog
|
||||
meterIds={meterIds}
|
||||
current={meterLabels}
|
||||
settingsData={settingsQ.data?.data ?? {}}
|
||||
onClose={() => setShowLabelEditor(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user