Compare commits

..

6 Commits

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

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

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

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

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

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

View File

@@ -18,11 +18,17 @@ jobs:
/tmp/test-venv/bin/pip install --quiet -r requirements.txt -r requirements-dev.txt
/tmp/test-venv/bin/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
View File

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

View File

@@ -68,3 +68,17 @@ Four automated flows on old-vps feed data into WealthySmart:
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
## API Type Generation
Frontend read types are generated from the backend OpenAPI spec. After changing
backend response models:
```bash
cd backend && DATABASE_URL='sqlite://' SECRET_KEY=$(openssl rand -hex 32) \
ADMIN_USERNAME=x ADMIN_PASSWORD=x-x OPENAI_API_KEY=dummy \
.venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
cd ../frontend && pnpm gen:api # regenerates src/lib/api-types.gen.ts
```
CI regenerates and fails the deploy if `api-types.gen.ts` is stale.

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, Request
from 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}

View File

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

View File

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

View File

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

View File

@@ -160,13 +160,22 @@ fallback already guarded `len >= 2`).
| UX-13 cycle filter | **mostly false positive** — daily-spending already accepts cycle params; the trend is multi-cycle by design |
| UX-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)

View File

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

View File

@@ -8,7 +8,8 @@
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
"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
View File

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

View File

@@ -0,0 +1,29 @@
{
"name": "WealthySmart",
"short_name": "WealthySmart",
"description": "Smart personal finance management",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"lang": "es-CR",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -1,4 +1,42 @@
/* WealthySmart service worker: web push + PWA installability.
* (Replaces the old self-unregistering cleanup stub, which also meant
* `navigator.serviceWorker.ready` never resolved and push silently no-oped.) */
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
await self.registration.unregister();
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", (event) => {
let data = { title: "WealthySmart", body: "", url: "/" };
try {
data = { ...data, ...event.data.json() };
} catch {
if (event.data) data.body = event.data.text();
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
data: { url: data.url },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ("focus" in client) {
client.navigate(url);
return client.focus();
}
}
return self.clients.openWindow(url);
}),
);
});

View File

@@ -24,6 +24,7 @@ import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones";
import 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>

View File

@@ -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" },
],
},

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { type UserSettingsData } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface Props {
meterIds: string[];
current: Record<string, string>;
settingsData: UserSettingsData;
onClose: () => void;
}
/** Human names for water meters, persisted in UserSettings (review UX-24). */
export default function MeterLabelsDialog({
meterIds,
current,
settingsData,
onClose,
}: Props) {
const queryClient = useQueryClient();
const [labels, setLabels] = useState<Record<string, string>>(() => ({
...current,
}));
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const cleaned = Object.fromEntries(
Object.entries(labels).filter(([, v]) => v.trim() !== ''),
);
await api.patch('/settings/', {
data: { ...settingsData, meter_labels: cleaned },
});
toast.success('Etiquetas guardadas');
queryClient.invalidateQueries({ queryKey: ['settings'] });
onClose();
} catch {
toast.error('No se pudieron guardar las etiquetas');
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Etiquetas de medidores</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{meterIds.map((id) => (
<div key={id} className="space-y-1">
<Label className="text-xs font-mono">Medidor {id}</Label>
<Input
value={labels[id] ?? ''}
placeholder="p. ej. Casa, Cochera…"
onChange={(e) =>
setLabels((prev) => ({ ...prev, [id]: e.target.value }))
}
/>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface Props {
label: string;
onPrev: () => void;
onNext: () => void;
prevDisabled?: boolean;
nextDisabled?: boolean;
/** 'outline' for page-level (year), 'ghost' for inline (month) */
variant?: 'outline' | 'ghost';
labelClassName?: string;
}
/** The one way to step through periods (UX-03) — same chevron pattern for
* years and months across Budget and Proyecciones. */
export default function PeriodNavigator({
label,
onPrev,
onNext,
prevDisabled,
nextDisabled,
variant = 'outline',
labelClassName = 'w-16 text-center font-semibold tabular-nums',
}: Props) {
return (
<div className="flex items-center gap-1">
<Button
variant={variant}
size="icon"
className={variant === 'ghost' ? 'h-7 w-7' : undefined}
disabled={prevDisabled}
onClick={onPrev}
aria-label={`Período anterior (${label})`}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className={labelClassName}>{label}</span>
<Button
variant={variant}
size="icon"
className={variant === 'ghost' ? 'h-7 w-7' : undefined}
disabled={nextDisabled}
onClick={onNext}
aria-label={`Período siguiente (${label})`}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,10 @@
import type { components } from "./api-types.gen";
/** Schemas generated from the backend OpenAPI spec (pnpm gen:api). Aliasing
* the app's read types to them makes backend schema drift a typecheck error
* instead of a runtime surprise (review ARCH-16 / FE-14 / plan 3.4). */
type Schema<K extends keyof components["schemas"]> = components["schemas"][K];
const BASE_URL = "/api/v1";
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 } });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { ChevronLeft, ChevronRight, Loader2, TrendingUp } from 'lucide-react';
import { Loader2, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { 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);

View File

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