Compare commits

...

51 Commits

Author SHA1 Message Date
Carlos Escalante
20b4ad102d Wrap transaction_type in col() for notin_ filter
All checks were successful
Deploy to VPS / deploy (push) Successful in 12s
SQLModel enum columns need col() to expose SQLAlchemy operators like
notin_. Without it the agent tool raised at query build time and the
chat card flashed away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:58:28 -06:00
Carlos Escalante
ec716e698f Exclude SALARY and DEPOSITO from agent recent-transactions tool
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
The 'last N transactions' answer was including salary deposits, which the
user reads as expense activity. Filter income types out at the query level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:55:53 -06:00
Carlos Escalante
f556c392fb Pass OPENAI_API_KEY and AGENT_MODEL to prod from Gitea secrets
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
Backend was hitting OpenAI with no key (401) because the deploy workflow
never wrote OPENAI_API_KEY into .env.prod. Add it plus AGENT_MODEL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:49:55 -06:00
Carlos Escalante
aa4bb6512f Proxy /api/v1 and /api/auth from Hono to FastAPI in prod
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s
In production the browser talks to the Hono server, which only proxied
/api/copilotkit/*. All other /api/* requests hit the SPA static fallback
and got index.html back. Forward /api/v1/* and /api/auth/* to BACKEND_URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:43:34 -06:00
Carlos Escalante
6b3069eef4 Fix prod backend hostname collision on nginx-prod-network
All checks were successful
Deploy to VPS / deploy (push) Successful in 5s
Frontend joins both wealthysmart-network-prod and nginx-prod-network
(needed for nginx-proxy reverse proxy + TLS). Another container on
nginx-prod-network is named "backend" too (receipts-backend-prod),
so DNS resolved "backend" to a sibling app and the agent endpoint
returned 404. Pin the agent/backend URLs to the unique container
name wealthysmart-backend-prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:31:16 -06:00
Carlos Escalante
ead8fb8684 Fix prod frontend container: tsx on PATH and cap build heap
All checks were successful
Deploy to VPS / deploy (push) Successful in 5s
Runner stage was invoking `tsx server.ts` via sh, which doesn't
have node_modules/.bin on PATH, so the container crash-looped with
"tsx: not found" (502 at the edge). Use the absolute binary path
instead.

Also caps the Vite build's V8 heap to 1.5 GB so a future build on
the 4-core / 8 GB VPS can't OOM-kill neighbouring services.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:21:45 -06:00
Carlos Escalante
097fe9c4cf Point sync-db at old-vps and add A2UI theming notes
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m17s
The production SSH alias is old-vps; the placeholder "production"
alias does not exist. Also captures research findings on theming
the A2UI basic catalog without overriding component internals,
for later reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:58 -06:00
Carlos Escalante
c92bfc66fe Update pages and components for new module paths
Repoints imports at the relocated lib/api and src/contexts modules,
and refreshes Layout + Login alongside the rest of the migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:46 -06:00
Carlos Escalante
cf8b7be778 Fix Tabs orientation selectors
Tailwind variants like data-horizontal and group-data-horizontal
never match the data-orientation=horizontal attribute Base UI
emits, so the flex layout collapsed and TabsList stretched
vertically. Switch to data-[orientation=...] selectors that
actually fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:33 -06:00
Carlos Escalante
8b3a19b552 Add Skeleton primitive and budget detail loading state
Replaces the blank flash on the budget detail tab with skeleton
placeholders that mirror the final card layout, so the page no
longer shifts when the API returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:22 -06:00
Carlos Escalante
5d5727ec4e Add Asistente chat page with A2UI render tools
Wires CopilotKit v2 chat into the SPA as the Asistente page,
declares a render_spending_summary action backed by a custom
SpendingSummaryCard, and configures static suggestions shown
before the first message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:12 -06:00
Carlos Escalante
140a75f706 Add cookie-based SPA auth and update container plumbing
Backend now exposes /api/auth/login + /api/auth/logout setting an
httpOnly ws_token cookie, and get_current_user accepts either the
cookie (SPA) or a Bearer token (n8n/CLI). AuthContext probes the
cookie via /api/v1/auth/me. Dockerfiles and compose files updated
for the new agent service deps and CopilotKit dev sidecar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:02 -06:00
Carlos Escalante
7f602a67af Add Microsoft Agent Framework assistant with read-only tools
Wires up an OpenAI-backed MAF agent that exposes WealthySmart
data through tool calls (recent transactions, cycle summary,
analytics, pensions). Pulls in agent-framework + AG-UI adapter
+ OpenAI client deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:01:50 -06:00
Carlos Escalante
5f2a4105f3 Migrate frontend to Vite + Hono CopilotKit runtime
Replaces the Next.js scaffold with a Vite SPA paired with a Hono
sidecar that hosts the CopilotKit runtime and proxies AG-UI traffic
to the MAF backend. Adds dev/prod Dockerfile, .dockerignore,
.gitignore, pnpm workspace config, and updates entrypoints
(main.tsx / App.tsx / index.css / index.html) plus the service
worker accordingly.

Server middleware reconciles MAF MESSAGES_SNAPSHOT id mismatches
so post-tool-call assistant text doesn't render twice, suppresses
duplicate text emitted alongside render tools, and strips OpenAI
training-token leaks from streamed deltas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:01:40 -06:00
Carlos Escalante
c4768e6912 Drop legacy pages, contexts, and dashboard widgets
Removes Dashboard / Transactions / Transfers pages, the section
configuration UI, the legacy useSettings hook, and the standalone
PrivacyContext/ThemeContext modules. Privacy/theme contexts now live
under src/contexts/ and the API helper / push-notifications module
move under src/lib/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:01:26 -06:00
Carlos Escalante
9fe17c0607 Drop legacy Next.js + CRA scaffold assets
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:01:06 -06:00
Carlos Escalante
98d32df763 Ignore tech_docs and local Claude state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:57:58 -06:00
Carlos Escalante
d4d0f65759 Exclude income transactions from budget transactions list
All checks were successful
Deploy to VPS / deploy (push) Successful in 16s
Salary/deposit transactions were showing in the "Efectivo y
Transferencias" tab on the Budget page with a negative sign,
which is confusing since that view is for expenses only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:27:50 -06:00
Carlos Escalante
d929ed6573 Remove Ahorro from budget UI, add SALARY type and savings auto-accrual
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
Ahorro was already deducted from gross salary so displaying it in
budget projections was misleading. This removes the Ahorro card,
summary line, Proyecciones column, and Ahorro Anual card from the UI,
and strips all savings fields from budget API responses.

Adds SALARY TransactionType so salary deposits can be distinguished
from generic DEPOSITO transfers. When a SALARY transaction arrives,
the system auto-increments MEMP and MPAT savings account balances
(+200K CRC each) once per month via an idempotent accrual log.

New CRUD endpoints at /api/v1/savings-accrual/ allow manual correction
of the accrual history. Feb+Mar 2026 are seeded as historical baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:13:29 -06:00
Carlos Escalante
94a8a894a6 Convert all currencies to CRC and poll rates every 6h
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Budget/transactions/salarios totals summed Transaction.amount directly,
so USD/EUR entries were treated as CRC and effectively disappeared from
the dashboard (the analytics fix in 9a80f2a only covered analytics).
Adds a shared get_converted_amount_expr() helper driven by the full
Currency enum — USD/EUR via ExchangeRate-API, BTC/XMR via CoinGecko —
and wires it into every func.sum(Transaction.amount) site.

Also starts a background task in the FastAPI lifespan that force-refreshes
every currency 4x/day, persisting USD to the DB and updating in-memory
caches for the rest. Failures are swallowed per-currency so a CoinGecko
outage cannot take out USD/EUR, and the last-known rate is always retained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:16:20 -06:00
Carlos Escalante
9a80f2a997 Convert USD and EUR to CRC in analytics endpoints
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
All three analytics endpoints (by-category, monthly-trend, daily-spending)
now convert foreign currency amounts to CRC using current exchange rates.
EUR/CRC rate derived from ExchangeRate-API (USD-based cross rate).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:41:38 -06:00
Carlos Escalante
efe6d88286 Add EUR currency support for international transactions
All checks were successful
Deploy to VPS / deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:29:37 -06:00
Carlos Escalante
4da00750a8 Fix migration to use IF NOT EXISTS and Postgres-compatible DEFAULT
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:14:20 -06:00
Carlos Escalante
792cef5006 Fix analytics case() bug, add privacy mode, add prod DB sync script
All checks were successful
Deploy to VPS / deploy (push) Successful in 28s
Fix SQLAlchemy case() import in monthly-trend endpoint. Add
data-sensitive attributes to Analytics charts and tables for privacy
blur. Add scripts/sync-db.sh for one-click prod-to-local PostgreSQL
sync. Remove SQLite artifacts from gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:58 -06:00
Carlos Escalante
78e20f30cb Replace axios with native fetch API wrapper
Drop axios dependency in favor of a lightweight fetch-based client
that preserves the same { data: T } interface, keeping all 25
consumer files unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:48 -06:00
Carlos Escalante
51c106dc6c Add Proyecciones page with yearly financial projections view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:37 -06:00
Carlos Escalante
0fdb5447b7 Add deferred transactions, revamp budget projections and UI
Adds deferred_to_next_cycle flag to transactions for billing cycle
bleed-over handling. Overhauls budget projection engine and refreshes
Budget page with improved monthly detail and transaction columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:23 -06:00
Carlos Escalante
37e04273b9 Add clickable legend toggles and charge trend chart
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
- Both water consumption and charge trend charts now have clickable legends
  to show/hide individual series
- Hidden series appear dimmed in the legend
- Added line chart showing charge evolution over time (one line per charge type)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:38:30 -06:00
Carlos Escalante
c005956458 Support multi-file upload for municipal receipts
All checks were successful
Deploy to VPS / deploy (push) Successful in 15s
Upload panel now accepts multiple PDFs at once (drag-drop or file picker),
shows a file queue with individual remove buttons, and displays per-file
results after processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:23:14 -06:00
Carlos Escalante
8f775e5531 Add python-dateutil to requirements for production
All checks were successful
Deploy to VPS / deploy (push) Successful in 33s
The municipal receipts endpoint uses dateutil.relativedelta to derive
the billing period (previous month from receipt date).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:16:33 -06:00
Carlos Escalante
739a32efd4 Add municipal receipt module and convert navbar to sidebar
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s
- New module: Municipalidad de Belén receipt extraction via pdftotext+regex
  - Backend: MunicipalReceipt + WaterMeterReading models, upload/list/detail/water-consumption endpoints
  - Auto-creates budget Transaction on upload (duplicate-safe via reference)
  - Frontend: ServiciosMunicipales page with summary cards, water consumption bar chart, receipt history, PDF upload
- Convert top navbar to left sidebar with section headers (General, Finanzas, Servicios)
  - Desktop: fixed 220px sidebar, mobile: sheet overlay
  - Grouped nav: Dashboard | Presupuesto, Salarios, Pensiones, Analytics | Municipalidad

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:11:51 -06:00
Carlos Escalante
45166f9d20 Add privacy mode toggle to blur sensitive financial amounts
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Eye/EyeOff icon next to theme toggle. Persists in localStorage.
Applies CSS blur to all elements marked with data-sensitive attribute
across Dashboard, Budget, Pensions, Salarios, and Transactions pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:43:50 -06:00
Carlos Escalante
aedf3aa3b0 Fix plus sign showing before negative rendimientos values
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:24:58 -06:00
Carlos Escalante
cab4d86b5c Restore MEMP/MPAT in Bank enum for DB compatibility
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
Existing account rows reference these values. Enum must stay compatible
with data in production. Only the frontend and seed data were cleaned up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:21:28 -06:00
Carlos Escalante
22334c2129 Remove MPAT and MEMP fund references from pension module
All checks were successful
Deploy to VPS / deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:18:36 -06:00
Carlos Escalante
0923337fff Fix pension paste parser for split-line format from bank website
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
The bank website puts field labels and amounts on separate lines.
Parser now handles both inline and split formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:09:51 -06:00
Carlos Escalante
898b540b3f Fix roiEarned crash when chart data has fewer than 12 entries
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:06:08 -06:00
Carlos Escalante
3c9656f416 Add manual pension data entry and fix chart to use real historical data
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
- Add paste-and-preview modal for entering pension fund balances from bank website
- Backend upsert logic so n8n PDF uploads overwrite manual entries
- Chart now shows actual snapshot data with dynamic month labels
- New POST /pensions/manual endpoint for JSON-based fund entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:03:29 -06:00
Carlos Escalante
e011a3adcc Fix date picker defaulting to UTC instead of browser local time
All checks were successful
Deploy to VPS / deploy (push) Successful in 17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:02:08 -06:00
Carlos Escalante
b68129a171 Add cumulative balance tracking with editable overrides
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
- New BalanceOverride table for manual balance adjustments per month
- Cumulative balance computation with cross-year carryover
- Three new columns: Acum. Anterior, Neto Mes, Balance Acum.
- Inline editing on Balance Acum. cell (pencil icon for overrides)
- Year navigation clamped to 2026–2030, fresh start at March 2026
- PUT/DELETE /budget/balance-override/{year}/{month} endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:03:43 -06:00
Carlos Escalante
99d0c4ebd7 Split budget Resumen into Detalle, Transacciones, and Proyecciones sub-tabs
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Reduces scrolling by organizing the budget overview into three inner tabs.
Clicking a month in the yearly table auto-switches to the Detalle tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:14:43 -06:00
Carlos Escalante
26a26b8ca2 Fix category dropdown showing ID and sort alphabetically
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
SelectValue now renders the category name instead of the raw ID.
Categories are sorted alphabetically when fetched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:56:03 -06:00
Carlos Escalante
fe8d0144eb Add Electronics category and fix DEPOSITO shown as expense
All checks were successful
Deploy to VPS / deploy (push) Successful in 24s
DEPOSITO transactions (salaries) were displaying with negative sign and
red styling. Flipped logic so only COMPRA is negative; DEPOSITO and
DEVOLUCION both show as positive income.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:49:22 -06:00
Carlos Escalante
eccfd53e0b Add pension PDF upload, parsing, and fund summary API
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s
Backend: parse BAC pension statement PDFs (VOL, ROP, FCL) via
pdftotext, store snapshots with duplicate detection, reject
credit card statements. Endpoints: POST /upload, GET /snapshots,
GET /fund-summary.

Frontend: wire up drag-and-drop upload, load real balances and
rendimientos from API, show upload results with error/duplicate
feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:24:42 -06:00
Carlos Escalante
1b90f0c70a Add Pensions page with fund overview, growth chart, and projections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:13:52 -06:00
Carlos Escalante
bd1346f9da Exclude DEPOSITO transactions from budget projections
All checks were successful
Deploy to VPS / deploy (push) Successful in 12s
Salary deposits were being counted as expenses in uncovered actuals,
causing negative balances. DEPOSITO transactions are income tracked
separately in the Salarios page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:29:45 -06:00
Carlos Escalante
9cfa1c4eb1 Add DEPOSITO transaction type and Salarios page
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
New TransactionType.DEPOSITO for salary deposits from n8n/Gmail flow.
New /salarios endpoint with summary. New top-level Salarios page with
DataTable and summary cards. Push notifications link to /salarios for
deposits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:57:41 -06:00
Carlos Escalante
8d76059ae8 Add budget module and push notifications for transactions
All checks were successful
Deploy to VPS / deploy (push) Successful in 34s
Budget: recurring items CRUD, yearly/monthly projections with no-double-count
logic, and full UI (overview, monthly detail, recurring items manager).

Push notifications: Web Push via VAPID keys, triggered on transaction creation
from n8n. Includes service worker handlers, frontend subscription flow, and
a test button on the Dashboard (temporary).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:28:14 -06:00
Carlos Escalante
2cd0d3b2e1 Migrate all components and pages to shadcn/ui with DataTable
All checks were successful
Deploy to VPS / deploy (push) Successful in 28s
Replace custom markup across all pages and components with shadcn/ui
primitives (Dialog, Sheet, Select, Card, Tabs, etc.). Add reusable
DataTable component powered by @tanstack/react-table with sortable
column headers and client-side pagination. Introduce TransactionList
with responsive mobile cards and desktop DataTable, dashboard section
customization (DashboardSection, SectionConfigDialog), and settings
API types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:45:44 -06:00
Carlos Escalante
46f2d8679c Add shadcn/ui design system, theme, and base components
Install shadcn/ui with base-nova style, tailwind-merge, tw-animate-css,
and font packages. Add oklch-based light/dark theme in index.css and
17 base UI components (button, card, dialog, table, tabs, etc.) plus
shared lib utilities (format, colors, cn) and useSettings hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:45:33 -06:00
Carlos Escalante
4d468036c6 Add user settings endpoint and exchange rate fallback APIs
Backend now stores user settings (dashboard config) in a JSONB column and
exposes CRUD via /settings/. Exchange rate service gains multiple fallback
providers (ExchangeRate-API, currency-api, FloatRates) so USD/CRC rates
stay available when BCCR is down.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:45:20 -06:00
113 changed files with 21152 additions and 2325 deletions

View File

@@ -21,6 +21,10 @@ jobs:
ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }}
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
AGENT_MODEL=${{ secrets.AGENT_MODEL }}
ENVEOF
sed -i 's/^[[:space:]]*//' .env.prod

8
.gitignore vendored
View File

@@ -2,7 +2,15 @@ node_modules/
dist/
__pycache__/
*.pyc
*.db
*.db.bak
.env
.env.*
!.env.example
docs/legacy_budget_analysis.md
# Reference clones of framework repos (read-only, not tracked)
tech_docs/
# Claude Code local state
.claude/

View File

@@ -13,9 +13,33 @@ Personal finance management web app.
cd frontend && pnpm install && pnpm run dev
```
## Local Docker
```bash
# Backend + DB containers
docker exec wealthysmart-db-dev psql -U wealthy_user -d wealthysmart -c 'SQL;'
```
## Deployment
- Deployed via Gitea Actions (self-hosted runner on VPS)
- Push to `main` triggers: GitHub → webhook → Gitea mirror sync → Actions workflow → Docker build & deploy
- Domain: wealth.cescalante.dev
- Reverse proxy: nginx-proxy + acme-companion (auto TLS)
## Infrastructure
- **Single server**: `ssh old-vps` — runs everything (WealthySmart, n8n, Forgejo, Vaultwarden, nginx-proxy)
- `ssh production` is **NOT valid** — do not use
- n8n UI: https://n8n.cescalante.dev — n8n DB queryable via `docker exec portfolio-db-prod psql -U portfolio_user -d n8n`
## n8n Flows
Four automated flows on old-vps feed data into WealthySmart:
1. **BAC Credit Card** — Gmail trigger → POST /transactions/
2. **Salary Deposits** — Gmail trigger → POST /transactions/
3. **Municipal Receipts** — Cron trigger → POST /municipal-receipts/upload
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`

View File

@@ -1,6 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --pre -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,5 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

View File

View File

@@ -0,0 +1,74 @@
"""Microsoft Agent Framework agent wired with OpenAI + WealthySmart tools."""
from __future__ import annotations
from datetime import date
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from app.config import settings
from app.agent.tools import TOOLS
SYSTEM_PROMPT = """You are the WealthySmart assistant, an AI analyst for a
personal-finance app owned by a single user (Carlos).
Context you can rely on:
- The user's primary currency is Costa Rican colones (CRC, ₡). USD and EUR
balances and transactions are always converted to CRC using the latest
exchange rate before being summed.
- Credit-card billing cycles run from the 18th of a month to the 18th of the
following month. When the user says "this month" or "last month" without
qualifiers, assume they mean the calendar month unless they mention
"cycle", "corte", or their credit card.
- Today's date is {today}. Use it when the user says "this month", "last
month", "last year", etc.
- Amounts are stored as raw numbers in their native currency (see `currency`
field on transactions/accounts). Tools that return `total_crc` are already
converted; tools that return per-transaction amounts are NOT.
How to answer:
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
- Call multiple tools in parallel when the question spans domains
(e.g. net worth + recent transactions).
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
USD (two decimals), € for EUR (two decimals).
- When showing lists, prefer markdown tables over prose.
- If a tool returns no data, say so explicitly — do not fill in zeros.
- You are read-only in this version. If asked to create, edit or delete
anything, explain that write actions aren't available yet and offer to
summarize or export the change instead.
Language: match the user. The app is bilingual (Spanish/English); respond in
whichever language they used.
Generative UI — render tools:
- When showing spending totals, cycle summaries, or category breakdowns →
call render_spending_summary. Source data: get_cycle_summary (by_source,
grand_total_crc) + get_analytics_by_category (by_category).
- When showing transaction lists or other structured data →
call render_a2ui in a SEPARATE tool-call step, only after all data-fetching
calls have returned. NEVER call render_a2ui in the same batch as any other
tool.
- Do NOT use markdown tables for data a render tool can display.
- CRITICAL RULE: When you call a render tool (render_spending_summary or
render_a2ui), that tool call MUST be the ONLY content in your message.
Do NOT include any text content alongside the tool call — no introduction,
no list, no explanation, nothing. The rendered card IS the complete
response. Any text you write in the same message as a render call will
appear as a duplicate below the card, which is wrong.
"""
def build_agent() -> Agent:
client = OpenAIChatCompletionClient(
api_key=settings.OPENAI_API_KEY,
model=settings.AGENT_MODEL,
)
return Agent(
name="wealthysmart",
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()),
client=client,
tools=TOOLS,
)

467
backend/app/agent/tools.py Normal file
View File

@@ -0,0 +1,467 @@
"""
Read-only tools exposed to the MAF ChatAgent. Each tool is a thin wrapper
around existing SQLModel queries / service helpers — they do NOT duplicate
business logic. The active DB session is resolved via a ContextVar so tool
signatures stay clean for the LLM.
"""
from __future__ import annotations
import contextvars
from datetime import datetime
from typing import Annotated, Optional
from pydantic import Field
from sqlalchemy import case
from sqlmodel import Session, col, func, select
from app.models.models import (
Account,
BalanceOverride,
Category,
MunicipalReceipt,
PensionSnapshot,
RecurringItem,
Transaction,
TransactionSource,
TransactionType,
WaterMeterReading,
)
from app.services.budget_projection import (
compute_monthly_projection,
compute_yearly_projection_with_cumulative,
get_cycle_range,
)
from app.services.exchange_rate import (
get_converted_amount_expr,
get_current_rate,
)
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
def set_session(session: Session) -> contextvars.Token:
return _session_ctx.set(session)
def reset_session(token: contextvars.Token) -> None:
_session_ctx.reset(token)
def _s() -> Session:
return _session_ctx.get()
# ─── Tools ──────────────────────────────────────────────────────────────────
def get_accounts() -> list[dict]:
"""List every account with current balance, currency, bank and type
(BANK, PENSION, CRYPTO, SAVINGS, LIABILITY). Use this for net-worth and
balance questions."""
rows = _s().exec(select(Account).order_by(Account.account_type, Account.label)).all()
return [
{
"id": a.id,
"bank": a.bank.value,
"label": a.label,
"currency": a.currency.value,
"balance": a.balance,
"account_type": a.account_type.value,
"next_payment": a.next_payment,
}
for a in rows
]
def get_net_worth() -> dict:
"""Return total assets, liabilities and net worth in CRC (primary currency).
USD/EUR balances are converted at the latest exchange rate."""
accounts = _s().exec(select(Account)).all()
rate = get_current_rate(_s())
sell = rate.sell_rate if rate else 600.0
assets_crc = 0.0
liabilities_crc = 0.0
for a in accounts:
amt = a.balance
if a.currency.value == "USD":
amt = a.balance * sell
elif a.currency.value == "EUR":
amt = a.balance * sell * 1.08 # rough; real conversion is endpoint-side
if a.account_type.value == "LIABILITY":
liabilities_crc += amt
else:
assets_crc += amt
return {
"assets_crc": round(assets_crc, 2),
"liabilities_crc": round(liabilities_crc, 2),
"net_crc": round(assets_crc - liabilities_crc, 2),
}
def get_recent_transactions(
limit: Annotated[int, Field(ge=1, le=100, description="How many rows to return")] = 20,
source: Annotated[
Optional[str],
Field(description="Filter by source: CREDIT_CARD, CASH, or TRANSFER"),
] = None,
category_id: Annotated[Optional[int], Field(description="Filter by category id")] = None,
search: Annotated[
Optional[str], Field(description="Substring match against merchant name")
] = None,
start_date: Annotated[
Optional[str], Field(description="ISO date lower bound, inclusive")
] = None,
end_date: Annotated[
Optional[str], Field(description="ISO date upper bound, exclusive")
] = None,
) -> list[dict]:
"""Recent transactions, newest first. Use filters to narrow down. For
billing-cycle scoped totals prefer get_cycle_summary."""
q = select(Transaction).where(
col(Transaction.transaction_type).notin_(
[TransactionType.SALARY, TransactionType.DEPOSITO]
)
)
if source:
q = q.where(Transaction.source == TransactionSource(source))
if category_id is not None:
q = q.where(Transaction.category_id == category_id)
if search:
q = q.where(col(Transaction.merchant).ilike(f"%{search}%"))
if start_date:
q = q.where(Transaction.date >= datetime.fromisoformat(start_date))
if end_date:
q = q.where(Transaction.date < datetime.fromisoformat(end_date))
q = q.order_by(col(Transaction.date).desc()).limit(limit)
return [
{
"id": t.id,
"date": t.date.isoformat(),
"merchant": t.merchant,
"amount": t.amount,
"currency": t.currency.value,
"source": t.source.value,
"transaction_type": t.transaction_type.value,
"bank": t.bank.value,
"category_id": t.category_id,
}
for t in _s().exec(q).all()
]
def get_cycle_summary(
cycle_year: Annotated[int, Field(description="Billing cycle year, e.g. 2026")],
cycle_month: Annotated[
int,
Field(ge=1, le=12, description="Billing cycle month (cycle runs 18th→18th)"),
],
) -> dict:
"""Totals for a credit-card billing cycle (18th of month → 18th of next).
Returns spend by source, count, and spend by category."""
session = _s()
amount_crc = get_converted_amount_expr(session)
start, end = get_cycle_range(cycle_year, cycle_month)
totals = session.exec(
select(
Transaction.source,
func.count(),
func.coalesce(func.sum(amount_crc), 0),
)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
)
.group_by(Transaction.source)
).all()
by_category = session.exec(
select(
Category.name,
func.coalesce(func.sum(amount_crc), 0),
func.count(),
)
.join(Category, Category.id == Transaction.category_id, isouter=True)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
)
.group_by(Category.name)
.order_by(func.sum(amount_crc).desc())
).all()
return {
"cycle_year": cycle_year,
"cycle_month": cycle_month,
"range": [start.isoformat(), end.isoformat()],
"by_source": [
{"source": s.value, "count": c, "total_crc": float(t)}
for s, c, t in totals
],
"by_category": [
{"category": n or "Uncategorized", "total_crc": float(t), "count": c}
for n, t, c in by_category
],
}
def get_budget_projection(
year: Annotated[int, Field(description="Year to project")],
month: Annotated[
Optional[int],
Field(ge=1, le=12, description="If given, return only that month's detail"),
] = None,
) -> dict:
"""Budget projection. If month is omitted, returns the yearly rollup; if
given, returns the monthly detail with income items, expense items and
actuals by source."""
session = _s()
if month is None:
months_data = compute_yearly_projection_with_cumulative(session, year)
return {
"year": year,
"months": months_data,
"annual_income": sum(m["projected_income"] for m in months_data),
"annual_expenses": sum(m["gran_total_egresos"] for m in months_data),
"annual_net": sum(m["net_balance"] for m in months_data),
}
return compute_monthly_projection(session, year, month)
def list_recurring_items() -> list[dict]:
"""All recurring items (income and expense, SAVINGS excluded) used by the
budget projection. Useful to explain what's driving a month's projection."""
rows = _s().exec(
select(RecurringItem)
.where(RecurringItem.is_active == True) # noqa: E712
.order_by(RecurringItem.item_type, RecurringItem.name)
).all()
return [
{
"id": r.id,
"name": r.name,
"amount": r.amount,
"currency": r.currency.value,
"item_type": r.item_type.value,
"frequency": r.frequency.value,
"day_of_month": r.day_of_month,
"category_id": r.category_id,
}
for r in rows
]
def get_pension_snapshots(
fund: Annotated[
Optional[str],
Field(description="Filter by fund bank code (FCL, ROP, VOL, etc.)"),
] = None,
latest_only: Annotated[
bool,
Field(description="If true, return only the latest snapshot per fund"),
] = True,
) -> list[dict]:
"""Pension fund snapshots. Each snapshot covers a period with balances,
contributions, returns, fees and the ending balance (saldo_final)."""
q = select(PensionSnapshot).order_by(col(PensionSnapshot.period_end).desc())
if fund:
q = q.where(PensionSnapshot.fund == fund)
rows = _s().exec(q).all()
if latest_only:
seen: dict[str, PensionSnapshot] = {}
for r in rows:
if r.fund.value not in seen:
seen[r.fund.value] = r
rows = list(seen.values())
return [
{
"fund": r.fund.value,
"period_start": r.period_start.isoformat(),
"period_end": r.period_end.isoformat(),
"saldo_anterior": r.saldo_anterior,
"aportes": r.aportes,
"rendimientos": r.rendimientos,
"retiros": r.retiros,
"comision": r.comision,
"saldo_final": r.saldo_final,
}
for r in rows
]
def get_salary_summary() -> dict:
"""Summary of salary deposits (count, total in CRC, latest date)."""
session = _s()
amount_crc = get_converted_amount_expr(session)
row = session.exec(
select(
func.count(),
func.coalesce(func.sum(amount_crc), 0),
func.max(Transaction.date),
).where(Transaction.transaction_type == TransactionType.SALARY)
).first()
count = row[0] if row else 0
total = float(row[1]) if row else 0.0
latest = row[2].isoformat() if row and row[2] else None
return {"count": count, "total_crc": total, "latest_date": latest}
def get_municipal_receipts(
limit: Annotated[int, Field(ge=1, le=50)] = 12,
account: Annotated[
Optional[str], Field(description="Municipal account/contract id")
] = None,
) -> list[dict]:
"""Recent municipal receipts (water + related services) with totals and
water consumption in m³."""
q = select(MunicipalReceipt).order_by(col(MunicipalReceipt.receipt_date).desc())
if account:
q = q.where(MunicipalReceipt.account == account)
q = q.limit(limit)
rows = _s().exec(q).all()
out: list[dict] = []
for r in rows:
readings = _s().exec(
select(WaterMeterReading).where(WaterMeterReading.receipt_id == r.id)
).all()
out.append(
{
"id": r.id,
"receipt_date": r.receipt_date.isoformat(),
"period": r.period,
"account": r.account,
"finca": r.finca,
"subtotal": r.subtotal,
"interests": r.interests,
"iva": r.iva,
"total": r.total,
"water_consumption_m3": sum(w.consumption_m3 for w in readings),
}
)
return out
def get_analytics_by_category(
cycle_year: Annotated[Optional[int], Field(description="Scope to a billing cycle")] = None,
cycle_month: Annotated[Optional[int], Field(ge=1, le=12)] = None,
) -> list[dict]:
"""Spending breakdown by category in CRC (optionally scoped to a billing
cycle). Percentages sum to 100."""
session = _s()
amount_crc = get_converted_amount_expr(session)
q = (
select(
Transaction.category_id,
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == TransactionType.COMPRA)
.group_by(Transaction.category_id)
)
if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
q = q.where(Transaction.date >= start, Transaction.date < end)
rows = session.exec(q).all()
grand = sum(float(r[1]) for r in rows) or 1.0
out = []
for cat_id, total, count in rows:
name = "Uncategorized"
if cat_id:
cat = session.get(Category, cat_id)
if cat:
name = cat.name
out.append(
{
"category_id": cat_id,
"category": name,
"total_crc": float(total),
"count": count,
"percentage": round(float(total) / grand * 100, 1),
}
)
out.sort(key=lambda x: x["total_crc"], reverse=True)
return out
def get_monthly_trend(
months: Annotated[int, Field(ge=1, le=24, description="How many months back")] = 6,
) -> list[dict]:
"""Spending trend by billing cycle for the last N months."""
session = _s()
amount_crc = get_converted_amount_expr(session)
now = datetime.now()
results: list[dict] = []
y, m = now.year, now.month
for _ in range(months):
start, end = get_cycle_range(y, m)
row = session.exec(
select(
func.count(),
func.coalesce(func.sum(amount_crc), 0),
func.coalesce(
func.sum(
case((Transaction.currency == "USD", Transaction.amount), else_=0)
),
0,
),
).where(
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
)
).first()
results.append(
{
"year": y,
"month": m,
"total_crc": float(row[1]) if row else 0.0,
"total_usd_raw": float(row[2]) if row else 0.0,
"count": row[0] if row else 0,
}
)
if m == 1:
y, m = y - 1, 12
else:
m -= 1
return list(reversed(results))
def get_exchange_rate() -> dict:
"""Latest USD/CRC exchange rate (buy and sell). All multi-currency data
in the app is normalized to CRC using these rates."""
rate = get_current_rate(_s())
if not rate:
return {"buy_rate": None, "sell_rate": None, "date": None}
return {
"buy_rate": rate.buy_rate,
"sell_rate": rate.sell_rate,
"date": rate.date.isoformat(),
}
def list_categories() -> list[dict]:
"""All transaction categories (id, name, icon). Use when the user asks
about a category and you need the id to filter by."""
rows = _s().exec(select(Category).order_by(Category.name)).all()
return [{"id": c.id, "name": c.name, "icon": c.icon} for c in rows]
# Registered with the agent in agent.py
TOOLS = [
get_accounts,
get_net_worth,
get_recent_transactions,
get_cycle_summary,
get_budget_projection,
list_recurring_items,
get_pension_snapshots,
get_salary_summary,
get_municipal_receipts,
get_analytics_by_category,
get_monthly_trend,
get_exchange_rate,
list_categories,
]

View File

@@ -3,12 +3,14 @@ from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import case
from sqlmodel import Session, func, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Category, Transaction
from app.api.v1.endpoints.transactions import get_cycle_range
from app.services.budget_projection import get_cycle_range
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -43,10 +45,12 @@ def spending_by_category(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
amount_crc = get_converted_amount_expr(session)
query = (
select(
Transaction.category_id,
func.sum(Transaction.amount).label("total"),
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == "COMPRA")
@@ -87,7 +91,12 @@ def monthly_trend(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Monthly spending totals using billing cycle boundaries (18th-18th)."""
"""Monthly spending totals using billing cycle boundaries (18th-18th).
total_crc includes all currencies converted to CRC at current rates.
total_usd is the raw USD amount (unconverted) for display purposes.
"""
amount_crc = get_converted_amount_expr(session)
now = datetime.now()
results = []
month_names = [
@@ -102,18 +111,10 @@ def monthly_trend(
row = session.exec(
select(
func.count(),
func.coalesce(func.sum(amount_crc), 0),
func.coalesce(
func.sum(
func.case(
(Transaction.currency == "CRC", Transaction.amount),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
func.case(
case(
(Transaction.currency == "USD", Transaction.amount),
else_=0,
)
@@ -162,10 +163,12 @@ def daily_spending(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
amount_crc = get_converted_amount_expr(session)
query = (
select(
func.date(Transaction.date).label("day"),
func.sum(Transaction.amount).label("total"),
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == "COMPRA")

View File

@@ -1,8 +1,7 @@
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from fastapi import Depends
from app.auth import create_access_token
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
from app.config import settings
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
)
token = create_access_token(form_data.username)
return {"access_token": token, "token_type": "bearer"}
@router.get("/me")
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
return {"username": username}

View File

@@ -0,0 +1,292 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
BalanceOverride,
BalanceOverrideCreate,
BalanceOverrideRead,
RecurringItem,
RecurringItemCreate,
RecurringItemRead,
RecurringItemType,
RecurringItemUpdate,
)
from app.services.budget_projection import (
FRESH_START_MONTH,
FRESH_START_YEAR,
MAX_YEAR,
MIN_YEAR,
compute_monthly_projection,
compute_yearly_projection_with_cumulative,
)
router = APIRouter(prefix="/budget", tags=["budget"])
# --- Recurring Item CRUD ---
@router.get("/recurring", response_model=list[RecurringItemRead])
def list_recurring_items(
item_type: Optional[RecurringItemType] = None,
is_active: Optional[bool] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
query = select(RecurringItem).where(
RecurringItem.item_type != RecurringItemType.SAVINGS
)
if item_type:
query = query.where(RecurringItem.item_type == item_type)
if is_active is not None:
query = query.where(RecurringItem.is_active == is_active)
query = query.order_by(RecurringItem.item_type, RecurringItem.name)
return session.exec(query).all()
@router.post("/recurring", response_model=RecurringItemRead, status_code=201)
def create_recurring_item(
data: RecurringItemCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
item = RecurringItem.model_validate(data)
session.add(item)
session.commit()
session.refresh(item)
return item
@router.patch("/recurring/{item_id}", response_model=RecurringItemRead)
def update_recurring_item(
item_id: int,
data: RecurringItemUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
item = session.get(RecurringItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Recurring item not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(item, key, value)
session.add(item)
session.commit()
session.refresh(item)
return item
@router.delete("/recurring/{item_id}", status_code=204)
def delete_recurring_item(
item_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
item = session.get(RecurringItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Recurring item not found")
session.delete(item)
session.commit()
# --- Projection Endpoints ---
class MonthlyProjectionResponse(BaseModel):
month: int
year: int
projected_income: float
projected_fixed_expenses: float
actual_credit_card: float
actual_cash: float
actual_transfers: float
uncovered_actual: float
gran_total_egresos: float
net_balance: float
carryover_balance: float = 0.0
cumulative_balance: float = 0.0
balance_overridden: bool = False
class YearlyProjectionResponse(BaseModel):
year: int
months: list[MonthlyProjectionResponse]
annual_income: float
annual_expenses: float
annual_net: float
@router.get("/projection/{year}", response_model=YearlyProjectionResponse)
def get_yearly_projection(
year: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
if year < MIN_YEAR or year > MAX_YEAR:
raise HTTPException(
status_code=400,
detail=f"Year must be between {MIN_YEAR} and {MAX_YEAR}",
)
months_data = compute_yearly_projection_with_cumulative(session, year)
months = []
annual_income = 0.0
annual_expenses = 0.0
annual_net = 0.0
for data in months_data:
monthly = MonthlyProjectionResponse(
month=data["month"],
year=data["year"],
projected_income=data["projected_income"],
projected_fixed_expenses=data["projected_fixed_expenses"],
actual_credit_card=data["actual_credit_card"],
actual_cash=data["actual_cash"],
actual_transfers=data["actual_transfers"],
uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"],
carryover_balance=data["carryover_balance"],
cumulative_balance=data["cumulative_balance"],
balance_overridden=data["balance_overridden"],
)
months.append(monthly)
annual_income += data["projected_income"]
annual_expenses += data["gran_total_egresos"]
annual_net += data["net_balance"]
return YearlyProjectionResponse(
year=year,
months=months,
annual_income=annual_income,
annual_expenses=annual_expenses,
annual_net=annual_net,
)
class RecurringItemDetail(BaseModel):
id: int
name: str
amount: float
projected_amount: float | None = None
used_actual: bool = False
item_type: str
frequency: str
category_name: str | None = None
category_id: int | None = None
class ActualsBySource(BaseModel):
source: str
total_compra: float
total_devolucion: float
net: float
count: int
class CCCategorySpending(BaseModel):
category_name: str
amount: float
class MonthlyDetailResponse(BaseModel):
year: int
month: int
income_items: list[RecurringItemDetail]
expense_items: list[RecurringItemDetail]
actuals_by_source: list[ActualsBySource]
total_projected_income: float
total_projected_expenses: float
uncovered_actual: float
gran_total_egresos: float
net_balance: float
cc_by_category: list[CCCategorySpending]
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
def get_monthly_detail(
year: int,
month: int = Path(ge=1, le=12),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
data = compute_monthly_projection(session, year, month)
return MonthlyDetailResponse(
year=data["year"],
month=data["month"],
income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
expense_items=[RecurringItemDetail(**i) for i in data["expense_items"]],
actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]],
total_projected_income=data["projected_income"],
total_projected_expenses=data["projected_fixed_expenses"],
uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"],
cc_by_category=[CCCategorySpending(**c) for c in data["cc_by_category"]],
)
# --- Balance Override CRUD ---
@router.put(
"/balance-override/{year}/{month}",
response_model=BalanceOverrideRead,
)
def upsert_balance_override(
year: int,
month: int = Path(ge=1, le=12),
data: BalanceOverrideCreate = ...,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
if year < MIN_YEAR or year > MAX_YEAR:
raise HTTPException(400, f"Year must be between {MIN_YEAR} and {MAX_YEAR}")
if year == FRESH_START_YEAR and month < FRESH_START_MONTH:
raise HTTPException(400, f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}")
existing = session.exec(
select(BalanceOverride).where(
BalanceOverride.year == year, BalanceOverride.month == month
)
).first()
if existing:
existing.override_balance = data.override_balance
existing.updated_at = datetime.utcnow()
session.add(existing)
session.commit()
session.refresh(existing)
return existing
override = BalanceOverride(
year=year, month=month, override_balance=data.override_balance
)
session.add(override)
session.commit()
session.refresh(override)
return override
@router.delete("/balance-override/{year}/{month}", status_code=204)
def delete_balance_override(
year: int,
month: int = Path(ge=1, le=12),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(BalanceOverride).where(
BalanceOverride.year == year, BalanceOverride.month == month
)
).first()
if not existing:
raise HTTPException(404, "No override found for this month")
session.delete(existing)
session.commit()

View File

@@ -0,0 +1,285 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
Category,
Currency,
MunicipalReceipt,
MunicipalReceiptRead,
Transaction,
TransactionSource,
TransactionType,
WaterMeterReading,
WaterMeterReadingRead,
)
from app.services.municipal_receipt_pdf import extract_municipal_receipt
router = APIRouter(prefix="/municipal-receipts", tags=["municipal-receipts"])
# --- Response models ---
class MunicipalReceiptDetailRead(MunicipalReceiptRead):
water_readings: list[WaterMeterReadingRead] = []
class MunicipalReceiptUploadResult(BaseModel):
imported: int
updated: int
errors: list[str]
receipt: Optional[MunicipalReceiptRead] = None
# --- Helpers ---
def _auto_categorize(merchant: str, session: Session) -> Optional[int]:
categories = session.exec(select(Category)).all()
merchant_lower = merchant.lower()
for cat in categories:
if cat.auto_match_patterns:
patterns = [p.strip().lower() for p in cat.auto_match_patterns.split(",")]
if any(p in merchant_lower for p in patterns if p):
return cat.id
return None
def _upsert_receipt(
session: Session, data: dict, filename: str
) -> tuple[MunicipalReceipt, bool]:
"""Insert or update a municipal receipt. Returns (row, is_new)."""
r = data["receipt"]
totals = data["totals"]
receipt_date_str = r["date"]
# The receipt is issued in month N but covers month N-1
receipt_dt = datetime.strptime(receipt_date_str, "%Y-%m-%d").date()
billing_month = receipt_dt - relativedelta(months=1)
period = billing_month.strftime("%Y-%m")
existing = session.exec(
select(MunicipalReceipt).where(
MunicipalReceipt.account == r["account"],
MunicipalReceipt.period == period,
)
).first()
charges = [
{"detail": c["detail"], "amount": c.get("amount", 0)}
for c in data.get("charges", [])
]
fields = dict(
receipt_date=datetime.strptime(receipt_date_str, "%Y-%m-%d").date(),
due_date=datetime.strptime(r["due_date"], "%Y-%m-%d").date(),
period=period,
account=r["account"],
finca=r.get("finca", ""),
holder_name=r.get("account_holder", {}).get("name", ""),
holder_cedula=r.get("account_holder", {}).get("cedula", ""),
holder_address=r.get("account_holder", {}).get("address", ""),
subtotal=totals.get("subtotal", 0),
interests=totals.get("interests", 0),
iva=totals.get("iva", 0),
total=totals.get("total", 0),
raw_charges=charges,
source_filename=filename,
)
if existing:
for k, v in fields.items():
setattr(existing, k, v)
session.add(existing)
# Delete old water readings for this receipt
old_readings = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.receipt_id == existing.id
)
).all()
for rd in old_readings:
session.delete(rd)
session.flush()
return existing, False
row = MunicipalReceipt(**fields)
session.add(row)
session.flush()
return row, True
def _insert_water_readings(
session: Session, receipt: MunicipalReceipt, data: dict
) -> None:
"""Insert water meter readings (current + historical) for a receipt."""
# Current period readings
for wm in data.get("water_meters", []):
reading = WaterMeterReading(
receipt_id=receipt.id,
meter_id=str(wm["meter_id"]),
period=wm["period"],
reading_previous=wm.get("reading_previous", 0),
reading_current=wm.get("reading_current", 0),
consumption_m3=wm.get("consumption_m3", 0),
agua_potable=wm.get("agua_potable", 0),
serv_ambientales=wm.get("serv_ambientales", 0),
alcant_sanitario=wm.get("alcant_sanitario", 0),
iva=wm.get("iva", 0),
is_historical=False,
)
session.add(reading)
# Historical consumption entries
for hc in data.get("historical_consumption", []):
period = hc["period"]
meter_id = str(hc["meter_id"])
# Upsert: check if this historical entry already exists
existing = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.meter_id == meter_id,
WaterMeterReading.period == period,
WaterMeterReading.is_historical == True, # noqa: E712
)
).first()
if existing:
existing.consumption_m3 = hc.get("consumption_m3", 0)
session.add(existing)
else:
session.add(
WaterMeterReading(
receipt_id=receipt.id,
meter_id=meter_id,
period=period,
consumption_m3=hc.get("consumption_m3", 0),
is_historical=True,
)
)
def _ensure_transaction(
session: Session, receipt: MunicipalReceipt
) -> None:
"""Create a budget Transaction for this receipt if one doesn't exist."""
reference = f"municipal-{receipt.account}-{receipt.period}"
existing = session.exec(
select(Transaction).where(Transaction.reference == reference)
).first()
if existing:
# Update amount in case receipt was re-uploaded with corrections
existing.amount = receipt.total
session.add(existing)
return
category_id = _auto_categorize("municipalidad", session)
tx = Transaction(
amount=receipt.total,
currency=Currency.CRC,
merchant="Municipalidad de Belén",
date=datetime.combine(receipt.receipt_date, datetime.min.time()),
transaction_type=TransactionType.COMPRA,
source=TransactionSource.TRANSFER,
reference=reference,
category_id=category_id,
notes=f"Recibo municipal {receipt.period}",
)
session.add(tx)
# --- Endpoints ---
@router.post("/upload", response_model=MunicipalReceiptUploadResult)
async def upload_municipal_receipt(
file: UploadFile,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
filename = file.filename or "unknown.pdf"
errors: list[str] = []
try:
pdf_bytes = await file.read()
data = extract_municipal_receipt(pdf_bytes, filename)
except ValueError as e:
return MunicipalReceiptUploadResult(imported=0, updated=0, errors=[str(e)])
except Exception as e:
return MunicipalReceiptUploadResult(
imported=0, updated=0, errors=[f"{filename}: {e}"]
)
receipt, is_new = _upsert_receipt(session, data, filename)
_insert_water_readings(session, receipt, data)
_ensure_transaction(session, receipt)
session.commit()
session.refresh(receipt)
return MunicipalReceiptUploadResult(
imported=1 if is_new else 0,
updated=0 if is_new else 1,
errors=errors,
receipt=MunicipalReceiptRead.model_validate(receipt),
)
@router.get("/", response_model=list[MunicipalReceiptRead])
def list_receipts(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(MunicipalReceipt).order_by(
MunicipalReceipt.receipt_date.desc() # type: ignore[union-attr]
)
).all()
return rows
@router.get("/water-consumption", response_model=list[WaterMeterReadingRead])
def get_water_consumption(
months: int = Query(default=24, ge=1, le=120),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(WaterMeterReading)
.where(WaterMeterReading.is_historical == False) # noqa: E712
.order_by(
WaterMeterReading.period.asc(), # type: ignore[union-attr]
WaterMeterReading.meter_id.asc(), # type: ignore[union-attr]
)
.limit(months * 3) # up to 3 meters per month
).all()
return rows
@router.get("/{receipt_id}", response_model=MunicipalReceiptDetailRead)
def get_receipt_detail(
receipt_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
receipt = session.get(MunicipalReceipt, receipt_id)
if not receipt:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Receipt not found")
readings = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.receipt_id == receipt_id
)
).all()
return MunicipalReceiptDetailRead(
**MunicipalReceiptRead.model_validate(receipt).model_dump(),
water_readings=[
WaterMeterReadingRead.model_validate(r) for r in readings
],
)

View File

@@ -0,0 +1,93 @@
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from pywebpush import WebPushException, webpush
from sqlmodel import Session, select
from app.auth import get_current_user
from app.config import settings
from app.db import get_session
from app.models.models import PushSubscription, PushSubscriptionCreate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("/vapid-public-key")
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)
def subscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
).first()
if existing:
existing.p256dh = data.keys["p256dh"]
existing.auth = data.keys["auth"]
session.add(existing)
session.commit()
return {"status": "updated"}
sub = PushSubscription(
endpoint=data.endpoint,
p256dh=data.keys["p256dh"],
auth=data.keys["auth"],
)
session.add(sub)
session.commit()
return {"status": "subscribed"}
@router.delete("/unsubscribe")
def unsubscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
).first()
if existing:
session.delete(existing)
session.commit()
return {"status": "unsubscribed"}
def send_push_to_all(session: Session, title: str, body: str, url: str = "/"):
"""Send a push notification to all registered subscriptions."""
if not settings.VAPID_PRIVATE_KEY or not settings.VAPID_PUBLIC_KEY:
logger.debug("VAPID keys not configured, skipping push notification")
return
subscriptions = session.exec(select(PushSubscription)).all()
payload = json.dumps({"title": title, "body": body, "url": url})
for sub in subscriptions:
subscription_info = {
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
}
try:
webpush(
subscription_info=subscription_info,
data=payload,
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims={"sub": settings.VAPID_CLAIM_EMAIL},
)
except WebPushException as e:
logger.warning("Push failed for %s: %s", sub.endpoint[:50], e)
if e.response and e.response.status_code in (404, 410):
session.delete(sub)
session.commit()
except Exception:
logger.exception("Unexpected push error for %s", sub.endpoint[:50])

View File

@@ -0,0 +1,245 @@
from datetime import date
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Bank, PensionSnapshot, PensionSnapshotRead
from app.services.pension_pdf import parse_pension_pdf
router = APIRouter(prefix="/pensions", tags=["pensions"])
class PensionUploadResult(BaseModel):
imported: int
updated: int
duplicates: int
errors: list[str]
snapshots: list[PensionSnapshotRead]
class PensionManualEntry(BaseModel):
fund: str
period_start: date
period_end: date
saldo_anterior: float
aportes: float
rendimientos: float
retiros: float
traslados: float
comision: float
correccion: float = 0.0
bonificacion: float = 0.0
saldo_final: float
class PensionManualRequest(BaseModel):
entries: list[PensionManualEntry]
def _upsert_snapshot(
session: Session,
fund: str,
period_start: date,
period_end: date,
saldo_anterior: float,
aportes: float,
rendimientos: float,
retiros: float,
traslados: float,
comision: float,
correccion: float,
bonificacion: float,
saldo_final: float,
source_filename: str,
contract_number: str = "",
) -> tuple[PensionSnapshot, bool]:
"""Insert or update a pension snapshot. Returns (row, is_new)."""
existing = session.exec(
select(PensionSnapshot).where(
PensionSnapshot.fund == Bank(fund),
PensionSnapshot.period_start == period_start,
PensionSnapshot.period_end == period_end,
)
).first()
if existing:
existing.saldo_anterior = saldo_anterior
existing.aportes = aportes
existing.rendimientos = rendimientos
existing.retiros = retiros
existing.traslados = traslados
existing.comision = comision
existing.correccion = correccion
existing.bonificacion = bonificacion
existing.saldo_final = saldo_final
existing.source_filename = source_filename
if contract_number:
existing.contract_number = contract_number
session.add(existing)
return existing, False
row = PensionSnapshot(
fund=Bank(fund),
contract_number=contract_number,
period_start=period_start,
period_end=period_end,
saldo_anterior=saldo_anterior,
aportes=aportes,
rendimientos=rendimientos,
retiros=retiros,
traslados=traslados,
comision=comision,
correccion=correccion,
bonificacion=bonificacion,
saldo_final=saldo_final,
source_filename=source_filename,
)
session.add(row)
return row, True
@router.post("/upload", response_model=PensionUploadResult)
async def upload_pension_pdfs(
files: list[UploadFile],
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
imported = 0
updated = 0
errors: list[str] = []
results: list[PensionSnapshot] = []
for file in files:
filename = file.filename or "unknown.pdf"
try:
pdf_bytes = await file.read()
fund_snapshots = parse_pension_pdf(pdf_bytes, filename)
except ValueError as e:
errors.append(str(e))
continue
except Exception as e:
errors.append(f"{filename}: {e}")
continue
for snap in fund_snapshots:
row, is_new = _upsert_snapshot(
session,
fund=snap.fund,
period_start=snap.period_start,
period_end=snap.period_end,
saldo_anterior=snap.saldo_anterior,
aportes=snap.aportes,
rendimientos=snap.rendimientos,
retiros=snap.retiros,
traslados=snap.traslados,
comision=snap.comision,
correccion=snap.correccion,
bonificacion=snap.bonificacion,
saldo_final=snap.saldo_final,
source_filename=filename,
contract_number=snap.contract_number,
)
results.append(row)
if is_new:
imported += 1
else:
updated += 1
if imported > 0 or updated > 0:
session.commit()
for row in results:
session.refresh(row)
return PensionUploadResult(
imported=imported,
updated=updated,
duplicates=0,
errors=errors,
snapshots=[PensionSnapshotRead.model_validate(r) for r in results],
)
@router.post("/manual", response_model=PensionUploadResult)
def submit_manual_entries(
body: PensionManualRequest,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
imported = 0
updated = 0
results: list[PensionSnapshot] = []
for entry in body.entries:
row, is_new = _upsert_snapshot(
session,
fund=entry.fund,
period_start=entry.period_start,
period_end=entry.period_end,
saldo_anterior=entry.saldo_anterior,
aportes=entry.aportes,
rendimientos=entry.rendimientos,
retiros=entry.retiros,
traslados=entry.traslados,
comision=entry.comision,
correccion=entry.correccion,
bonificacion=entry.bonificacion,
saldo_final=entry.saldo_final,
source_filename="manual-entry",
)
results.append(row)
if is_new:
imported += 1
else:
updated += 1
if imported > 0 or updated > 0:
session.commit()
for row in results:
session.refresh(row)
return PensionUploadResult(
imported=imported,
updated=updated,
duplicates=0,
errors=[],
snapshots=[PensionSnapshotRead.model_validate(r) for r in results],
)
@router.get("/snapshots", response_model=list[PensionSnapshotRead])
def get_snapshots(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(PensionSnapshot).order_by(
PensionSnapshot.period_end.desc(), # type: ignore[union-attr]
PensionSnapshot.fund,
)
).all()
return rows
@router.get("/fund-summary", response_model=list[PensionSnapshotRead])
def get_fund_summary(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Return the latest snapshot per fund (by most recent period_end)."""
all_rows = session.exec(
select(PensionSnapshot).order_by(
PensionSnapshot.period_end.desc(), # type: ignore[union-attr]
)
).all()
seen: set[str] = set()
latest: list[PensionSnapshot] = []
for row in all_rows:
if row.fund.value not in seen:
seen.add(row.fund.value)
latest.append(row)
return latest

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlmodel import Session, col, func, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Transaction, TransactionRead, TransactionType
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/salarios", tags=["salarios"])
SALARIO_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
class SalariosSummary(BaseModel):
count: int
total_amount: float
latest_date: Optional[datetime] = None
@router.get("/", response_model=list[TransactionRead])
def list_salarios(
limit: int = Query(default=50, le=500),
offset: int = 0,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
query = (
select(Transaction)
.where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
.order_by(col(Transaction.date).desc())
.offset(offset)
.limit(limit)
)
return session.exec(query).all()
@router.get("/summary", response_model=SalariosSummary)
def salarios_summary(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
amount_crc = get_converted_amount_expr(session)
result = session.exec(
select(
func.count(),
func.coalesce(func.sum(amount_crc), 0),
func.max(Transaction.date),
).where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
).first()
return SalariosSummary(
count=result[0] if result else 0,
total_amount=float(result[1]) if result else 0.0,
latest_date=result[2] if result else None,
)

View File

@@ -0,0 +1,83 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, col, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
SavingsAccrual,
SavingsAccrualCreate,
SavingsAccrualRead,
SavingsAccrualUpdate,
)
router = APIRouter(prefix="/savings-accrual", tags=["savings-accrual"])
@router.get("/", response_model=list[SavingsAccrualRead])
def list_accruals(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
query = select(SavingsAccrual).order_by(
col(SavingsAccrual.year).desc(), col(SavingsAccrual.month).desc()
)
return session.exec(query).all()
@router.post("/", response_model=SavingsAccrualRead, status_code=201)
def create_accrual(
data: SavingsAccrualCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(SavingsAccrual).where(
SavingsAccrual.year == data.year,
SavingsAccrual.month == data.month,
)
).first()
if existing:
raise HTTPException(
status_code=409,
detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})",
)
accrual = SavingsAccrual.model_validate(data)
accrual.applied_at = datetime.utcnow()
session.add(accrual)
session.commit()
session.refresh(accrual)
return accrual
@router.patch("/{accrual_id}", response_model=SavingsAccrualRead)
def update_accrual(
accrual_id: int,
data: SavingsAccrualUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
accrual = session.get(SavingsAccrual, accrual_id)
if not accrual:
raise HTTPException(status_code=404, detail="Accrual not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(accrual, key, value)
session.add(accrual)
session.commit()
session.refresh(accrual)
return accrual
@router.delete("/{accrual_id}", status_code=204)
def delete_accrual(
accrual_id: int = Path(...),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
accrual = session.get(SavingsAccrual, accrual_id)
if not accrual:
raise HTTPException(status_code=404, detail="Accrual not found")
session.delete(accrual)
session.commit()

View File

@@ -0,0 +1,59 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate
router = APIRouter(prefix="/settings", tags=["settings"])
DEFAULT_SETTINGS = {
"dashboard": {
"sections": {
"crc_accounts": {"label": "CRC Accounts", "color": "primary", "cardColor": "primary", "visible": True, "order": 0, "expanded": False},
"usd_accounts": {"label": "USD Accounts", "color": "chart-1", "cardColor": "chart-1", "visible": True, "order": 1, "expanded": False},
"pension": {"label": "Pension", "color": "chart-2", "cardColor": "chart-2", "visible": True, "order": 2, "expanded": False},
"savings": {"label": "Savings", "color": "chart-3", "cardColor": "chart-3", "visible": True, "order": 3, "expanded": False},
"liabilities": {"label": "Liabilities", "color": "destructive", "cardColor": "destructive", "visible": True, "order": 4, "expanded": False},
"crypto": {"label": "Crypto", "color": "chart-4", "cardColor": "chart-4", "visible": True, "order": 5, "expanded": False},
}
}
}
@router.get("/", response_model=UserSettingsRead)
def get_settings(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
settings = session.exec(
select(UserSettings).where(UserSettings.key == "default")
).first()
if not settings:
settings = UserSettings(key="default", data=DEFAULT_SETTINGS)
session.add(settings)
session.commit()
session.refresh(settings)
return settings
@router.patch("/", response_model=UserSettingsRead)
def update_settings(
body: UserSettingsUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
settings = session.exec(
select(UserSettings).where(UserSettings.key == "default")
).first()
if not settings:
settings = UserSettings(key="default", data=body.data)
else:
settings.data = body.data
settings.updated_at = datetime.utcnow()
session.add(settings)
session.commit()
session.refresh(settings)
return settings

View File

@@ -7,28 +7,24 @@ from sqlmodel import Session, col, func, select
from app.auth import get_current_user
from app.db import get_session
from app.api.v1.endpoints.notifications import send_push_to_all
from app.models.models import (
Category,
Currency,
Transaction,
TransactionCreate,
TransactionRead,
TransactionSource,
TransactionType,
TransactionUpdate,
)
from app.services.budget_projection import get_cycle_range, get_previous_cycle
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/transactions", tags=["transactions"])
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
class BillingCycle(BaseModel):
year: int
month: int
@@ -51,10 +47,13 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
@router.get("/", response_model=list[TransactionRead])
def list_transactions(
source: Optional[TransactionSource] = None,
exclude_source: Optional[TransactionSource] = None,
search: Optional[str] = None,
category_id: Optional[int] = None,
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = Query(default=50, le=500),
offset: int = 0,
session: Session = Depends(get_session),
@@ -63,13 +62,37 @@ def list_transactions(
query = select(Transaction)
if source:
query = query.where(Transaction.source == source)
if exclude_source:
query = query.where(Transaction.source != exclude_source)
if category_id:
query = query.where(Transaction.category_id == category_id)
if search:
query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)
prev_y, prev_m = get_previous_cycle(cycle_year, cycle_month)
prev_start, prev_end = get_cycle_range(prev_y, prev_m)
# Normal transactions in this cycle (not deferred) + deferred from previous cycle
from sqlalchemy import or_, and_
query = query.where(
or_(
and_(
Transaction.date >= start,
Transaction.date < end,
Transaction.deferred_to_next_cycle == False, # noqa: E712
),
and_(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.deferred_to_next_cycle == True, # noqa: E712
),
)
)
elif start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
return session.exec(query).all()
@@ -88,6 +111,7 @@ def list_billing_cycles(
return []
min_date, max_date = result
amount_crc = get_converted_amount_expr(session)
cycles = []
# Determine which cycle the min_date falls into
@@ -107,7 +131,7 @@ def list_billing_cycles(
# Count transactions in this cycle
count_result = session.exec(
select(func.count(), func.coalesce(func.sum(Transaction.amount), 0)).where(
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date < end
)
).first()
@@ -170,6 +194,25 @@ def create_transaction(
session.add(tx)
session.commit()
session.refresh(tx)
# Send push notification
symbols = {Currency.CRC: "", Currency.USD: "$", Currency.EUR: ""}
symbol = symbols.get(tx.currency, tx.currency.value)
amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}"
is_income = tx.transaction_type in (TransactionType.DEPOSITO, TransactionType.SALARY)
is_salary = tx.transaction_type == TransactionType.SALARY
label = "salario" if is_salary else ("depósito" if is_income else tx.transaction_type.value.lower())
send_push_to_all(
session,
title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
body=f"{amount_str}{tx.bank.value} {label}",
url="/salarios" if is_income else "/budget",
)
if is_salary:
from app.services.savings_accrual import maybe_apply_monthly_savings
maybe_apply_monthly_savings(session, tx)
return tx

View File

@@ -4,9 +4,16 @@ from app.api.v1.endpoints import (
accounts,
analytics,
auth,
budget,
categories,
exchange_rate,
import_transactions,
municipal_receipts,
notifications,
pensions,
salarios,
savings_accrual,
settings,
tokens,
transactions,
)
@@ -20,3 +27,10 @@ api_router.include_router(import_transactions.router)
api_router.include_router(exchange_rate.router)
api_router.include_router(tokens.router)
api_router.include_router(analytics.router)
api_router.include_router(settings.router)
api_router.include_router(budget.router)
api_router.include_router(notifications.router)
api_router.include_router(salarios.router)
api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router)
api_router.include_router(savings_accrual.router)

View File

@@ -1,7 +1,9 @@
import hashlib
import re
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi import Cookie, Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlmodel import Session, select
@@ -22,8 +24,8 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
# Try JWT first
def _validate_token(token: str) -> str:
"""Validate JWT and return subject, or raise 401."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
@@ -51,3 +53,26 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
return f"api:{api_token.name}"
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
def get_current_user_cookie_or_bearer(
authorization: Optional[str] = Header(default=None),
ws_token: Optional[str] = Cookie(default=None),
) -> str:
"""Accepts httpOnly cookie (SPA) or Bearer token (API clients / n8n)."""
token: Optional[str] = None
if authorization and authorization.lower().startswith("bearer "):
token = authorization.split(" ", 1)[1].strip()
elif ws_token:
token = ws_token
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return _validate_token(token)
def get_current_user(
authorization: Optional[str] = Header(default=None),
ws_token: Optional[str] = Cookie(default=None),
) -> str:
"""SPA cookie or Bearer token. Single dependency for all v1 endpoints."""
return get_current_user_cookie_or_bearer(authorization, ws_token)

View File

@@ -9,6 +9,11 @@ class Settings(BaseSettings):
ADMIN_PASSWORD: str = "admin"
BCCR_API_EMAIL: str = ""
BCCR_API_TOKEN: str = ""
VAPID_PRIVATE_KEY: str = ""
VAPID_PUBLIC_KEY: str = ""
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
OPENAI_API_KEY: str = ""
AGENT_MODEL: str = "gpt-5.4-mini"
class Config:
env_file = ".env"

View File

@@ -1,3 +1,4 @@
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine
from app.config import settings
@@ -9,6 +10,72 @@ def init_db():
SQLModel.metadata.create_all(engine)
def run_migrations():
"""Run idempotent schema migrations for columns added after initial create."""
with engine.connect() as conn:
try:
conn.execute(
text(
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false"
)
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(text("ALTER TYPE currency ADD VALUE IF NOT EXISTS 'EUR'"))
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text("ALTER TYPE transactiontype ADD VALUE IF NOT EXISTS 'SALARY'")
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS savingsaccrual (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
memp_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
mpat_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
trigger_transaction_id INTEGER,
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
notes TEXT,
CONSTRAINT savingsaccrual_year_month_key UNIQUE (year, month)
)
"""
)
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text(
"""
INSERT INTO savingsaccrual (year, month, memp_amount, mpat_amount, notes)
VALUES
(2026, 2, 200000, 200000, 'Seeded: historical baseline'),
(2026, 3, 200000, 200000, 'Seeded: historical baseline')
ON CONFLICT (year, month) DO NOTHING
"""
)
)
conn.commit()
except Exception:
conn.rollback()
def get_session():
with Session(engine) as session:
yield session

View File

@@ -1,19 +1,77 @@
import asyncio
import json
import re
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware
from jose import JWTError, jwt
from pydantic import BaseModel
from app.agent.agent import build_agent
from app.agent.tools import reset_session, set_session
from app.api.v1.router import api_router
from app.auth import ALGORITHM, create_access_token
from app.config import settings
from app.db import init_db
from app.db import get_session, init_db, run_migrations
from app.seed import seed_db
from app.services.exchange_rate import refresh_rates_periodically
AGENT_PATH = "/api/v1/agent/agui"
def _pair_orphan_tool_calls(messages: list) -> list:
"""Inject synthetic tool responses for any assistant tool_calls that have
no matching tool message. OpenAI rejects histories where a tool_calls
entry is not immediately followed by the corresponding tool response."""
out: list = []
pending: list[str] = []
def flush():
for call_id in pending:
out.append({"role": "tool", "tool_call_id": call_id, "content": ""})
pending.clear()
for msg in messages:
role = msg.get("role", "")
if role == "tool":
call_id = msg.get("tool_call_id") or msg.get("toolCallId")
if call_id and call_id in pending:
pending.remove(call_id)
out.append(msg)
continue
if role == "assistant":
flush()
out.append(msg)
for tc in msg.get("tool_calls") or msg.get("toolCalls") or []:
tc_id = tc.get("id") if isinstance(tc, dict) else None
if tc_id:
pending.append(tc_id)
continue
flush()
out.append(msg)
flush()
return out
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
run_migrations()
seed_db()
yield
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
try:
yield
finally:
rate_refresh_task.cancel()
try:
await rate_refresh_task
except asyncio.CancelledError:
pass
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
@@ -26,9 +84,106 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def agent_auth_and_session(request: Request, call_next):
"""For the AG-UI route, validate the JWT, repair message history, and
bind a DB session to a ContextVar so agent tools can query without going
through Depends."""
if not request.url.path.startswith(AGENT_PATH):
return await call_next(request)
if request.method == "OPTIONS":
return await call_next(request)
auth_header = request.headers.get("authorization", "")
token: str | None = None
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
else:
cookie_header = request.headers.get("cookie", "")
m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header)
if m:
token = m.group(1)
if not token:
return Response(status_code=401, content="Missing auth")
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
if not payload.get("sub"):
return Response(status_code=401, content="Invalid token")
except JWTError:
return Response(status_code=401, content="Invalid token")
# Repair orphan tool_calls before the MAF agent sees the message history.
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
raw = await request.body()
try:
body = json.loads(raw)
if isinstance(body.get("messages"), list):
body["messages"] = _pair_orphan_tool_calls(body["messages"])
raw = json.dumps(body).encode()
except Exception:
pass
# Starlette caches the body; replace it so call_next sees the fixed bytes.
request._body = raw # type: ignore[attr-defined]
session_gen = get_session()
session = next(session_gen)
token_var = set_session(session)
try:
return await call_next(request)
finally:
reset_session(token_var)
try:
next(session_gen)
except StopIteration:
pass
# Register app routes
app.include_router(api_router)
# Mount the AG-UI agent endpoint.
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
@app.get("/")
def root():
return {"app": "WealthySmart", "version": "0.1.0"}
# ── Cookie-based auth endpoints (used by the Vite SPA) ──────────────────────
class LoginRequest(BaseModel):
username: str
password: str
@app.post("/api/auth/login")
def cookie_login(body: LoginRequest, response: Response):
if (
body.username != settings.ADMIN_USERNAME
or body.password != settings.ADMIN_PASSWORD
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(body.username)
response.set_cookie(
key="ws_token",
value=token,
httponly=True,
samesite="lax",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
secure=False, # set True behind TLS in production via nginx
)
return {"ok": True}
@app.post("/api/auth/logout", status_code=204)
def cookie_logout(response: Response):
response.delete_cookie("ws_token")
@app.get("/api/health")
def health():
return {"ok": True}

View File

@@ -1,13 +1,30 @@
import enum
from datetime import datetime
from datetime import date, datetime
from typing import Optional
from sqlalchemy import JSON, Column, UniqueConstraint
from sqlmodel import Field, Relationship, SQLModel
class RecurringItemType(str, enum.Enum):
INCOME = "INCOME"
EXPENSE = "EXPENSE"
SAVINGS = "SAVINGS"
class RecurringFrequency(str, enum.Enum):
WEEKLY = "WEEKLY"
MONTHLY = "MONTHLY"
QUARTERLY = "QUARTERLY"
BIANNUAL = "BIANNUAL"
YEARLY = "YEARLY"
class TransactionType(str, enum.Enum):
COMPRA = "COMPRA"
DEVOLUCION = "DEVOLUCION"
DEPOSITO = "DEPOSITO"
SALARY = "SALARY"
class TransactionSource(str, enum.Enum):
@@ -19,6 +36,7 @@ class TransactionSource(str, enum.Enum):
class Currency(str, enum.Enum):
CRC = "CRC"
USD = "USD"
EUR = "EUR"
BTC = "BTC"
XMR = "XMR"
@@ -124,6 +142,7 @@ class TransactionBase(SQLModel):
bank: Bank = Bank.BAC
notes: Optional[str] = None
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
deferred_to_next_cycle: bool = Field(default=False)
class Transaction(TransactionBase, table=True):
@@ -152,6 +171,7 @@ class TransactionUpdate(SQLModel):
source: Optional[TransactionSource] = None
notes: Optional[str] = None
category_id: Optional[int] = None
deferred_to_next_cycle: Optional[bool] = None
# --- Exchange Rate ---
@@ -195,3 +215,255 @@ class APITokenRead(SQLModel):
created_at: datetime
expires_at: Optional[datetime]
is_active: bool
# --- User Settings ---
class UserSettings(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
key: str = Field(index=True, unique=True, default="default")
data: dict = Field(
default_factory=dict,
sa_column=Column(JSON, nullable=False, server_default="{}"),
)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class UserSettingsRead(SQLModel):
key: str
data: dict
updated_at: datetime
class UserSettingsUpdate(SQLModel):
data: dict
# --- Recurring Item ---
class RecurringItemBase(SQLModel):
name: str
amount: float
currency: Currency = Currency.CRC
item_type: RecurringItemType
frequency: RecurringFrequency = RecurringFrequency.MONTHLY
day_of_month: Optional[int] = None
month_of_year: Optional[int] = None
override_amounts: Optional[dict] = Field(
default=None,
sa_column=Column(JSON, nullable=True),
)
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
is_active: bool = True
notes: Optional[str] = None
class RecurringItem(RecurringItemBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
category: Optional[Category] = Relationship()
class RecurringItemCreate(RecurringItemBase):
pass
class RecurringItemRead(RecurringItemBase):
id: int
created_at: datetime
category: Optional[CategoryRead] = None
class RecurringItemUpdate(SQLModel):
name: Optional[str] = None
amount: Optional[float] = None
currency: Optional[Currency] = None
item_type: Optional[RecurringItemType] = None
frequency: Optional[RecurringFrequency] = None
day_of_month: Optional[int] = None
month_of_year: Optional[int] = None
override_amounts: Optional[dict] = None
category_id: Optional[int] = None
is_active: Optional[bool] = None
notes: Optional[str] = None
# --- Push Subscription ---
class PushSubscription(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
endpoint: str = Field(unique=True)
p256dh: str
auth: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class PushSubscriptionCreate(SQLModel):
endpoint: str
keys: dict # {"p256dh": "...", "auth": "..."}
# --- Pension Snapshot ---
class PensionSnapshotBase(SQLModel):
fund: Bank
contract_number: str
period_start: date
period_end: date
saldo_anterior: float
aportes: float
rendimientos: float
retiros: float
traslados: float
comision: float
correccion: float
bonificacion: float
saldo_final: float
source_filename: str
class PensionSnapshot(PensionSnapshotBase, table=True):
__table_args__ = (
UniqueConstraint("fund", "period_start", "period_end"),
)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class PensionSnapshotRead(PensionSnapshotBase):
id: int
created_at: datetime
# --- Balance Override ---
class BalanceOverride(SQLModel, table=True):
__table_args__ = (UniqueConstraint("year", "month"),)
id: Optional[int] = Field(default=None, primary_key=True)
year: int
month: int
override_balance: float
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class BalanceOverrideCreate(SQLModel):
override_balance: float
class BalanceOverrideRead(SQLModel):
id: int
year: int
month: int
override_balance: float
updated_at: datetime
# --- Savings Accrual ---
class SavingsAccrualBase(SQLModel):
year: int
month: int
memp_amount: float = 200000.0
mpat_amount: float = 200000.0
trigger_transaction_id: Optional[int] = None
notes: Optional[str] = None
class SavingsAccrual(SavingsAccrualBase, table=True):
__table_args__ = (UniqueConstraint("year", "month"),)
id: Optional[int] = Field(default=None, primary_key=True)
applied_at: datetime = Field(default_factory=datetime.utcnow)
class SavingsAccrualCreate(SavingsAccrualBase):
pass
class SavingsAccrualRead(SavingsAccrualBase):
id: int
applied_at: datetime
class SavingsAccrualUpdate(SQLModel):
memp_amount: Optional[float] = None
mpat_amount: Optional[float] = None
notes: Optional[str] = None
# --- Municipal Receipt ---
class MunicipalReceiptBase(SQLModel):
receipt_date: date
due_date: date
period: str # "YYYY-MM"
account: str
finca: str
holder_name: str
holder_cedula: str
holder_address: str
subtotal: float
interests: float
iva: float
total: float
raw_charges: list[dict] = Field(
default_factory=list,
sa_column=Column(JSON, nullable=False, server_default="[]"),
)
source_filename: str
class MunicipalReceipt(MunicipalReceiptBase, table=True):
__table_args__ = (UniqueConstraint("account", "period"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
water_readings: list["WaterMeterReading"] = Relationship(
back_populates="receipt",
)
class MunicipalReceiptCreate(MunicipalReceiptBase):
pass
class MunicipalReceiptRead(MunicipalReceiptBase):
id: int
created_at: datetime
# --- Water Meter Reading ---
class WaterMeterReadingBase(SQLModel):
meter_id: str
period: str # "YYYY-MM"
reading_previous: float = 0
reading_current: float = 0
consumption_m3: float
agua_potable: float = 0
serv_ambientales: float = 0
alcant_sanitario: float = 0
iva: float = 0
is_historical: bool = False
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
class WaterMeterReading(WaterMeterReadingBase, table=True):
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
receipt: Optional[MunicipalReceipt] = Relationship(
back_populates="water_readings",
)
class WaterMeterReadingRead(WaterMeterReadingBase):
id: int
created_at: datetime

View File

@@ -1,14 +1,23 @@
from sqlmodel import Session, select
from app.db import engine
from app.models.models import Account, AccountType, Bank, Category, Currency
from app.models.models import (
Account,
AccountType,
Bank,
Category,
Currency,
RecurringFrequency,
RecurringItem,
RecurringItemType,
)
DEFAULT_CATEGORIES = [
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"),
("Food & Delivery", "utensils", "uber eats,rappi,mcdonalds,subway,pizza,restaurant,soda,cafe,coyote ugly,el rodeo,steak house"),
("Utilities", "zap", "c.n.f.l,cnfl,ice,aya,claro cr telecomunicaciones"),
("Transportation", "car", "gasolina,gasolinera,uber rides,didi,parqueo,parking,peaje,estacion de servicio,estac.de serv"),
("Shopping", "shopping-bag", "amazon,ebay,ticotek,construplaza,epa,novex,novedades chayfer,total imports,tiendalaliga,gnc live well"),
("Shopping", "shopping-bag", "amazon,ebay,construplaza,epa,novex,novedades chayfer,total imports,tiendalaliga,gnc live well"),
("Entertainment", "film", "netflix,disney,cine,steam,playstation,blizzard,diablo"),
("Health", "heart-pulse", "farmacia,hospital,clinica,laboratorio,optica,medicina regenerativa,neumi,doer fitness,kettlebell,lacrosse"),
("Education", "graduation-cap", "universidad,udemy,coursera,libro"),
@@ -18,6 +27,7 @@ DEFAULT_CATEGORIES = [
("Telecom", "phone", "liberty,tigo,kolbi"),
("Parking & Fees", "circle-parking", "centro comercial curridabat,debito compass,cobro administr,compass"),
("Auto", "car-front", "auto lavado,lavado"),
("Electronics", "cpu", "extremetechcr,extreme tech,ticotek,ishop,gollo,radioshack"),
("Lab & Medical", "microscope", "laboratorio echandi"),
("Other", "tag", ""),
]
@@ -34,9 +44,6 @@ DEFAULT_ACCOUNTS = [
(Bank.FCL, Currency.CRC, "FCL", AccountType.PENSION),
(Bank.ROP, Currency.CRC, "ROP", AccountType.PENSION),
(Bank.VOL, Currency.CRC, "VOL", AccountType.PENSION),
# Savings (CRC)
(Bank.MEMP, Currency.CRC, "MEMP", AccountType.SAVINGS),
(Bank.MPAT, Currency.CRC, "MPAT", AccountType.SAVINGS),
# Liabilities
(Bank.MORTGAGE, Currency.USD, "Mortgage", AccountType.LIABILITY),
# Crypto
@@ -45,6 +52,128 @@ DEFAULT_ACCOUNTS = [
]
DEFAULT_RECURRING_ITEMS = [
# Incomes
{
"name": "Alquiler Apt 1",
"amount": 320000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.MONTHLY,
"day_of_month": 1,
"notes": "Tenant rent - start of month",
},
{
"name": "Alquiler Apt 2",
"amount": 360000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.MONTHLY,
"day_of_month": 15,
"notes": "Tenant rent - mid month",
},
{
"name": "Salario Quincenal 1",
"amount": 1400000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.MONTHLY,
"day_of_month": 15,
"notes": "Net salary - mid month",
},
{
"name": "Salario Quincenal 2",
"amount": 1400000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.MONTHLY,
"day_of_month": 30,
"notes": "Net salary - end of month",
},
{
"name": "Aguinaldo",
"amount": 3000000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.YEARLY,
"month_of_year": 12,
"notes": "Yearly bonus",
},
# Fixed expenses
{
"name": "Hipoteca",
"amount": 450000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Mortgage payment estimate",
},
{
"name": "Comida y Gasolina",
"amount": 300000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Food & Gas estimate",
},
{
"name": "CNFL",
"amount": 50000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Electricity",
},
{
"name": "Internet",
"amount": 50000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Internet service",
},
{
"name": "Municipalidad",
"amount": 30000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"override_amounts": {"3": 150000, "6": 150000, "9": 150000, "12": 150000},
"notes": "Local gov fees; 150k in property tax quarters",
},
{
"name": "Tennis y Limpieza",
"amount": 150000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Tennis lessons + house cleaning",
},
# Cash transfers
{
"name": "Empleada Doméstica",
"amount": 20000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.WEEKLY,
"day_of_month": 0,
"notes": "Weekly maid payment (~80k/month)",
},
{
"name": "Clases de Tennis",
"amount": 50000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Monthly tennis lessons cash transfer",
},
# Sporadic
{
"name": "CCE (Country Club)",
"amount": 720000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.YEARLY,
"month_of_year": 2,
"notes": "Yearly country club fee",
},
{
"name": "Seguro Vehicular",
"amount": 150000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.BIANNUAL,
"month_of_year": 1,
"notes": "Car insurance every 6 months (Jan, Jul)",
},
]
def seed_db():
with Session(engine) as session:
existing = session.exec(select(Category)).first()
@@ -58,3 +187,9 @@ def seed_db():
for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
session.commit()
existing_recurring = session.exec(select(RecurringItem)).first()
if not existing_recurring:
for item_data in DEFAULT_RECURRING_ITEMS:
session.add(RecurringItem(**item_data))
session.commit()

View File

@@ -0,0 +1,615 @@
import calendar
from datetime import datetime
from sqlmodel import Session, col, func, select
from app.models.models import (
BalanceOverride,
RecurringFrequency,
RecurringItem,
RecurringItemType,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.exchange_rate import get_converted_amount_expr
MIN_YEAR = 2026
MAX_YEAR = 2030
# Fresh start: months before this are zeroed out
FRESH_START_YEAR = 2026
FRESH_START_MONTH = 3
# Income-like transaction types that should never be counted as expenses
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
freq = item.frequency
if freq == RecurringFrequency.MONTHLY:
if item.override_amounts and str(month) in item.override_amounts:
return float(item.override_amounts[str(month)])
return item.amount
if freq == RecurringFrequency.WEEKLY:
# Count occurrences of the weekday in this month
# day_of_month stores day-of-week: 0=Monday
weekday = item.day_of_month if item.day_of_month is not None else 0
cal = calendar.monthcalendar(year, month)
count = sum(1 for week in cal if week[weekday] != 0)
return item.amount * count
if freq == RecurringFrequency.QUARTERLY:
# Active in months 3, 6, 9, 12 by default
if month % 3 == 0:
if item.override_amounts and str(month) in item.override_amounts:
return float(item.override_amounts[str(month)])
return item.amount
return None
if freq == RecurringFrequency.BIANNUAL:
# Active in month_of_year and 6 months later
base = item.month_of_year or 1
second = base + 6 if base <= 6 else base - 6
if month in (base, second):
return item.amount
return None
if freq == RecurringFrequency.YEARLY:
if month == (item.month_of_year or 12):
return item.amount
return None
return None
def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for a calendar month."""
start = datetime(year, month, 1)
if month == 12:
end = datetime(year + 1, 1, 1)
else:
end = datetime(year, month + 1, 1)
return start, end
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
def get_previous_cycle(year: int, month: int) -> tuple[int, int]:
"""Return (year, month) for the billing cycle preceding the given one."""
if month == 1:
return year - 1, 12
return year, month - 1
def compute_actuals_by_source(
session: Session, year: int, month: int
) -> dict[str, dict]:
"""Query actual transaction totals grouped by source.
Credit card uses billing cycle (18th-18th) with deferred logic.
Cash/Transfer use calendar month (1st-1st).
"""
# CC billing cycle for budget month M is the cycle that *ends* around the 18th of M
# i.e. cycle (M-1): from (M-1)/18 to M/18, paid with month M salary
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
results = {}
for source in TransactionSource:
if source == TransactionSource.CREDIT_CARD:
start, end = cc_start, cc_end
# Normal transactions in this cycle (not deferred)
compra_normal = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
# Deferred from previous cycle
compra_deferred = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
compra = float(compra_normal) + float(compra_deferred)
dev_normal = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
dev_deferred = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
devolucion = float(dev_normal) + float(dev_deferred)
count_normal = session.exec(
select(func.count()).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
count_deferred = session.exec(
select(func.count()).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
count = count_normal + count_deferred
results[source.value] = {
"source": source.value,
"total_compra": compra,
"total_devolucion": devolucion,
"net": compra - devolucion,
"count": count,
}
else:
# Cash / Transfer: calendar month, no deferred logic
compra = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
)
).one()
devolucion = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
)
).one()
count = session.exec(
select(func.count()).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
).one()
compra_val = float(compra)
devolucion_val = float(devolucion)
results[source.value] = {
"source": source.value,
"total_compra": compra_val,
"total_devolucion": devolucion_val,
"net": compra_val - devolucion_val,
"count": count,
}
return results
def compute_actuals_by_category(
session: Session, year: int, month: int
) -> dict[int, float]:
"""Return {category_id: net_amount} for actual transactions.
Credit card uses billing cycle (18th-18th) with deferred logic.
Cash/Transfer use calendar month (1st-1st).
"""
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
totals: dict[int, float] = {}
def _merge_rows(rows: list) -> None:
for cat_id, tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
totals[cat_id] = totals.get(cat_id, 0) + val
# 1) CC normal in this cycle (not deferred)
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# 2) CC deferred from previous cycle
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# 3) Non-CC: calendar month
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
return totals
def compute_cc_by_category(
session: Session, year: int, month: int
) -> list[dict]:
"""Return credit card spending by category for the billing cycle."""
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
amount_crc = get_converted_amount_expr(session)
totals: dict[int | None, float] = {}
def _merge(rows: list) -> None:
for cat_id, tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
totals[cat_id] = totals.get(cat_id, 0) + val
# CC normal in this cycle
_merge(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# CC deferred from previous cycle
_merge(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# Resolve category names
from app.models.models import Category
result = []
for cat_id, amount in totals.items():
if amount <= 0:
continue
if cat_id is not None:
cat = session.get(Category, cat_id)
name = cat.name if cat else "Sin categoría"
else:
name = "Sin categoría"
result.append({"category_name": name, "amount": round(amount, 2)})
return sorted(result, key=lambda x: x["amount"], reverse=True)
def compute_monthly_projection(
session: Session, year: int, month: int
) -> dict:
"""Compute full monthly projection with no-double-count logic."""
items = session.exec(
select(RecurringItem).where(
RecurringItem.is_active == True, # noqa: E712
RecurringItem.item_type != RecurringItemType.SAVINGS,
)
).all()
actuals_by_source = compute_actuals_by_source(session, year, month)
actuals_by_category = compute_actuals_by_category(session, year, month)
income_items = []
expense_items = []
total_income = 0.0
total_fixed_expenses = 0.0
for item in items:
effective = get_effective_amount(item, month, year)
if effective is None:
continue
detail = {
"id": item.id,
"name": item.name,
"amount": effective,
"item_type": item.item_type.value,
"frequency": item.frequency.value,
"category_name": item.category.name if item.category else None,
"category_id": item.category_id,
"used_actual": False,
}
if item.item_type == RecurringItemType.INCOME:
income_items.append(detail)
total_income += effective
elif item.item_type == RecurringItemType.EXPENSE:
# No-double-count: if category has actuals, use actual instead
if item.category_id and item.category_id in actuals_by_category:
actual_amount = actuals_by_category[item.category_id]
detail["amount"] = actual_amount
detail["projected_amount"] = effective
detail["used_actual"] = True
total_fixed_expenses += actual_amount
else:
total_fixed_expenses += effective
expense_items.append(detail)
# Sum actuals from sources for categories NOT covered by recurring items
covered_category_ids = {
item.category_id
for item in items
if item.item_type == RecurringItemType.EXPENSE
and item.category_id is not None
and get_effective_amount(item, month, year) is not None
}
uncovered_actual = 0.0
for cat_id, amount in actuals_by_category.items():
if cat_id not in covered_category_ids:
uncovered_actual += amount
# Also add transactions with no category (hybrid ranges + deferred)
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
def _sum_uncategorized(rows: list) -> float:
total = 0.0
for tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
total += val
return total
# CC uncategorized: this cycle (not deferred)
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.transaction_type)
).all()
)
# CC uncategorized: deferred from previous cycle
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.transaction_type)
).all()
)
# Non-CC uncategorized: calendar month
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
.group_by(Transaction.transaction_type)
).all()
)
actual_credit_card = actuals_by_source.get("CREDIT_CARD", {}).get("net", 0)
actual_cash = actuals_by_source.get("CASH", {}).get("net", 0)
actual_transfers = actuals_by_source.get("TRANSFER", {}).get("net", 0)
cc_by_category = compute_cc_by_category(session, year, month)
gran_total = total_fixed_expenses + uncovered_actual
net_balance = total_income - gran_total
return {
"year": year,
"month": month,
"projected_income": total_income,
"projected_fixed_expenses": total_fixed_expenses,
"actual_credit_card": actual_credit_card,
"actual_cash": actual_cash,
"actual_transfers": actual_transfers,
"uncovered_actual": uncovered_actual,
"gran_total_egresos": gran_total,
"net_balance": net_balance,
"income_items": income_items,
"expense_items": expense_items,
"actuals_by_source": list(actuals_by_source.values()),
"cc_by_category": cc_by_category,
}
def _get_december_cumulative(session: Session, year: int) -> float:
"""Get the cumulative balance for December of a given year."""
# Check for an override first
override = session.exec(
select(BalanceOverride).where(
BalanceOverride.year == year, BalanceOverride.month == 12
)
).first()
if override:
return override.override_balance
# Compute the full year to get December's cumulative
overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year)
).all()
override_map = {o.month: o.override_balance for o in overrides}
cumulative = 0.0
if year > FRESH_START_YEAR:
cumulative = _get_december_cumulative(session, year - 1)
for m in range(1, 13):
if year == FRESH_START_YEAR and m < FRESH_START_MONTH:
continue
data = compute_monthly_projection(session, year, m)
cumulative += data["net_balance"]
if m in override_map:
cumulative = override_map[m]
return cumulative
def compute_yearly_projection_with_cumulative(
session: Session, year: int
) -> list[dict]:
"""Compute all 12 months with cumulative balance tracking."""
overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year)
).all()
override_map = {o.month: o.override_balance for o in overrides}
# Determine January carryover
if year <= FRESH_START_YEAR:
carryover = 0.0
else:
carryover = _get_december_cumulative(session, year - 1)
months = []
for m in range(1, 13):
data = compute_monthly_projection(session, year, m)
is_before_fresh_start = (
year == FRESH_START_YEAR and m < FRESH_START_MONTH
)
if is_before_fresh_start:
data["carryover_balance"] = 0.0
data["cumulative_balance"] = 0.0
data["balance_overridden"] = False
else:
data["carryover_balance"] = carryover
cumulative = carryover + data["net_balance"]
if m in override_map:
cumulative = override_map[m]
data["balance_overridden"] = True
else:
data["balance_overridden"] = False
data["cumulative_balance"] = cumulative
carryover = cumulative
months.append(data)
return months

View File

@@ -1,18 +1,45 @@
import asyncio
import logging
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
import httpx
from sqlalchemy import case
from sqlmodel import Session, col, select
from app.config import settings
from app.db import engine
from app.models.models import ExchangeRate
logger = logging.getLogger(__name__)
# Scheduled refresh interval — 4x/day
REFRESH_INTERVAL_SECONDS = 6 * 3600
# BCCR indicators: 317 = buy, 318 = sell
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
# Fallback APIs (no API key required, all support CRC)
EXCHANGERATE_API_URL = "https://open.er-api.com/v6/latest/USD"
CURRENCY_API_URL = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json"
CURRENCY_API_FALLBACK_URL = "https://latest.currency-api.pages.dev/v1/currencies/usd.json"
FLOATRATES_URL = "https://www.floatrates.com/daily/usd.json"
# Typical buy/sell spread for USD/CRC (~0.5% each side of mid-market)
_SPREAD = 0.005
_cache: dict[str, tuple[ExchangeRate, datetime]] = {}
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
CACHE_TTL = timedelta(hours=1)
# Generic X/CRC mid-market rate cache (by currency code)
_xcrc_cache: dict[str, tuple[float, datetime]] = {}
_last_known_xcrc: dict[str, float] = {}
# CoinGecko ids for supported crypto codes
_COINGECKO_IDS = {"BTC": "bitcoin", "XMR": "monero"}
COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price"
def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
"""Fetch a single indicator from BCCR API."""
@@ -29,10 +56,7 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
resp = httpx.get(BCCR_URL, params=params, timeout=10)
resp.raise_for_status()
# Parse XML response
root = ET.fromstring(resp.text)
# The value is in INGC011_DES_DATOS > NUM_VALOR
ns = {"": "http://ws.sdde.bccr.fi.cr"}
for datos in root.iter():
if datos.tag.endswith("NUM_VALOR"):
return float(datos.text.strip().replace(",", "."))
@@ -41,14 +65,92 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
return None
def _fetch_bccr() -> tuple[float, float] | None:
"""Try BCCR official API. Returns (buy, sell) or None."""
today = datetime.now().strftime("%d/%m/%Y")
buy = _fetch_bccr_rate(317, today)
sell = _fetch_bccr_rate(318, today)
if buy is not None and sell is not None:
return (buy, sell)
return None
def _mid_to_buy_sell(mid: float) -> tuple[float, float]:
"""Convert a mid-market rate to approximate buy/sell with a spread."""
return (mid * (1 - _SPREAD), mid * (1 + _SPREAD))
def _fetch_exchangerate_api() -> tuple[float, float] | None:
"""Try ExchangeRate-API (open.er-api.com). No key required."""
try:
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("result") == "success":
crc = data["rates"].get("CRC")
if crc:
return _mid_to_buy_sell(float(crc))
except Exception:
pass
return None
def _fetch_currency_api() -> tuple[float, float] | None:
"""Try fawazahmed0/currency-api (CDN-hosted). No key required."""
for url in (CURRENCY_API_URL, CURRENCY_API_FALLBACK_URL):
try:
resp = httpx.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
crc = data.get("usd", {}).get("crc")
if crc:
return _mid_to_buy_sell(float(crc))
except Exception:
continue
return None
def _fetch_floatrates() -> tuple[float, float] | None:
"""Try FloatRates. No key required."""
try:
resp = httpx.get(FLOATRATES_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
crc_data = data.get("crc")
if crc_data and "rate" in crc_data:
return _mid_to_buy_sell(float(crc_data["rate"]))
except Exception:
pass
return None
def _fetch_rate_from_apis() -> tuple[float, float] | None:
"""Try all sources in order: BCCR → ExchangeRate-API → currency-api → FloatRates."""
for fetcher in (_fetch_bccr, _fetch_exchangerate_api, _fetch_currency_api, _fetch_floatrates):
result = fetcher()
if result is not None:
return result
return None
def _remember(rate: ExchangeRate) -> ExchangeRate:
"""Store rate in both TTL cache and permanent last-known holder."""
global _last_known
_cache["current"] = (rate, datetime.utcnow())
_last_known = rate
return rate
def get_current_rate(session: Session) -> ExchangeRate | None:
"""Get current USD/CRC rate. Uses in-memory cache + DB fallback."""
# Check memory cache
"""Get current USD/CRC rate. Never returns None once a rate has been fetched."""
global _last_known
# 1. Fresh memory cache (< 1 hour)
cached = _cache.get("current")
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
return cached[0]
# Check DB for recent rate
# 2. Fresh DB rate (< 1 hour)
one_hour_ago = datetime.utcnow() - CACHE_TTL
db_rate = session.exec(
select(ExchangeRate)
@@ -56,31 +158,212 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
.order_by(col(ExchangeRate.fetched_at).desc())
).first()
if db_rate:
_cache["current"] = (db_rate, datetime.utcnow())
return db_rate
return _remember(db_rate)
# Fetch from BCCR
today = datetime.now().strftime("%d/%m/%Y")
buy = _fetch_bccr_rate(317, today)
sell = _fetch_bccr_rate(318, today)
# 3. Try all API sources
result = _fetch_rate_from_apis()
if result is not None:
buy, sell = result
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate)
session.commit()
session.refresh(rate)
return _remember(rate)
if buy is None or sell is None:
# Fallback: return most recent DB rate regardless of age
fallback = session.exec(
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
).first()
return fallback
# 4. Stale DB rate (any age)
fallback = session.exec(
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
).first()
if fallback:
return _remember(fallback)
rate = ExchangeRate(
date=datetime.utcnow(),
buy_rate=buy,
sell_rate=sell,
)
session.add(rate)
session.commit()
session.refresh(rate)
_cache["current"] = (rate, datetime.utcnow())
return rate
# 5. Last known in-memory rate (survives even if DB is empty)
if _last_known:
return _last_known
return None
def _fetch_fiat_crc_mid(code: str) -> float | None:
"""Derive {code}/CRC mid-market rate from ExchangeRate-API (USD-based).
X/CRC = CRC_per_USD / X_per_USD
"""
try:
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("result") == "success":
crc = data["rates"].get("CRC")
x = data["rates"].get(code)
if crc and x:
return float(crc) / float(x)
except Exception:
pass
return None
def _fetch_crypto_crc(code: str) -> float | None:
"""Fetch {code}/CRC spot from CoinGecko."""
coin_id = _COINGECKO_IDS.get(code)
if not coin_id:
return None
try:
resp = httpx.get(
COINGECKO_URL,
params={"ids": coin_id, "vs_currencies": "crc"},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
price = data.get(coin_id, {}).get("crc")
if price:
return float(price)
except Exception:
pass
return None
def get_crc_rate(code: str) -> float | None:
"""Get current {code}→CRC rate (cached 1 hour). Fiat via ExchangeRate-API, crypto via CoinGecko."""
if code == "CRC":
return 1.0
cached = _xcrc_cache.get(code)
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
return cached[0]
if code in _COINGECKO_IDS:
rate = _fetch_crypto_crc(code)
else:
rate = _fetch_fiat_crc_mid(code)
if rate is not None:
_xcrc_cache[code] = (rate, datetime.utcnow())
_last_known_xcrc[code] = rate
return rate
return _last_known_xcrc.get(code)
def get_crc_multipliers(session: Session) -> dict[str, float]:
"""Return {currency_code: CRC_multiplier} for every supported currency."""
from app.models.models import Currency
multipliers: dict[str, float] = {"CRC": 1.0}
usd_rate = get_current_rate(session)
if usd_rate:
multipliers["USD"] = usd_rate.sell_rate
for code in (c.value for c in Currency):
if code in multipliers:
continue
rate = get_crc_rate(code)
if rate is not None:
multipliers[code] = rate
return multipliers
def get_converted_amount_expr(session: Session):
"""Return a SQLAlchemy expression converting Transaction.amount to CRC.
Builds a CASE that multiplies by the per-currency CRC rate; CRC passes through.
Missing rates fall back to 1.0 (treat as CRC) rather than 0.0 so a transient
API outage does not silently zero out foreign-currency totals.
"""
from app.models.models import Transaction
multipliers = get_crc_multipliers(session)
whens = [
(Transaction.currency == code, Transaction.amount * mult)
for code, mult in multipliers.items()
if code != "CRC"
]
if not whens:
return Transaction.amount
return case(*whens, else_=Transaction.amount)
def _refresh_usd_rate() -> bool:
"""Force-fetch USD/CRC from APIs and persist to DB. Returns True on success."""
fetched = _fetch_rate_from_apis()
if fetched is None:
return False
buy, sell = fetched
with Session(engine) as session:
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate)
session.commit()
session.refresh(rate)
_remember(rate)
return True
def _refresh_other_rate(code: str) -> bool:
"""Force-fetch {code}/CRC and update in-memory cache. Returns True on success."""
if code in _COINGECKO_IDS:
rate = _fetch_crypto_crc(code)
else:
rate = _fetch_fiat_crc_mid(code)
if rate is None:
return False
_xcrc_cache[code] = (rate, datetime.utcnow())
_last_known_xcrc[code] = rate
return True
def refresh_all_rates() -> dict[str, bool]:
"""Force-refresh every supported currency.
Each currency is refreshed independently — one failure does not affect others.
On success the DB (for USD) and in-memory caches are updated. On failure the
previous value is retained via `_last_known_*` / stale-DB fallback, so callers
always see the most recent working rate.
"""
from app.models.models import Currency
results: dict[str, bool] = {}
try:
results["USD"] = _refresh_usd_rate()
except Exception:
logger.exception("USD rate refresh failed")
results["USD"] = False
for currency in Currency:
code = currency.value
if code in ("CRC", "USD"):
continue
try:
results[code] = _refresh_other_rate(code)
except Exception:
logger.exception("%s rate refresh failed", code)
results[code] = False
return results
async def refresh_rates_periodically(
interval_seconds: int = REFRESH_INTERVAL_SECONDS,
) -> None:
"""Background loop that refreshes all currency rates every `interval_seconds`.
Never raises — failures are logged and the last-known rates are retained.
Runs one refresh immediately on startup, then sleeps on the fixed interval.
"""
while True:
try:
report = await asyncio.to_thread(refresh_all_rates)
ok = sorted(k for k, v in report.items() if v)
failed = sorted(k for k, v in report.items() if not v)
logger.info(
"Exchange rate refresh complete: ok=%s failed=%s", ok, failed
)
except Exception:
logger.exception("Exchange rate refresh loop crashed")
await asyncio.sleep(interval_seconds)
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:

View File

@@ -0,0 +1,291 @@
"""
Extract structured data from Municipalidad de Belén receipts using pdftotext + regex.
"""
import re
import subprocess
import tempfile
from dataclasses import dataclass, field
def _parse_amount(s: str) -> float:
"""Parse a Costa Rican formatted number: '1,875.00' → 1875.00"""
return float(s.replace(",", ""))
def _parse_date(s: str) -> str:
"""Convert dd/mm/yyyy → YYYY-MM-DD"""
d, m, y = s.strip().split("/")
return f"{y}-{m.zfill(2)}-{d.zfill(2)}"
def _parse_period(s: str) -> str:
"""Convert mm/yyyy → YYYY-MM"""
m, y = s.strip().split("/")
return f"{y}-{m.zfill(2)}"
@dataclass
class Charge:
detail: str
interests: float
iva: float
amount: float
@dataclass
class WaterMeter:
period: str
meter_id: str
reading_previous: int
reading_current: int
consumption_m3: int
agua_potable: float
serv_ambientales: float
alcant_sanitario: float
iva: float
@dataclass
class HistoricalConsumption:
meter_id: str
period: str
consumption_m3: int
@dataclass
class MunicipalReceiptData:
receipt_date: str # YYYY-MM-DD
due_date: str # YYYY-MM-DD
holder_name: str
holder_cedula: str
holder_address: str
account: str
finca: str
charges: list[Charge] = field(default_factory=list)
subtotal: float = 0.0
interests: float = 0.0
iva: float = 0.0
total: float = 0.0
water_meters: list[WaterMeter] = field(default_factory=list)
historical_consumption: list[HistoricalConsumption] = field(default_factory=list)
def _pdf_to_text(pdf_bytes: bytes) -> str:
"""Convert PDF bytes to text using pdftotext -layout."""
with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
tmp.write(pdf_bytes)
tmp.flush()
result = subprocess.run(
["pdftotext", "-layout", tmp.name, "-"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise ValueError(f"pdftotext failed: {result.stderr}")
return result.stdout
# Regex patterns
RE_FECHA = re.compile(r"Fecha:\s*(\d{2}/\d{2}/\d{4})")
RE_VENCIMIENTO = re.compile(r"Fecha de vencimiento:\s*(\d{2}/\d{2}/\d{4})")
RE_NOMBRE = re.compile(r"Nombre:\s*(.+)")
RE_CEDULA = re.compile(r"Cédula:\s*(\d+)")
RE_DIRECCION = re.compile(r"Dirección:\s*(.+)")
# Charge line: DETAIL_TEXT account finca interests iva periodo_actual periodo_anterior
RE_CHARGE = re.compile(
r"^([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s.]+?)\s+"
r"(\d{4})\s+"
r"(\d{6}---\d{3})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s*$"
)
RE_SUBTOTAL = re.compile(r"Sub-Total:\s+([\d,]+\.\d{2})")
RE_INTERESES = re.compile(r"Intereses:\s+([\d,]+\.\d{2})")
RE_IVA = re.compile(r"IVA\s+([\d,]+\.\d{2})")
RE_TOTAL = re.compile(r"Total:\s+([\d,]+\.\d{2})")
# Water meter line: period meter_id lec_ant lec_act consumo agua_potable serv_amb alcant iva
RE_WATER_METER = re.compile(
r"(\d{2}/\d{4})\s+"
r"(\d{4})\s+"
r"(\d{5})\s+"
r"(\d{5})\s+"
r"(\d+)\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})"
)
# Historical consumption: meter_id period consumption
RE_HISTORICAL = re.compile(
r"(\d{4})\s+(\d{2}/\d{4})\s+(\d{5})"
)
def extract_municipal_receipt(
pdf_bytes: bytes, filename: str
) -> dict:
"""Extract structured data from a municipal receipt PDF.
Returns a dict matching the target JSON schema.
"""
text = _pdf_to_text(pdf_bytes)
if "RECIBO MUNICIPAL" not in text:
raise ValueError(f"{filename}: Not a municipal receipt")
data = MunicipalReceiptData(
receipt_date="",
due_date="",
holder_name="",
holder_cedula="",
holder_address="",
account="",
finca="",
)
# --- Header fields ---
m = RE_FECHA.search(text)
if m:
data.receipt_date = _parse_date(m.group(1))
m = RE_VENCIMIENTO.search(text)
if m:
data.due_date = _parse_date(m.group(1))
m = RE_NOMBRE.search(text)
if m:
data.holder_name = m.group(1).strip()
m = RE_CEDULA.search(text)
if m:
data.holder_cedula = m.group(1).strip()
m = RE_DIRECCION.search(text)
if m:
data.holder_address = m.group(1).strip().rstrip(".")
# --- Charges ---
for line in text.splitlines():
m = RE_CHARGE.match(line.strip())
if m:
detail = m.group(1).strip()
data.account = m.group(2)
data.finca = m.group(3)
interests = _parse_amount(m.group(4))
iva = _parse_amount(m.group(5))
amount = _parse_amount(m.group(6))
data.charges.append(Charge(detail=detail, interests=interests, iva=iva, amount=amount))
# --- Totals ---
m = RE_SUBTOTAL.search(text)
if m:
data.subtotal = _parse_amount(m.group(1))
m = RE_INTERESES.search(text)
if m:
data.interests = _parse_amount(m.group(1))
m = RE_IVA.search(text)
if m:
data.iva = _parse_amount(m.group(1))
m = RE_TOTAL.search(text)
if m:
data.total = _parse_amount(m.group(1))
# --- Water meters ---
for m in RE_WATER_METER.finditer(text):
data.water_meters.append(
WaterMeter(
period=_parse_period(m.group(1)),
meter_id=m.group(2),
reading_previous=int(m.group(3)),
reading_current=int(m.group(4)),
consumption_m3=int(m.group(5)),
agua_potable=_parse_amount(m.group(6)),
serv_ambientales=_parse_amount(m.group(7)),
alcant_sanitario=_parse_amount(m.group(8)),
iva=_parse_amount(m.group(9)),
)
)
# --- Historical consumption ---
# Only parse lines AFTER "DETALLE DE CONSUMO MESES ANTERIORES"
hist_section = text.split("DETALLE DE CONSUMO MESES ANTERIORES")
if len(hist_section) > 1:
for m in RE_HISTORICAL.finditer(hist_section[1]):
data.historical_consumption.append(
HistoricalConsumption(
meter_id=m.group(1),
period=_parse_period(m.group(2)),
consumption_m3=int(m.group(3)),
)
)
# --- Validation ---
if not data.receipt_date:
raise ValueError(f"{filename}: Could not parse receipt date")
if not data.charges:
raise ValueError(f"{filename}: No charges found")
# --- Build output dict ---
return {
"receipt": {
"type": "RECIBO MUNICIPAL",
"issuer": {
"name": "MUNICIPALIDAD DE BELÉN",
"phone": "(506) 2587-0000",
"fax": "(506) 2293-3667",
"website": "www.belen.go.cr",
},
"date": data.receipt_date,
"due_date": data.due_date,
"account_holder": {
"name": data.holder_name,
"cedula": data.holder_cedula,
"address": data.holder_address,
},
"account": data.account,
"finca": data.finca,
},
"charges": [
{"detail": c.detail, "interests": c.interests, "iva": c.iva, "amount": c.amount}
for c in data.charges
],
"totals": {
"subtotal": data.subtotal,
"interests": data.interests,
"iva": data.iva,
"total": data.total,
},
"water_meters": [
{
"period": wm.period,
"meter_id": wm.meter_id,
"reading_previous": wm.reading_previous,
"reading_current": wm.reading_current,
"consumption_m3": wm.consumption_m3,
"agua_potable": wm.agua_potable,
"serv_ambientales": wm.serv_ambientales,
"alcant_sanitario": wm.alcant_sanitario,
"iva": wm.iva,
}
for wm in data.water_meters
],
"historical_consumption": [
{
"meter_id": hc.meter_id,
"period": hc.period,
"consumption_m3": hc.consumption_m3,
}
for hc in data.historical_consumption
],
}

View File

@@ -0,0 +1,225 @@
"""Parse BAC San José Pensiones PDF statements into structured fund snapshots."""
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import date
@dataclass
class FundSnapshot:
fund: str # "ROP", "FCL", or "VOL"
contract_number: str
period_start: date
period_end: date
saldo_anterior: float
aportes: float
rendimientos: float
retiros: float
traslados: float
comision: float
correccion: float
bonificacion: float
saldo_final: float
def _find_pdftotext() -> str:
"""Find pdftotext binary, checking common install paths."""
import os
cmd = shutil.which("pdftotext")
if cmd:
return cmd
for path in ["/opt/homebrew/bin/pdftotext", "/usr/bin/pdftotext", "/usr/local/bin/pdftotext"]:
if os.path.isfile(path):
return path
raise FileNotFoundError("pdftotext not found — install poppler-utils")
def extract_text(pdf_bytes: bytes) -> str:
pdftotext_bin = _find_pdftotext()
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
f.write(pdf_bytes)
f.flush()
result = subprocess.run(
[pdftotext_bin, "-layout", f.name, "-"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise ValueError(f"pdftotext failed: {result.stderr.strip()}")
return result.stdout
def detect_type(text: str) -> str:
"""Return 'VOL', 'ROP_FCL', or 'UNKNOWN'."""
if any(kw in text for kw in ("MARCA DE TARJETA", "ESTADO DE CUENTA", "PAGO MÍNIMO")):
return "CREDIT_CARD"
if "FONDO C VOLUNTARIO" in text:
return "VOL"
if "RÉGIMEN OBLIGATORIO" in text or ("ROP" in text and "FCL" in text):
return "ROP_FCL"
return "UNKNOWN"
def _parse_amount(s: str) -> float:
"""Parse '17,819,176.79' or '-12,693.13' into float."""
cleaned = s.replace(",", "")
return float(cleaned)
def _find_amounts(line: str) -> list[float]:
"""Extract all ¢-prefixed amounts from a line."""
return [_parse_amount(m) for m in re.findall(r"¢\s*(-?[\d,]+\.\d{2})", line)]
def _parse_period(text: str) -> tuple[date, date]:
m = re.search(r"DEL\s+(\d{2}/\d{2}/\d{4})\s+AL\s+(\d{2}/\d{2}/\d{4})", text)
if not m:
raise ValueError("Could not find period dates (DEL ... AL ...)")
start = date(int(m.group(1)[6:]), int(m.group(1)[3:5]), int(m.group(1)[:2]))
end = date(int(m.group(2)[6:]), int(m.group(2)[3:5]), int(m.group(2)[:2]))
return start, end
def _extract_summary_value(text: str, label: str) -> list[float]:
"""Find a summary line by label and return all ¢ amounts on that line."""
pattern = re.compile(re.escape(label) + r".*", re.IGNORECASE)
for line in text.split("\n"):
if pattern.search(line):
amounts = _find_amounts(line)
if amounts:
return amounts
return []
_SUMMARY_FIELDS = [
("Saldo Anterior", "saldo_anterior"),
("Aportes", "aportes"),
("Rendimientos", "rendimientos"),
("Retiros", "retiros"),
("Traslados", "traslados"),
("Comisión de Administración", "comision"),
("Corrección de Imputaciones", "correccion"),
("Bonificación", "bonificacion"),
]
def _find_final_balance(text: str, after_label: str = "Bonificación") -> list[float]:
"""Find the standalone balance line after the last summary field.
After Bonificación (or Corrección for ROP+FCL), there's a line with just
the final balance amount(s) and no label.
"""
lines = text.split("\n")
found_label = False
for line in lines:
if after_label in line:
found_label = True
continue
if found_label:
amounts = _find_amounts(line)
if amounts:
return amounts
return []
def parse_vol(text: str) -> list[FundSnapshot]:
period_start, period_end = _parse_period(text)
# Contract number
m = re.search(r"\s*Contrato:\s*(\S+)", text)
contract = m.group(1) if m else ""
data: dict[str, float] = {}
for label, field in _SUMMARY_FIELDS:
amounts = _extract_summary_value(text, label)
data[field] = amounts[0] if amounts else 0.0
finals = _find_final_balance(text, "Bonificación")
if not finals:
# Fallback: look after Corrección
finals = _find_final_balance(text, "Corrección de Imputaciones")
saldo_final = finals[0] if finals else 0.0
return [
FundSnapshot(
fund="VOL",
contract_number=contract,
period_start=period_start,
period_end=period_end,
saldo_final=saldo_final,
**data,
)
]
def parse_rop_fcl(text: str) -> list[FundSnapshot]:
period_start, period_end = _parse_period(text)
# Contract numbers
m_rop = re.search(r"\s*Contrato\s*ROP:\s*(\S+)", text)
m_fcl = re.search(r"\s*Contrato\s*FCL:\s*(\S+)", text)
contract_rop = m_rop.group(1) if m_rop else ""
contract_fcl = m_fcl.group(1) if m_fcl else ""
rop_data: dict[str, float] = {}
fcl_data: dict[str, float] = {}
for label, field in _SUMMARY_FIELDS:
amounts = _extract_summary_value(text, label)
if len(amounts) >= 2:
rop_data[field] = amounts[0]
fcl_data[field] = amounts[1]
elif len(amounts) == 1:
rop_data[field] = amounts[0]
fcl_data[field] = 0.0
else:
rop_data[field] = 0.0
fcl_data[field] = 0.0
# Final balance line (after Corrección since ROP+FCL has no Bonificación)
finals = _find_final_balance(text, "Corrección de Imputaciones")
rop_final = finals[0] if len(finals) >= 1 else 0.0
fcl_final = finals[1] if len(finals) >= 2 else 0.0
return [
FundSnapshot(
fund="ROP",
contract_number=contract_rop,
period_start=period_start,
period_end=period_end,
saldo_final=rop_final,
**rop_data,
),
FundSnapshot(
fund="FCL",
contract_number=contract_fcl,
period_start=period_start,
period_end=period_end,
saldo_final=fcl_final,
**fcl_data,
),
]
def parse_pension_pdf(pdf_bytes: bytes, filename: str = "") -> list[FundSnapshot]:
"""Parse a pension PDF and return fund snapshots.
Raises ValueError for credit card statements or unrecognized formats.
"""
text = extract_text(pdf_bytes)
doc_type = detect_type(text)
if doc_type == "CREDIT_CARD":
raise ValueError(f"'{filename}' is a credit card statement, not a pension extract")
if doc_type == "UNKNOWN":
raise ValueError(f"'{filename}' is not a recognized BAC pension statement")
if doc_type == "VOL":
return parse_vol(text)
else:
return parse_rop_fcl(text)

View File

@@ -0,0 +1,62 @@
from sqlmodel import Session, select
from app.models.models import (
Account,
AccountType,
Bank,
SavingsAccrual,
Transaction,
)
MEMP_MONTHLY = 200000.0
MPAT_MONTHLY = 200000.0
def _get_savings_account(session: Session, bank: Bank) -> Account | None:
return session.exec(
select(Account).where(
Account.account_type == AccountType.SAVINGS,
Account.bank == bank,
)
).first()
def maybe_apply_monthly_savings(session: Session, tx: Transaction) -> SavingsAccrual | None:
"""Apply monthly savings contribution if this is the first salary of the month.
Idempotent: if a SavingsAccrual row already exists for (year, month), do nothing.
Bumps MEMP and MPAT savings account balances and records the accrual.
"""
year = tx.date.year
month = tx.date.month
existing = session.exec(
select(SavingsAccrual).where(
SavingsAccrual.year == year,
SavingsAccrual.month == month,
)
).first()
if existing:
return None
memp = _get_savings_account(session, Bank.MEMP)
mpat = _get_savings_account(session, Bank.MPAT)
if memp is None or mpat is None:
return None
memp.balance += MEMP_MONTHLY
mpat.balance += MPAT_MONTHLY
session.add(memp)
session.add(mpat)
accrual = SavingsAccrual(
year=year,
month=month,
memp_amount=MEMP_MONTHLY,
mpat_amount=MPAT_MONTHLY,
trigger_transaction_id=tx.id,
)
session.add(accrual)
session.commit()
session.refresh(accrual)
return accrual

View File

@@ -8,3 +8,9 @@ python-multipart
python-dotenv
alembic
httpx
pywebpush
py-vapid
python-dateutil
agent-framework==1.2.1
agent-framework-ag-ui==1.0.0b260428
agent-framework-openai==1.2.1

View File

@@ -28,6 +28,10 @@ services:
SECRET_KEY: ${SECRET_KEY}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
expose:
- "8000"
networks:
@@ -45,22 +49,32 @@ services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
dockerfile: Dockerfile
target: runner
args:
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
container_name: wealthysmart-frontend-prod
restart: unless-stopped
environment:
NODE_ENV: production
BACKEND_URL: http://wealthysmart-backend-prod:8000
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui
JWT_SECRET: ${SECRET_KEY}
COOKIE_DOMAIN: wealth.cescalante.dev
COOKIE_SECURE: "true"
VIRTUAL_HOST: wealth.cescalante.dev
VIRTUAL_PORT: "3000"
LETSENCRYPT_HOST: wealth.cescalante.dev
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
expose:
- "80"
- "3000"
networks:
- wealthysmart-network
- nginx-prod-network
depends_on:
- backend
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -23,6 +23,10 @@ services:
container_name: wealthysmart-backend-dev
environment:
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
ports:
- "8001:8000"
volumes:
@@ -30,17 +34,52 @@ services:
depends_on:
db:
condition: service_healthy
develop:
watch:
- path: ./backend/app
action: sync
target: /app/app
- path: ./backend/requirements.txt
action: rebuild
- path: ./backend/Dockerfile
action: rebuild
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
container_name: wealthysmart-frontend-dev
ports:
- "5175:5173"
volumes:
- ./frontend:/app
- /app/node_modules
- "5175:3000"
environment:
NODE_ENV: development
AGENT_URL: http://backend:8000/api/v1/agent/agui
depends_on:
- backend
develop:
watch:
- path: ./frontend/src
action: sync
target: /app/src
- path: ./frontend/public
action: sync
target: /app/public
- path: ./frontend/server.ts
action: sync
target: /app/server.ts
- path: ./frontend/vite.config.ts
action: sync+restart
target: /app/vite.config.ts
- path: ./frontend/tsconfig.json
action: sync+restart
target: /app/tsconfig.json
- path: ./frontend/package.json
action: rebuild
- path: ./frontend/pnpm-lock.yaml
action: rebuild
- path: ./frontend/Dockerfile
action: rebuild
volumes:
postgres_data:

View File

@@ -0,0 +1,97 @@
{
"name": "WealthySmart: BAC Pensions Statements parser",
"nodes": [
{
"parameters": {
"pollTimes": {
"item": [
{
"hour": 0
}
]
},
"simple": false,
"filters": {
"q": "Estado de cuenta Pensiones BAC CREDOMATIC -{tarjeta} "
},
"options": {
"downloadAttachments": true
}
},
"type": "n8n-nodes-base.gmailTrigger",
"typeVersion": 1.3,
"position": [
0,
0
],
"id": "bc94143f-9980-4b94-9cd7-8e206dc1232e",
"name": "Gmail Trigger",
"credentials": {
"gmailOAuth2": {
"id": "LkAGMVgAqsiCkMFX",
"name": "Gmail account"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://wealth.cescalante.dev/api/v1/pensions/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "files",
"inputDataFieldName": "attachment_0"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
208,
0
],
"id": "b73c0182-8d56-48b3-b37e-c4dd9361a062",
"name": "HTTP Request",
"credentials": {
"httpBearerAuth": {
"id": "d4Le4M5bCuhEb2NN",
"name": "BAC Parser - wealth.cescalante.dev"
}
}
}
],
"pinData": {},
"connections": {
"Gmail Trigger": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "f0bbb681-1cf3-49f4-b06e-de0218b7ff3e",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "f446bf52a953467f756f2c188ff6b09473528d6316ecb3b9568cdbfbaa3258f3"
},
"id": "e88c3UhBeo9WCbcy",
"tags": []
}

View File

@@ -0,0 +1,53 @@
# A2UI Theming Findings
Captured 2026-04-28 while researching how to beautify the table the agent renders via `render_a2ui` without overriding component internals.
## TL;DR
- A2UI v0.9 docs prescribe theming through a small set of **documented top-level CSS variables** declared on `:where(:root)` (zero specificity, so host CSS wins without `!important`).
- Default theme uses `light-dark()` so tokens self-toggle. Recommended dark-mode coupling: add the documented `a2ui-dark` class on `<html>` whenever Tailwind's `.dark` is on; that flips `color-scheme: dark` and resolves every `light-dark()` to its dark branch.
- **In our app, this approach has near-zero visible effect.** We render through `@copilotkit/a2ui-renderer` (React variant), not `@a2ui/lit`. Inspecting the compiled React renderer (`node_modules/.pnpm/@copilotkit+a2ui-renderer@1.56.4/.../catalog/basic/components/*.mjs`), every component uses **hardcoded inline styles**; the only CSS variable consumed across the whole basic catalog is `--a2ui-primary-color`.
- This matches open issues `google/A2UI#977` and `#1285` ("React renderer parity is incomplete").
## Documented token surface (Lit / Angular)
From `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`:
- Color palette: `--a2ui-color-background`, `--a2ui-color-on-background`, `--a2ui-color-surface`, `--a2ui-color-on-surface`, `--a2ui-color-primary`, `--a2ui-color-primary-light/-dark/-hover`, `--a2ui-color-on-primary`, `--a2ui-color-secondary`, `--a2ui-color-secondary-light/-dark/-hover`, `--a2ui-color-on-secondary`, `--a2ui-color-input`, `--a2ui-color-on-input`, `--a2ui-color-border`.
- Borders: `--a2ui-border`, `--a2ui-border-width`, `--a2ui-border-radius`.
- Typography: `--a2ui-font-family-title`, `--a2ui-font-family-monospace`, `--a2ui-font-size`, `--a2ui-font-scale`, `--a2ui-font-size-xs..2xl`, `--a2ui-line-height-headings`, `--a2ui-line-height-body`.
- Spacing: `--a2ui-grid-base`, `--a2ui-spacing-xs..xl`.
Per-component tokens like `--a2ui-card-background` exist but the renderer README and the official theming guide explicitly frame them as catalog-internal, not stable spec. Avoid relying on them.
## Dark-mode coupling
A2UI default uses `light-dark(...)` and supports forced modes via `a2ui-light` / `a2ui-dark` classes (see the `:where(.a2ui-dark)` block in `default.ts`). Best practice for a host that uses Tailwind `.dark` on `<html>`: toggle the matching `a2ui-dark` class in lockstep — one effect-driven `useEffect`, or add it directly to the root if the app is dark-only. No need to redeclare every token under `.dark`; the default rules already pick up the right branch through `light-dark()`.
## Footguns in v0.9
- The agent-supplied `theme` payload (e.g. `createSurface.theme.primaryColor`) is **not yet wired** in the basic catalog. Tracked in `google/A2UI#979` (Lit) and `#977` (React). Don't expect agent-side theming to recolor anything in v0.9; only the host CSS does.
- `hintedStyles` (h1h6 mappings) require all keys when overridden — see `google/A2UI#602`.
- Tracking issues for the broader theming surface: `google/A2UI#1083`, `#1118`.
## Why this won't move the table in our app
`@copilotkit/a2ui-renderer@1.56.4` (React) is what `<CopilotKit a2ui={{}}>` plugs in. Its components apply colors and borders as inline `style` props (`backgroundColor: "#fff"`, `border: "1px solid #ccc"`, etc.). Inline styles can only be overridden with `!important` in author CSS, which contradicts the "don't take its freedom" goal. So the documented `--a2ui-*` token contract is currently a **Lit-only surface** for this catalog.
## Practical options for this codebase
1. **Quick win**: set `--a2ui-primary-color: var(--primary)` on `:root`. Real but small effect (primary buttons, links).
2. **Prompt-side polish**: instruct the agent to compose richer A2UI structures — wrap the table in a `Card`, use `usageHint: "h3"` for the section title, insert a `Divider`, group columns with `Row` + `Column` weights — so the renderer's existing inline styles produce a cleaner result. No code change.
3. **Switch renderer**: replace `@copilotkit/a2ui-renderer` with `@a2ui/lit` (or wait for `@a2ui/react` parity). Then every documented `--a2ui-*` token actually applies. Non-trivial migration.
4. **Custom catalog overrides**: register replacement React components for `Row`/`List`/`Card` via `createA2UICatalog`; full control, most invasive, accepts ownership of those components forever.
## Source links
- `tech_docs/A2UI/docs/guides/theming.md`
- `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`
- `tech_docs/A2UI/renderers/lit/README.md` §"CSS-based Basic Catalog Theming"
- `https://github.com/google/A2UI/issues/977` (React renderer theming gap)
- `https://github.com/google/A2UI/issues/1285` (React renderer parity)
- `https://github.com/google/A2UI/issues/1118` (theming surface roadmap)
- `https://github.com/google/A2UI/issues/979` (Lit `theme` payload not wired)
- `https://github.com/google/A2UI/pull/1079` (v0.9 CSS-variable rollout)

15
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
.next
out
build
coverage
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.env*
.vercel
*.tsbuildinfo
next-env.d.ts

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,7 +1,39 @@
FROM node:20-slim
RUN corepack enable && corepack prepare pnpm@latest --activate
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
# Dev: Vite HMR on port 3000 + Hono CK server on port 3001
FROM node:22-alpine AS dev
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["pnpm", "run", "dev", "--", "--host"]
ENV NODE_ENV=development
EXPOSE 3000
CMD ["sh", "-c", "corepack enable && pnpm dev"]
# Build Vite SPA
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Cap node heap so a build on a small VPS can't OOM-kill neighbours.
ENV NODE_OPTIONS=--max-old-space-size=1536
RUN corepack enable && pnpm build
# Production: Hono serves dist/ + /api/copilotkit on port 3000
FROM node:22-alpine AS runner
RUN apk add --no-cache libc6-compat
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY server.ts package.json ./
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["./node_modules/.bin/tsx", "server.ts"]

View File

@@ -1,14 +0,0 @@
# Stage 1: Build
FROM node:20-slim AS build
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

25
frontend/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default-translucent",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -12,8 +12,6 @@
<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.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>WealthySmart</title>
</head>
<body>

View File

@@ -1,31 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API to backend (same docker network)
location /api/ {
proxy_pass http://wealthysmart-backend-prod:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# No cache for service worker
location /sw.js {
add_header Cache-Control "no-cache";
}
# Cache immutable assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -1,28 +1,46 @@
{
"name": "wealthysmart-frontend",
"private": true,
"name": "frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"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": "vite preview"
"preview": "tsx server.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.13.6",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0",
"recharts": "^3.8.0"
"@ag-ui/client": "0.0.52",
"@base-ui/react": "^1.4.1",
"@copilotkit/react-core": "1.56.4",
"@copilotkit/react-ui": "1.56.4",
"@copilotkit/runtime": "1.56.4",
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10",
"@hono/node-server": "^1.14.4",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.1.2",
"hono": "^4.12.15",
"lucide-react": "^1.12.0",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-router-dom": "^7.6.0",
"recharts": "^3.8.1",
"rxjs": "^7.8.1",
"tailwind-merge": "^3.5.0",
"tsx": "^4.19.4",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.2.4"
"@tailwindcss/vite": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react-swc": "^3.9.0",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6.3.5"
}
}

8215
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,3 @@
onlyBuiltDependencies: '["@swc/core", "esbuild"]'
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#10b981"/>
<text x="16" y="23" text-anchor="middle" font-size="20" font-weight="bold" fill="#0f172a" font-family="system-ui">W</text>
</svg>

Before

Width:  |  Height:  |  Size: 248 B

View File

@@ -1,27 +0,0 @@
{
"name": "WealthySmart",
"short_name": "WealthySmart",
"description": "Smart personal finance management",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"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,52 +1,4 @@
const CACHE_NAME = 'wealthysmart-v1';
const STATIC_ASSETS = ['/', '/index.html'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Network-first for API calls
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(request).catch(() => caches.match(request)));
return;
}
// Cache-first for static assets
if (url.pathname.startsWith('/assets/')) {
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return res;
}))
);
return;
}
// Network-first for navigation, fallback to cached index.html
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/index.html'))
);
return;
}
// Default: network with cache fallback
event.respondWith(fetch(request).catch(() => caches.match(request)));
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
await self.registration.unregister();
});

416
frontend/server.ts Normal file
View File

@@ -0,0 +1,416 @@
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
import { Observable } from "rxjs";
import { Hono } from "hono";
const AGENT_URL =
process.env.AGENT_URL ?? "http://backend:8000/api/v1/agent/agui";
const BACKEND_URL =
process.env.BACKEND_URL ?? "http://localhost:8001";
const isProd = process.env.NODE_ENV === "production";
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
// ── pairOrphanToolCalls ──────────────────────────────────────────────────────
// CopilotKit's browser-side store sometimes drops the tool-role message
// between turns, producing assistant(toolCalls=[X]) → assistant(text) which
// OpenAI rejects. Inject a synthetic empty tool response for each orphan id.
interface AGUIMessage {
id?: string;
role?: string;
content?: unknown;
toolCalls?: Array<{ id?: string }>;
tool_calls?: Array<{ id?: string }>;
toolCallId?: string;
tool_call_id?: string;
}
function pairOrphanToolCalls(messages: AGUIMessage[]): AGUIMessage[] {
const out: AGUIMessage[] = [];
let pending: string[] = [];
const flush = () => {
for (const callId of pending) {
out.push({ id: `synth-${Math.random().toString(36).slice(2)}`, role: "tool", toolCallId: callId, content: "" });
}
pending = [];
};
for (const msg of messages) {
const role = String(msg?.role ?? "");
if (role === "tool") {
const callId = msg.toolCallId ?? msg.tool_call_id;
if (callId) pending = pending.filter((p) => p !== callId);
out.push(msg);
continue;
}
if (role === "assistant") {
flush();
const toolCalls = msg.toolCalls ?? msg.tool_calls ?? [];
out.push(msg);
for (const tc of toolCalls) {
if (tc?.id) pending.push(String(tc.id));
}
continue;
}
flush();
out.push(msg);
}
flush();
return out;
}
// ── DeduplicateToolCallMiddleware ─────────────────────────────────────────────
// MAF re-emits TOOL_CALL_START for declaration-only calls when they are invoked
// in parallel with other tools, causing verifyEvents to throw an AGUIError.
// This middleware tracks completed tool calls and silently drops any duplicate
// START/ARGS/END events for a call ID that has already been closed.
//
// NOTE: runNextWithState emits { event, messages, state } wrappers; always
// extract `.event` and forward the raw BaseEvent — never the wrapper.
// ── DebugLogMiddleware ──────────────────────────────────────────────────────
// Temporary: logs every event type flowing out to diagnose double-response bug.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class DebugLogMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
const type: string = event?.type ?? "";
const extra = event?.toolCallName ? ` [${event.toolCallName}]`
: event?.activityType ? ` [${event.activityType}]`
: event?.messageId ? ` [msg:${event.messageId.slice(0, 8)}]`
: "";
console.log(`[DBG] ${type}${extra}`);
if (type === EventType.MESSAGES_SNAPSHOT) {
const msgs = (event as any)?.messages ?? [];
console.log(`[DBG] SNAPSHOT msgs: ${msgs.map((m: any) => `${m.role}:${(m.id ?? "").slice(0,8)}:${typeof m.content === "string" ? m.content.length : "?"}c`).join(" | ")}`);
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class DeduplicateToolCallMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
const open = new Set<string>(); // tool call IDs currently in progress
const closed = new Set<string>(); // tool call IDs that already received END
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event; // raw BaseEvent
const type: string = event?.type ?? "";
const id: string | undefined = event?.toolCallId;
if (type === EventType.TOOL_CALL_START) {
if (!id || closed.has(id) || open.has(id)) return; // duplicate
open.add(id);
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
if (id && closed.has(id)) return; // already completed, drop
if (type === EventType.TOOL_CALL_END && id) {
open.delete(id);
closed.add(id);
}
}
observer.next(event); // emit raw BaseEvent
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── StripModelArtifactsMiddleware ────────────────────────────────────────────
// Some OpenAI models occasionally leak training-data special tokens such as
// `<|ipynb_marker|>` into completion text. Filter them out of streamed deltas
// before they reach the chat UI.
const ARTIFACT_RE = /<\|[^|>]+\|>/g;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class StripModelArtifactsMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
if (
event?.type === EventType.TEXT_MESSAGE_CONTENT &&
typeof event?.delta === "string" &&
ARTIFACT_RE.test(event.delta)
) {
const cleaned = event.delta.replace(ARTIFACT_RE, "");
if (cleaned.length === 0) return;
observer.next({ ...event, delta: cleaned });
return;
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
// by id then APPENDS unknown ids, so the browser ends up with two assistant
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
// the assistant-with-toolCalls message in state past the run). The right fix
// is to drop just the orphan text-only assistant message that has no streamed
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class ReconcileSnapshotMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
const streamedTextIds = new Set<string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
const type: string = event?.type ?? "";
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
streamedTextIds.add(String(event.messageId));
}
if (type === EventType.MESSAGES_SNAPSHOT) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
const filtered = msgs.filter((m) => {
if (m?.role !== "assistant") return true;
const id = String(m?.id ?? "");
const hasText = typeof m?.content === "string" && m.content.length > 0;
const hasToolCalls =
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) {
return false; // drop orphan text-only assistant duplicate
}
return true;
});
if (filtered.length !== msgs.length) {
observer.next({ ...event, messages: filtered });
return;
}
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── SuppressRenderToolTextMiddleware ──────────────────────────────────────────
// When the LLM emits text content in the same response as a render tool call
// (render_a2ui or render_spending_summary), the text appears as a duplicate
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
// them if any render tool call is detected in the same turn.
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
let renderToolSeen = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const textBuffer: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event; // raw BaseEvent
const type: string = event?.type ?? "";
if (type === EventType.TOOL_CALL_START) {
const toolName: string = event?.toolCallName ?? "";
if (RENDER_TOOLS.has(toolName)) {
renderToolSeen = true;
textBuffer.length = 0; // discard text buffered before we saw the tool call
}
}
if (
type === EventType.TEXT_MESSAGE_START ||
type === EventType.TEXT_MESSAGE_CONTENT ||
type === EventType.TEXT_MESSAGE_END
) {
if (!renderToolSeen) textBuffer.push(event); // buffer raw event
return; // always hold — flush at turn end
}
if (type === EventType.RUN_FINISHED || type === EventType.RUN_ERROR) {
if (!renderToolSeen) {
for (const e of textBuffer) observer.next(e); // flush raw events
}
textBuffer.length = 0;
observer.next(event); // emit raw event
return;
}
observer.next(event); // emit raw event
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => {
if (!renderToolSeen) {
for (const e of textBuffer) observer.next(e);
}
observer.complete();
},
});
return () => sub.unsubscribe();
});
}
}
// ── Hono app ─────────────────────────────────────────────────────────────────
const app = new Hono();
app.all("/api/copilotkit/*", async (c) => {
const cookieHeader = c.req.header("cookie") ?? "";
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
const token = match?.[1];
const agentHeaders: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {};
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new SuppressRenderToolTextMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new StripModelArtifactsMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new ReconcileSnapshotMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new DeduplicateToolCallMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new DebugLogMiddleware() as any);
const runtime = new CopilotRuntime({
agents: { wealthysmart: agent },
a2ui: { injectA2UITool: true },
beforeRequestMiddleware: async ({ request: outbound }) => {
if (outbound.method !== "POST") return;
const ct = outbound.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return;
try {
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
if (!Array.isArray(body.messages)) return;
const paired = pairOrphanToolCalls(body.messages);
if (paired.length === body.messages.length) return;
return new Request(outbound.url, {
method: outbound.method,
headers: outbound.headers,
body: JSON.stringify({ ...body, messages: paired }),
});
} catch {
return;
}
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
});
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
});
app.get("/api/health", (c) => c.json({ ok: true }));
// Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly,
// but in prod the browser talks to this Hono server, which must forward
// `/api/v1/*` and `/api/auth/*` to the FastAPI container — otherwise the SPA
// fallback below swallows them and returns index.html.
const proxyToBackend = async (c: import("hono").Context) => {
const url = new URL(c.req.url);
const target = `${BACKEND_URL}${url.pathname}${url.search}`;
const method = c.req.method;
const headers = new Headers(c.req.raw.headers);
headers.delete("host");
const init: RequestInit = {
method,
headers,
redirect: "manual",
};
if (method !== "GET" && method !== "HEAD") {
init.body = c.req.raw.body;
// @ts-expect-error undici requires duplex for streamed bodies
init.duplex = "half";
}
return fetch(target, init);
};
app.all("/api/v1/*", proxyToBackend);
app.all("/api/auth/*", proxyToBackend);
// In production, serve the Vite build output.
if (isProd) {
app.use("/*", serveStatic({ root: "./dist" }));
// SPA fallback: any non-asset path returns index.html
app.get("/*", serveStatic({ path: "./dist/index.html" }));
}
serve({ fetch: app.fetch, port: PORT }, (info) => {
console.log(`CopilotKit server on port ${info.port} [${isProd ? "prod" : "dev"}]`);
});

View File

@@ -1,26 +1,34 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './AuthContext';
import { ThemeProvider } from './ThemeContext';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Transactions from './pages/Transactions';
import Transfers from './pages/Transfers';
import Analytics from './pages/Analytics';
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { CopilotKit } from "@copilotkit/react-core";
import { AuthProvider, useAuth } from "./AuthContext";
import { ThemeProvider } from "./contexts/theme-context";
import { PrivacyProvider } from "./contexts/privacy-context";
import Layout from "./components/Layout";
import LoginPage from "./pages/Login";
import Asistente from "./pages/Asistente";
import Analytics from "./pages/Analytics";
import Budget from "./pages/Budget";
import Salarios from "./pages/Salarios";
import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
function AppRoutes() {
const { isAuthenticated } = useAuth();
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
return (
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
/>
<Route
element={
@@ -29,10 +37,16 @@ function AppRoutes() {
</ProtectedRoute>
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/transactions" element={<Transactions />} />
<Route index element={<Navigate to="/asistente" replace />} />
<Route path="/asistente" element={<Asistente />} />
<Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/transfers" element={<Transfers />} />
<Route path="/proyecciones" element={<Proyecciones />} />
<Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route>
</Routes>
);
@@ -42,9 +56,13 @@ export default function App() {
return (
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<AppRoutes />
</AuthProvider>
<PrivacyProvider>
<AuthProvider>
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
<AppRoutes />
</CopilotKit>
</AuthProvider>
</PrivacyProvider>
</ThemeProvider>
</BrowserRouter>
);

View File

@@ -1,33 +1,40 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
import { logout as apiLogout } from "@/lib/api";
interface AuthCtx {
isAuthenticated: boolean;
logout: () => void;
isLoading: boolean;
logout: () => Promise<void>;
setAuthenticated: (v: boolean) => void;
}
const AuthContext = createContext<AuthCtx>({
isAuthenticated: false,
logout: () => {},
isLoading: true,
logout: async () => {},
setAuthenticated: () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token'));
const [isAuthenticated, setAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const check = () => setAuthenticated(!!localStorage.getItem('token'));
window.addEventListener('storage', check);
return () => window.removeEventListener('storage', check);
// Probe auth state by hitting a protected endpoint.
// If the ws_token cookie is valid, the server returns 200; else 401.
fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => setAuthenticated(r.ok))
.catch(() => setAuthenticated(false))
.finally(() => setIsLoading(false));
}, []);
const logout = () => {
localStorage.removeItem('token');
const logout = async () => {
await apiLogout();
setAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
{children}
</AuthContext.Provider>
);

View File

@@ -1,31 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({ theme: 'dark', toggleTheme: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('theme') as Theme;
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@@ -1,77 +0,0 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(err);
},
);
export default api;
export async function login(username: string, password: string) {
const form = new URLSearchParams();
form.append('username', username);
form.append('password', password);
const { data } = await api.post('/auth/login', form);
localStorage.setItem('token', data.access_token);
return data;
}
export interface Account {
id: number;
bank: string;
currency: string;
label: string;
balance: number;
account_type: string;
next_payment: number | null;
updated_at: string;
}
export interface Category {
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export interface ImportResult {
imported: number;
duplicates: number;
errors: string[];
}
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;
created_at: string;
}

View File

@@ -0,0 +1,39 @@
"use client";
import { CopilotChat } from "@copilotkit/react-ui";
import { Sparkles } from "lucide-react";
const SUGGESTIONS = [
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
];
export default function AgentHomeClient() {
return (
<div className="flex flex-col h-[calc(100vh-105px)]">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight font-heading flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
Asistente
</h1>
<p className="text-sm text-muted-foreground">
Pregúntale a WealthySmart sobre tus finanzas.
</p>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat
className="h-full"
labels={{
title: "WealthySmart",
initial: "¿Qué quieres saber sobre tus finanzas?",
placeholder: "Escribe tu pregunta…",
}}
suggestions={SUGGESTIONS}
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,13 @@
import { useEffect, useState } from 'react';
import { Calendar, ChevronDown } from 'lucide-react';
import api from '../api';
import { Calendar } from 'lucide-react';
import api from '@/lib/api';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface CycleOption {
year: number;
@@ -22,33 +29,34 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
api.get('/transactions/cycles').then((r) => setCycles(r.data));
}, []);
const selectedKey = value ? `${value.year}-${value.month}` : '';
const selectedKey = value ? `${value.year}-${value.month}` : 'all';
return (
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-text-muted" />
<div className="relative">
<select
value={selectedKey}
onChange={(e) => {
if (!e.target.value) {
onChange(null);
} else {
const [y, m] = e.target.value.split('-').map(Number);
onChange({ year: y, month: m });
}
}}
className="appearance-none bg-input-bg border border-border rounded-lg pl-3 pr-9 py-2 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
>
<option value="">All time</option>
<Calendar className="w-4 h-4 text-muted-foreground" />
<Select
value={selectedKey}
onValueChange={(val) => {
if (val === 'all') {
onChange(null);
} else {
const [y, m] = val.split('-').map(Number);
onChange({ year: y, month: m });
}
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All time</SelectItem>
{cycles.map((c) => (
<option key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
<SelectItem key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
{c.label} ({c.count})
</option>
</SelectItem>
))}
</select>
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
</div>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -1,4 +1,15 @@
import { AlertTriangle, X } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialogMedia,
} from '@/components/ui/alert-dialog';
interface Props {
title: string;
@@ -11,45 +22,26 @@ interface Props {
export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={onCancel}>
<div
className="bg-surface border border-border rounded-xl w-full max-w-sm animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold">{title}</h3>
<button onClick={onCancel} className="text-text-muted hover:text-text-primary transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-5">
<div className="flex gap-3 items-start">
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-5 h-5 text-red-500 dark:text-red-400" />
</div>
<p className="text-sm text-text-secondary pt-2">{message}</p>
</div>
</div>
<div className="flex gap-3 px-5 pb-5">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
type="button"
<AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10">
<AlertTriangle className="text-destructive" />
</AlertDialogMedia>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{message}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={onConfirm}
disabled={loading}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-red-500 hover:bg-red-600 text-white transition-colors disabled:opacity-50"
>
{loading ? 'Deleting...' : confirmLabel}
</button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,130 +1,213 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useState, useEffect } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
LayoutDashboard,
CreditCard,
Sparkles,
Calculator,
BarChart3,
ArrowLeftRight,
Landmark,
PiggyBank,
Droplets,
LogOut,
TrendingUp,
Wallet,
Menu,
X,
Sun,
Moon,
} from 'lucide-react';
import { useState } from 'react';
import { useAuth } from '../AuthContext';
import { useTheme } from '../ThemeContext';
Eye,
EyeOff,
type LucideIcon,
} from "lucide-react";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
} from "@/components/ui/sheet";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/transactions', icon: CreditCard, label: 'Transactions' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
interface NavSection {
label: string;
items: { to: string; icon: LucideIcon; label: string }[];
}
const navSections: NavSection[] = [
{
label: "General",
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
},
{
label: "Finanzas",
items: [
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
{ to: "/salarios", icon: Landmark, label: "Salarios" },
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
],
},
{
label: "Servicios",
items: [
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
],
},
];
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const { pathname } = useLocation();
const isActive = (to: string) =>
pathname === to || pathname.startsWith(`${to}/`);
return (
<nav className="flex flex-col gap-0.5 px-3">
{navSections.map((section) => (
<div key={section.label}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 pt-4 pb-1">
{section.label}
</p>
{section.items.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
isActive(to)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Icon className="w-4 h-4" />
{label}
</Link>
))}
</div>
))}
</nav>
);
}
export default function Layout() {
const { logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy();
const { logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const handleLogout = () => {
logout();
navigate('/login');
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
return (
<div className="min-h-screen bg-surface text-text-primary">
{/* Top bar */}
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-surface/90">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center">
<Wallet className="w-4 h-4 text-[#FEFAE0]" strokeWidth={2.5} />
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
>
<Menu className="w-5 h-5" />
</Button>
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span className="text-lg font-bold tracking-tight hidden sm:inline">
Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span>
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</div>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
}`
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2">
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-colors"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
<button
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
className="hidden md:flex items-center gap-2 text-text-muted hover:text-text-secondary text-sm transition-colors"
title="Sign out"
aria-label="Sign out"
className="hidden md:inline-flex"
>
<LogOut className="w-4 h-4" />
</button>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="md:hidden text-text-muted"
>
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</Button>
</div>
</div>
{/* Mobile nav */}
{mobileOpen && (
<div className="md:hidden border-t border-border px-4 pb-4 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
}`
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-text-muted hover:text-text-primary hover:bg-surface-hover w-full"
>
<LogOut className="w-4 h-4" />
Sign out
</button>
</div>
)}
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<Outlet />
</main>
<div className="flex">
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
<div className="flex-1">
<SidebarNav />
</div>
<div className="px-3 pb-4">
<Separator className="mb-2" />
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<LogOut className="w-4 h-4" />
Cerrar sesión
</button>
</div>
</aside>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="p-0 w-64">
<SheetHeader className="p-4">
<SheetTitle className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</SheetTitle>
</SheetHeader>
<Separator />
<div className="flex flex-col h-[calc(100%-65px)]">
<div className="flex-1 overflow-y-auto">
<SidebarNav onNavigate={() => setMobileOpen(false)} />
</div>
<div className="px-3 pb-4">
<Separator className="mb-2" />
<SheetClose render={<span />}>
<button
onClick={() => {
setMobileOpen(false);
void handleLogout();
}}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
>
<LogOut className="w-4 h-4" />
Cerrar sesión
</button>
</SheetClose>
</div>
</div>
</SheetContent>
</Sheet>
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
<div className="max-w-6xl mx-auto">
<Outlet />
</div>
</main>
</div>
</div>
);
}

View File

@@ -1,6 +1,24 @@
import { useState } from 'react';
import { X, ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import api, { type ImportResult } from '../api';
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import api, { type ImportResult } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface Props {
onClose: () => void;
@@ -28,114 +46,100 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
}
};
const inputClass =
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center gap-2">
<ClipboardPaste className="w-4 h-4 text-[#606C38] dark:text-[#7a8a4a]" />
<h3 className="font-semibold">Import Bank Statement</h3>
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ClipboardPaste className="w-4 h-4 text-primary" />
Import Bank Statement
</DialogTitle>
</DialogHeader>
{!result ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bank</Label>
<Select value={bank} onValueChange={setBank}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BAC">BAC</SelectItem>
<SelectItem value="BCR">BCR</SelectItem>
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Source</Label>
<Select value={source} onValueChange={setSource}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
<SelectItem value="CASH">Cash</SelectItem>
<SelectItem value="TRANSFER">Transfer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Statement Text</Label>
<Textarea
className="h-48 font-mono text-xs resize-y"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Paste bank statement lines here...\n\nFormat: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CRC`}
/>
<p className="text-xs text-muted-foreground">
One transaction per line. Tab-separated columns.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleImport} disabled={importing || !text.trim()}>
{importing ? 'Importing...' : 'Import'}
</Button>
</DialogFooter>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
<X className="w-5 h-5" />
</button>
</div>
) : (
<div className="space-y-4">
<Alert>
<CheckCircle className="h-4 w-4 text-primary" />
<AlertTitle className="text-primary">Import Complete</AlertTitle>
<AlertDescription>
{result.imported} imported
{result.duplicates > 0 && ` · ${result.duplicates} duplicates skipped`}
</AlertDescription>
</Alert>
<div className="p-5 space-y-4">
{!result ? (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Bank</label>
<select className={inputClass} value={bank} onChange={(e) => setBank(e.target.value)}>
<option value="BAC">BAC</option>
<option value="BCR">BCR</option>
<option value="DAVIVIENDA">Davivienda</option>
</select>
</div>
<div>
<label className={labelClass}>Source</label>
<select className={inputClass} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="CREDIT_CARD">Credit Card</option>
<option value="CASH">Cash</option>
<option value="TRANSFER">Transfer</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Statement Text</label>
<textarea
className={`${inputClass} h-48 font-mono text-xs resize-y`}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Paste bank statement lines here...\n\nFormat: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CRC`}
/>
<p className="text-xs text-text-faint mt-1">
One transaction per line. Tab-separated columns.
</p>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={importing || !text.trim()}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
>
{importing ? 'Importing...' : 'Import'}
</button>
</div>
</>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-[#606C38]/10 border border-[#606C38]/20 rounded-lg">
<CheckCircle className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a] flex-shrink-0" />
<div>
<p className="font-medium text-[#606C38] dark:text-[#7a8a4a]">Import Complete</p>
<p className="text-sm text-text-secondary mt-1">
{result.imported} imported
{result.duplicates > 0 && ` · ${result.duplicates} duplicates skipped`}
</p>
</div>
</div>
{result.errors.length > 0 && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-amber-500 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">
{result.errors.length} errors
</span>
</div>
<ul className="text-xs text-text-secondary space-y-1 font-mono max-h-32 overflow-y-auto">
{result.errors.length > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{result.errors.length} errors</AlertTitle>
<AlertDescription>
<ul className="text-xs font-mono max-h-32 overflow-y-auto space-y-1 mt-1">
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
)}
<button
onClick={onClose}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors"
>
Done
</button>
</div>
)}
</div>
</div>
</div>
<Button onClick={onClose} className="w-full">
Done
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import { type PensionUploadResult, submitPensionManualEntries } from '@/lib/api';
import { parsePensionPaste, type PensionParsedEntry } from '@/lib/parsePensionPaste';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface Props {
onClose: () => void;
onImported: () => void;
}
const formatCRC = (n: number) =>
new Intl.NumberFormat('es-CR', {
style: 'currency',
currency: 'CRC',
maximumFractionDigits: 0,
}).format(n);
const FUND_LABELS: Record<string, string> = {
ROP: 'ROP',
FCL: 'FCL',
VOL: 'Voluntario',
};
export default function PensionManualEntryModal({ onClose, onImported }: Props) {
const [text, setText] = useState('');
const [parsed, setParsed] = useState<PensionParsedEntry[] | null>(null);
const [parseError, setParseError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<PensionUploadResult | null>(null);
const handlePreview = () => {
setParseError('');
const entries = parsePensionPaste(text);
if (entries.length === 0) {
setParseError('No se encontraron datos de fondos. Verifica que el texto pegado tenga el formato correcto.');
setParsed(null);
return;
}
setParsed(entries);
};
const handleSubmit = async () => {
if (!parsed) return;
setSubmitting(true);
try {
const { data } = await submitPensionManualEntries(parsed);
setResult(data);
if (data.imported > 0 || data.updated > 0) onImported();
} catch (err) {
console.error(err);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ClipboardPaste className="w-4 h-4 text-primary" />
Ingresar Datos de Pensión
</DialogTitle>
</DialogHeader>
{result ? (
<div className="space-y-4">
<Alert>
<CheckCircle className="h-4 w-4 text-primary" />
<AlertTitle className="text-primary">Datos Guardados</AlertTitle>
<AlertDescription>
{result.imported > 0 && `${result.imported} nuevo(s)`}
{result.imported > 0 && result.updated > 0 && ' · '}
{result.updated > 0 && `${result.updated} actualizado(s)`}
</AlertDescription>
</Alert>
<Button onClick={onClose} className="w-full">Listo</Button>
</div>
) : !parsed ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>Pegar resumen del período</Label>
<Textarea
className="h-56 font-mono text-xs resize-y"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Pega aquí el texto del resumen de BAC Pensiones.\n\nEjemplo:\nResumen del Período\tROP\tFCL\nSaldo Anterior\t¢ 18,684,764.98\t¢ 650,467.87\nAportes\t¢ 120,012.00\t¢ 60,006.00\n...\n\nSepara ROP+FCL y Voluntario con una línea "---"`}
/>
<p className="text-xs text-muted-foreground">
Pega los bloques de ROP+FCL y Fondo Voluntario. Sepáralos con "---".
</p>
</div>
{parseError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{parseError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={handlePreview} disabled={!text.trim()}>
Vista Previa
</Button>
</DialogFooter>
</div>
) : (
<div className="space-y-4">
<div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Fondo</th>
<th className="text-left px-3 py-2 font-medium">Período</th>
<th className="text-right px-3 py-2 font-medium">Saldo Ant.</th>
<th className="text-right px-3 py-2 font-medium">Aportes</th>
<th className="text-right px-3 py-2 font-medium">Rendim.</th>
<th className="text-right px-3 py-2 font-medium">Saldo Final</th>
</tr>
</thead>
<tbody>
{parsed.map((e, i) => (
<tr key={i} className="border-b last:border-0">
<td className="px-3 py-2 font-medium">{FUND_LABELS[e.fund] ?? e.fund}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{e.period_start} {e.period_end}
</td>
<td className="px-3 py-2 text-right font-mono text-xs">{formatCRC(e.saldo_anterior)}</td>
<td className="px-3 py-2 text-right font-mono text-xs">{formatCRC(e.aportes)}</td>
<td className={`px-3 py-2 text-right font-mono text-xs ${e.rendimientos < 0 ? 'text-red-500' : 'text-green-600'}`}>
{formatCRC(e.rendimientos)}
</td>
<td className="px-3 py-2 text-right font-mono text-xs font-semibold">{formatCRC(e.saldo_final)}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setParsed(null)}>
Editar
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Guardando...' : 'Confirmar'}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,223 @@
import { useState, useMemo } from 'react';
import {
Plus,
Search,
Pencil,
Trash2,
TrendingUp,
TrendingDown,
ArrowLeftRight,
ArrowRightFromLine,
Banknote,
} from 'lucide-react';
import api, { type Transaction } from '@/lib/api';
import TransactionModal from './TransactionModal';
import ConfirmDialog from './ConfirmDialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/ui/data-table';
import { getTransactionColumns } from '@/components/transactions/transaction-columns';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
export interface TransactionListProps {
transactions: Transaction[];
loading: boolean;
source: 'CREDIT_CARD' | 'CASH' | 'TRANSFER';
search: string;
onSearchChange: (value: string) => void;
onRefresh: () => void;
emptyIcon?: React.ReactNode;
emptyMessage?: string;
showCategory?: boolean;
showSourceIcon?: boolean;
addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void;
}
export default function TransactionList({
transactions,
loading,
source,
search,
onSearchChange,
onRefresh,
emptyIcon,
emptyMessage = 'No transactions found',
showCategory = true,
showSourceIcon = false,
addLabel = 'Add Transaction',
onToggleDeferred,
}: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const handleEdit = (tx: Transaction) => {
setEditing(tx);
setModalOpen(true);
};
const handleDelete = async () => {
if (deleteId === null) return;
setDeleting(true);
try {
await api.delete(`/transactions/${deleteId}`);
setDeleteId(null);
onRefresh();
} finally {
setDeleting(false);
}
};
const columns = useMemo(
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
[showCategory, showSourceIcon, onToggleDeferred],
);
const empty = transactions.length === 0 && !loading;
return (
<>
{/* Search + Add */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
placeholder="Search merchants..."
/>
</div>
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
<Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
{/* Mobile list */}
<Card className="md:hidden">
<CardContent className="p-0 divide-y divide-border">
{empty ? (
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
{emptyMessage}
</div>
) : (
transactions.map((tx) => (
<div key={tx.id} className="flex items-center gap-3 px-4 py-3">
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
tx.transaction_type === 'DEVOLUCION'
? 'bg-primary/10 text-primary'
: 'bg-destructive/10 text-destructive'
)}
>
{tx.transaction_type === 'DEVOLUCION' ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
{showSourceIcon && (
tx.source === 'CASH'
? <Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: tx.source === 'TRANSFER'
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: null
)}
</div>
<p className="text-xs text-muted-foreground">
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{showCategory && tx.category && (
<span className="ml-1.5 text-muted-foreground/60">{tx.category.name}</span>
)}
</p>
</div>
<span
data-sensitive
className={cn(
'font-mono text-sm font-medium shrink-0',
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
)}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{onToggleDeferred && (
<Button
variant="ghost"
size="icon"
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
onClick={() => onToggleDeferred(tx)}
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
>
<ArrowRightFromLine className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete transaction"
aria-label="Delete transaction"
onClick={() => setDeleteId(tx.id)}
className="hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
{/* Desktop table */}
<Card className="hidden md:block">
<CardContent className="p-0">
<DataTable
columns={columns}
data={transactions}
pagination
pageSize={25}
initialSorting={[{ id: 'date', desc: true }]}
emptyMessage={emptyMessage}
/>
</CardContent>
</Card>
{/* Modals */}
{modalOpen && (
<TransactionModal
transaction={editing}
source={source}
onClose={() => setModalOpen(false)}
onSaved={onRefresh}
/>
)}
{deleteId !== null && (
<ConfirmDialog
title="Delete Transaction"
message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
loading={deleting}
/>
)}
</>
);
}

View File

@@ -1,6 +1,25 @@
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import api, { type Category, type Transaction } from '../api';
import api, { type Category, type Transaction } from '@/lib/api';
import { formatLocalDatetime } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
interface Props {
transaction?: Transaction | null;
@@ -15,7 +34,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
merchant: '',
amount: '',
currency: 'CRC',
date: new Date().toISOString().slice(0, 16),
date: formatLocalDatetime(new Date()),
transaction_type: 'COMPRA',
source,
bank: 'BAC',
@@ -29,7 +48,10 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
const [error, setError] = useState('');
useEffect(() => {
api.get('/categories/').then((r) => setCategories(r.data));
api.get('/categories/').then((r) => {
const sorted = [...r.data].sort((a: Category, b: Category) => a.name.localeCompare(b.name));
setCategories(sorted);
});
}, []);
useEffect(() => {
@@ -83,43 +105,35 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
}
};
const inputClass =
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface border border-border rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold">
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{transaction ? 'Edit Transaction' : 'New Transaction'}
</h3>
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
<X className="w-5 h-5" />
</button>
</div>
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-500 dark:text-red-400">
{error}
</div>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className={labelClass}>Merchant</label>
<input
className={inputClass}
<div className="col-span-2 space-y-2">
<Label>Merchant</Label>
<Input
value={form.merchant}
onChange={(e) => setForm({ ...form, merchant: e.target.value })}
placeholder="e.g. AUTO MERCADO ON LINE"
required
/>
</div>
<div>
<label className={labelClass}>Amount</label>
<input
className={inputClass}
<div className="space-y-2">
<Label>Amount</Label>
<Input
type="number"
step="0.01"
value={form.amount}
@@ -128,69 +142,79 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
required
/>
</div>
<div>
<label className={labelClass}>Currency</label>
<select
className={inputClass}
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
>
<option value="CRC">CRC ()</option>
<option value="USD">USD ($)</option>
</select>
<div className="space-y-2">
<Label>Currency</Label>
<Select value={form.currency} onValueChange={(v) => setForm({ ...form, currency: v })}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CRC">CRC ()</SelectItem>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className={labelClass}>Date</label>
<input
className={inputClass}
<div className="space-y-2">
<Label>Date</Label>
<Input
type="datetime-local"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>Type</label>
<select
className={inputClass}
value={form.transaction_type}
onChange={(e) => setForm({ ...form, transaction_type: e.target.value })}
>
<option value="COMPRA">Compra</option>
<option value="DEVOLUCION">Devolución</option>
</select>
<div className="space-y-2">
<Label>Type</Label>
<Select value={form.transaction_type} onValueChange={(v) => setForm({ ...form, transaction_type: v })}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="COMPRA">Compra</SelectItem>
<SelectItem value="DEVOLUCION">Devolución</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className={labelClass}>Category</label>
<select
className={inputClass}
value={form.category_id}
onChange={(e) => setForm({ ...form, category_id: e.target.value })}
<div className="space-y-2">
<Label>Category</Label>
<Select
value={form.category_id ? String(form.category_id) : 'auto'}
onValueChange={(v) => setForm({ ...form, category_id: v === 'auto' ? '' : v })}
>
<option value="">Auto-detect</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<SelectTrigger className="w-full">
<SelectValue>
{form.category_id
? categories.find((c) => c.id === Number(form.category_id))?.name ?? form.category_id
: 'Auto-detect'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className={labelClass}>Bank</label>
<select
className={inputClass}
value={form.bank}
onChange={(e) => setForm({ ...form, bank: e.target.value })}
>
<option value="BAC">BAC</option>
<option value="BCR">BCR</option>
<option value="DAVIVIENDA">Davivienda</option>
</select>
<div className="space-y-2">
<Label>Bank</Label>
<Select value={form.bank} onValueChange={(v) => setForm({ ...form, bank: v })}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BAC">BAC</SelectItem>
<SelectItem value="BCR">BCR</SelectItem>
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className={labelClass}>City</label>
<input
className={inputClass}
<div className="space-y-2">
<Label>City</Label>
<Input
value={form.city}
onChange={(e) => setForm({ ...form, city: e.target.value })}
placeholder="SAN JOSE, Costa Rica"
@@ -198,19 +222,17 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div>
{source === 'CREDIT_CARD' && (
<>
<div>
<label className={labelClass}>Card Type</label>
<input
className={inputClass}
<div className="space-y-2">
<Label>Card Type</Label>
<Input
value={form.card_type}
onChange={(e) => setForm({ ...form, card_type: e.target.value })}
placeholder="MASTER"
/>
</div>
<div>
<label className={labelClass}>Card Last 4</label>
<input
className={inputClass}
<div className="space-y-2">
<Label>Card Last 4</Label>
<Input
value={form.card_last4}
onChange={(e) => setForm({ ...form, card_last4: e.target.value })}
placeholder="6585"
@@ -219,10 +241,9 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div>
</>
)}
<div className="col-span-2">
<label className={labelClass}>Notes</label>
<input
className={inputClass}
<div className="col-span-2 space-y-2">
<Label>Notes</Label>
<Input
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional notes"
@@ -230,24 +251,16 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
>
</Button>
<Button type="submit" disabled={saving}>
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
</button>
</div>
</Button>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,541 @@
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import {
TrendingUp,
TrendingDown,
CreditCard,
Banknote,
ArrowLeftRight,
Info,
} from 'lucide-react';
type PaletteMode = 'chatgpt' | 'gemini';
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
chatgpt: {
// Pure green scale, darkest → lightest (assigned by rank)
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
// Pure amber scale, darkest → lightest (assigned by rank)
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
// Warm-to-cool alternating for CC categories
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
},
gemini: {
// Qualitative greens: dark green, mint, pale green, forest
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
// Terracotta, slate blue, sage, sand — diverse hues
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
// Pastel/muted diverse for CC categories
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
},
};
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
CASH: { label: 'Efectivo', icon: Banknote },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
};
interface MonthlyDetailProps {
detail: MonthlyDetailType | null;
loading?: boolean;
onNavigateToTransactions?: () => void;
}
function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof TrendingUp; title: string }) {
return (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<TitleIcon className="w-4 h-4" />
{title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center">
<div className="h-[200px] w-full flex items-center justify-center">
<Skeleton className="h-[160px] w-[160px] rounded-full" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-1.5">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 flex-1" />
</div>
))}
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</CardContent>
</Card>
);
}
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading || !detail) {
return (
<div className="space-y-4">
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
</div>
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<CreditCard className="w-4 h-4" />
Tarjeta de Crédito
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row items-center gap-4">
<div className="h-[200px] w-full md:w-1/2 flex items-center justify-center">
<Skeleton className="h-[160px] w-[160px] rounded-full" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-1.5">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 flex-1" />
</div>
))}
</div>
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-28" />
</div>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<Banknote className="w-4 h-4" />
Efectivo o Transferencias
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
))}
</CardContent>
</Card>
<Card className="border-2 border-muted/40">
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<Separator />
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-6 w-32" />
</div>
</CardContent>
</Card>
</div>
</div>
);
}
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
// For ChatGPT mode: assign colors by rank (largest = darkest)
// For Gemini mode: assign colors by position (qualitative)
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
if (paletteMode === 'chatgpt') {
const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>();
sorted.forEach((item, i) => {
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
});
return map;
}
// Gemini: positional
const map = new Map<string, string>();
data.forEach((item, i) => {
map.set(item.name, colors[i % colors.length]);
});
return map;
}
const incomeColorMap = buildColorMap(incomeData, incomeColors);
const expenseColorMap = buildColorMap(expenseData, expenseColors);
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
return acc;
}, {});
const expenseConfig = expenseData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: expenseColorMap.get(item.name)! };
return acc;
}, {});
// CC spending by category
const ccData = (detail.cc_by_category ?? []).map((item) => ({
name: item.category_name,
value: item.amount,
}));
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
return acc;
}, {});
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0);
// Filter actuals to only cash and transfer (no credit card)
const cashTransferActuals = detail.actuals_by_source.filter(
(src) => src.source !== 'CREDIT_CARD' && src.count > 0
);
return (
<div className="space-y-4">
{/* Palette Toggle */}
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Button
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('chatgpt')}
>
ChatGPT
</Button>
<Button
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('gemini')}
>
Gemini
</Button>
</div>
{/* Pie Charts */}
<div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */}
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
Ingresos
</CardTitle>
</CardHeader>
<CardContent>
{incomeData.length > 0 ? (
<div className="flex flex-col items-center">
<ChartContainer config={incomeConfig} className="h-[200px] w-full">
<PieChart>
<Pie
data={incomeData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{incomeData.map((item, i) => (
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{incomeData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: incomeColorMap.get(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total</span>
<span data-sensitive className="font-mono text-primary">
{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">Sin ingresos</p>
)}
</CardContent>
</Card>
{/* Expenses Pie */}
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-destructive" />
Egresos Fijos
</CardTitle>
</CardHeader>
<CardContent>
{expenseData.length > 0 ? (
<div className="flex flex-col items-center">
<ChartContainer config={expenseConfig} className="h-[200px] w-full">
<PieChart>
<Pie
data={expenseData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{expenseData.map((item, i) => (
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{expenseData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: expenseColorMap.get(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total Fijos</span>
<span data-sensitive className="font-mono">
{formatAmount(detail.total_projected_expenses, 'CRC')}
</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">Sin egresos fijos</p>
)}
</CardContent>
</Card>
</div>
{/* Credit Card by Category */}
{ccData.length > 0 && (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Tarjeta de Crédito
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row items-center gap-4">
<ChartContainer config={ccConfig} className="h-[200px] w-full md:w-1/2">
<PieChart>
<Pie
data={ccData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{ccData.map((_, i) => (
<Cell key={i} fill={ccColors[i % ccColors.length]} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{ccData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: ccColors[i % ccColors.length] }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total Tarjeta</span>
<span data-sensitive className="font-mono">
{formatAmount(ccTotal, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Actuals + Summary */}
<div className="grid gap-4 md:grid-cols-2">
{/* Cash & Transfer Actuals Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Banknote className="w-4 h-4" />
Efectivo o Transferencias
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{cashTransferActuals.map((src) => {
const meta = SOURCE_LABELS[src.source];
if (!meta) return null;
const Icon = meta.icon;
const isClickable = onNavigateToTransactions != null;
return (
<div key={src.source} className="flex items-center justify-between text-sm">
<button
type="button"
className={cn(
'flex items-center gap-1.5',
isClickable && 'cursor-pointer hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
)}
onClick={isClickable ? onNavigateToTransactions : undefined}
disabled={!isClickable}
>
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
<span>{meta.label}</span>
<span className="text-xs text-muted-foreground">({src.count})</span>
</button>
<span data-sensitive className="font-mono whitespace-nowrap">
{formatAmount(src.net, 'CRC')}
</span>
</div>
);
})}
{cashTransferActuals.length === 0 && (
<p className="text-sm text-muted-foreground">Sin transacciones</p>
)}
{detail.uncovered_actual > 0 && (
<>
<Separator />
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<Info className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">No cubierto por fijos</span>
</div>
<span data-sensitive className="font-mono">{formatAmount(detail.uncovered_actual, 'CRC')}</span>
</div>
</>
)}
</CardContent>
</Card>
{/* Summary */}
<Card className={cn(
'border-2',
detail.net_balance >= 0 ? 'border-primary/30' : 'border-destructive/30',
)}>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between text-sm">
<span>Total Ingresos</span>
<span data-sensitive className="font-mono font-medium text-primary">
+{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Gran Total Egresos</span>
<span data-sensitive className="font-mono font-medium">
-{formatAmount(detail.gran_total_egresos, 'CRC')}
</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-semibold">Balance Neto</span>
<span
data-sensitive
className={cn(
'font-mono font-bold text-lg',
detail.net_balance >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{detail.net_balance >= 0 ? '+' : ''}
{formatAmount(detail.net_balance, 'CRC')}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,309 @@
import { useState, useEffect } from 'react';
import {
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
type RecurringItemType,
type RecurringFrequency,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Plus, Trash2 } from 'lucide-react';
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
{ value: 'INCOME', label: 'Ingreso' },
{ value: 'EXPENSE', label: 'Egreso' },
];
const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [
{ value: 'WEEKLY', label: 'Semanal' },
{ value: 'MONTHLY', label: 'Mensual' },
{ value: 'QUARTERLY', label: 'Trimestral' },
{ value: 'BIANNUAL', label: 'Semestral' },
{ value: 'YEARLY', label: 'Anual' },
];
const MONTH_LABELS = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
interface RecurringItemDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item?: RecurringItem | null;
onSave: (data: RecurringItemCreate | RecurringItemUpdate) => Promise<void>;
}
export default function RecurringItemDialog({
open,
onOpenChange,
item,
onSave,
}: RecurringItemDialogProps) {
const isEdit = !!item;
const [name, setName] = useState('');
const [amount, setAmount] = useState('');
const [itemType, setItemType] = useState<RecurringItemType>('EXPENSE');
const [frequency, setFrequency] = useState<RecurringFrequency>('MONTHLY');
const [dayOfMonth, setDayOfMonth] = useState('');
const [monthOfYear, setMonthOfYear] = useState('');
const [overrides, setOverrides] = useState<{ month: string; amount: string }[]>([]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
if (item) {
setName(item.name);
setAmount(String(item.amount));
setItemType(item.item_type);
setFrequency(item.frequency);
setDayOfMonth(item.day_of_month != null ? String(item.day_of_month) : '');
setMonthOfYear(item.month_of_year != null ? String(item.month_of_year) : '');
setOverrides(
item.override_amounts
? Object.entries(item.override_amounts).map(([m, a]) => ({
month: m,
amount: String(a),
}))
: [],
);
setNotes(item.notes || '');
} else {
setName('');
setAmount('');
setItemType('EXPENSE');
setFrequency('MONTHLY');
setDayOfMonth('');
setMonthOfYear('');
setOverrides([]);
setNotes('');
}
}
}, [open, item]);
const showDayOfMonth = frequency === 'MONTHLY' || frequency === 'WEEKLY';
const showMonthOfYear = frequency === 'YEARLY' || frequency === 'BIANNUAL';
const showOverrides = frequency === 'MONTHLY';
const handleSubmit = async () => {
setSaving(true);
try {
const overrideAmounts =
overrides.length > 0
? Object.fromEntries(
overrides
.filter((o) => o.month && o.amount)
.map((o) => [o.month, parseFloat(o.amount)]),
)
: null;
const data = {
name,
amount: parseFloat(amount),
item_type: itemType,
frequency,
day_of_month: dayOfMonth ? parseInt(dayOfMonth) : null,
month_of_year: monthOfYear ? parseInt(monthOfYear) : null,
override_amounts: overrideAmounts,
notes: notes || null,
};
await onSave(data);
onOpenChange(false);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar' : 'Nuevo'} Item Recurrente</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="ri-name">Nombre</Label>
<Input id="ri-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ri-amount">Monto (CRC)</Label>
<Input
id="ri-amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Tipo</Label>
<Select value={itemType} onValueChange={(v) => v && setItemType(v as RecurringItemType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Frecuencia</Label>
<Select value={frequency} onValueChange={(v) => v && setFrequency(v as RecurringFrequency)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showDayOfMonth && (
<div className="space-y-1.5">
<Label htmlFor="ri-day">
{frequency === 'WEEKLY' ? 'Día de semana (0=Lun)' : 'Día del mes'}
</Label>
<Input
id="ri-day"
type="number"
value={dayOfMonth}
onChange={(e) => setDayOfMonth(e.target.value)}
/>
</div>
)}
{showMonthOfYear && (
<div className="space-y-1.5">
<Label>Mes</Label>
<Select value={monthOfYear} onValueChange={(v) => v && setMonthOfYear(v)}>
<SelectTrigger>
<SelectValue placeholder="Seleccionar" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<SelectItem key={m} value={String(m)}>
{MONTH_LABELS[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{showOverrides && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
Montos por mes (sobreescrituras)
</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setOverrides([...overrides, { month: '', amount: '' }])}
>
<Plus className="w-3 h-3 mr-1" />
Agregar
</Button>
</div>
{overrides.map((o, idx) => (
<div key={idx} className="flex items-center gap-2">
<Select
value={o.month}
onValueChange={(v) => {
if (!v) return;
const next = [...overrides];
next[idx].month = v;
setOverrides(next);
}}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="Mes" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<SelectItem key={m} value={String(m)}>
{MONTH_LABELS[m]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
placeholder="Monto"
value={o.amount}
onChange={(e) => {
const next = [...overrides];
next[idx].amount = e.target.value;
setOverrides(next);
}}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => setOverrides(overrides.filter((_, i) => i !== idx))}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ri-notes">Notas</Label>
<Textarea
id="ri-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={saving || !name || !amount}>
{saving ? 'Guardando...' : isEdit ? 'Guardar' : 'Crear'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,183 @@
import { useState, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import {
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
} from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import RecurringItemDialog from './RecurringItemDialog';
import ConfirmDialog from '@/components/ConfirmDialog';
const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
INCOME: { label: 'Ingreso', variant: 'default' },
EXPENSE: { label: 'Egreso', variant: 'secondary' },
};
const FREQ_LABELS: Record<string, string> = {
WEEKLY: 'Semanal',
MONTHLY: 'Mensual',
QUARTERLY: 'Trimestral',
BIANNUAL: 'Semestral',
YEARLY: 'Anual',
};
interface RecurringItemsManagerProps {
items: RecurringItem[];
onAdd: (data: RecurringItemCreate) => Promise<void>;
onUpdate: (id: number, data: RecurringItemUpdate) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}
export default function RecurringItemsManager({
items,
onAdd,
onUpdate,
onDelete,
}: RecurringItemsManagerProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editItem, setEditItem] = useState<RecurringItem | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const handleEdit = (item: RecurringItem) => {
setEditItem(item);
setDialogOpen(true);
};
const handleAdd = () => {
setEditItem(null);
setDialogOpen(true);
};
const handleSave = async (data: RecurringItemCreate | RecurringItemUpdate) => {
if (editItem) {
await onUpdate(editItem.id, data as RecurringItemUpdate);
} else {
await onAdd(data as RecurringItemCreate);
}
};
const handleDelete = async () => {
if (deleteId != null) {
await onDelete(deleteId);
setDeleteId(null);
}
};
const columns = useMemo<ColumnDef<RecurringItem, unknown>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Nombre" />,
cell: ({ row }) => (
<div>
<span className="font-medium">{row.original.name}</span>
{!row.original.is_active && (
<Badge variant="outline" className="ml-2 text-[10px]">inactivo</Badge>
)}
</div>
),
},
{
accessorKey: 'item_type',
header: ({ column }) => <DataTableColumnHeader column={column} title="Tipo" />,
cell: ({ row }) => {
const meta = TYPE_LABELS[row.original.item_type];
return <Badge variant={meta?.variant ?? 'secondary'}>{meta?.label ?? row.original.item_type}</Badge>;
},
},
{
accessorKey: 'frequency',
header: ({ column }) => <DataTableColumnHeader column={column} title="Frecuencia" />,
cell: ({ row }) => (
<span className="text-sm">{FREQ_LABELS[row.original.frequency] ?? row.original.frequency}</span>
),
},
{
accessorKey: 'amount',
meta: { className: 'text-right' },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
),
cell: ({ row }) => (
<span data-sensitive className="font-mono text-sm">
{formatAmount(row.original.amount, row.original.currency)}
</span>
),
},
{
id: 'actions',
meta: { className: 'text-right' },
size: 80,
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
title="Editar"
aria-label="Editar"
onClick={() => handleEdit(row.original)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Eliminar"
aria-label="Eliminar"
onClick={() => setDeleteId(row.original.id)}
className="hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
),
},
],
[],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Items Recurrentes</h3>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
Nuevo
</Button>
</div>
<DataTable
columns={columns}
data={items}
pagination
pageSize={20}
initialSorting={[{ id: 'item_type', desc: false }]}
emptyMessage="No hay items recurrentes."
/>
<RecurringItemDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
item={editItem}
onSave={handleSave}
/>
{deleteId != null && (
<ConfirmDialog
title="Eliminar item"
message="Esta acción no se puede deshacer."
confirmLabel="Eliminar"
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,207 @@
import { useState, useRef, useEffect } from 'react';
import { Pencil } from 'lucide-react';
import { type MonthlyProjection } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
const MONTH_NAMES = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
const FRESH_START_YEAR = 2026;
const FRESH_START_MONTH = 3;
interface YearlyOverviewProps {
months: MonthlyProjection[];
selectedMonth: number;
year: number;
onSelectMonth: (month: number) => void;
onSaveOverride: (month: number, value: number) => Promise<void>;
onClearOverride: (month: number) => Promise<void>;
}
export default function YearlyOverview({
months,
selectedMonth,
year,
onSelectMonth,
onSaveOverride,
onClearOverride,
}: YearlyOverviewProps) {
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
const [editingMonth, setEditingMonth] = useState<number | null>(null);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingMonth !== null && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingMonth]);
const handleStartEdit = (m: MonthlyProjection) => {
setEditingMonth(m.month);
setEditValue(String(Math.round(m.cumulative_balance)));
};
const handleSave = async () => {
if (editingMonth === null) return;
const trimmed = editValue.trim();
if (trimmed === '') {
await onClearOverride(editingMonth);
} else {
const num = parseFloat(trimmed);
if (!isNaN(num)) {
await onSaveOverride(editingMonth, num);
}
}
setEditingMonth(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
setEditingMonth(null);
}
};
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Mes</TableHead>
<TableHead className="text-right">Ingresos</TableHead>
<TableHead className="text-right">Egresos Fijos</TableHead>
<TableHead className="text-right">Otros Gastos</TableHead>
<TableHead className="text-right">Gran Total</TableHead>
<TableHead className="text-right">Acum. Anterior</TableHead>
<TableHead className="text-right">Neto Mes</TableHead>
<TableHead className="text-right">Balance Acum.</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{months.map((m) => {
const isSelected = m.month === selectedMonth;
const isCurrent = m.month === currentMonth && m.year === currentYear;
const isBeforeFreshStart =
year === FRESH_START_YEAR && m.month < FRESH_START_MONTH;
const isEditing = editingMonth === m.month;
return (
<TableRow
key={m.month}
className={cn(
'cursor-pointer transition-colors',
isSelected && 'bg-accent',
isCurrent && !isSelected && 'bg-accent/40',
isBeforeFreshStart && 'opacity-40',
)}
onClick={() => onSelectMonth(m.month)}
>
<TableCell className="font-medium">
{MONTH_NAMES[m.month]}
{isCurrent && (
<span className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-primary" />
)}
</TableCell>
<TableCell data-sensitive className="text-right font-mono text-sm text-primary">
{formatAmount(m.projected_income, 'CRC')}
</TableCell>
<TableCell data-sensitive className="text-right font-mono text-sm">
{formatAmount(m.projected_fixed_expenses, 'CRC')}
</TableCell>
<TableCell data-sensitive className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.uncovered_actual, 'CRC')}
</TableCell>
<TableCell data-sensitive className="text-right font-mono text-sm font-medium">
{formatAmount(m.gran_total_egresos, 'CRC')}
</TableCell>
<TableCell
className={cn(
'text-right font-mono text-sm',
m.carryover_balance >= 0
? 'text-muted-foreground'
: 'text-destructive',
)}
>
{isBeforeFreshStart
? '—'
: <span data-sensitive>
{m.carryover_balance >= 0 ? '+' : ''}
{formatAmount(m.carryover_balance, 'CRC')}
</span>
}
</TableCell>
<TableCell
data-sensitive
className={cn(
'text-right font-mono text-sm font-semibold',
m.net_balance >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{m.net_balance >= 0 ? '+' : ''}
{formatAmount(m.net_balance, 'CRC')}
</TableCell>
<TableCell
className="text-right font-mono text-sm font-semibold p-0 pr-2"
onClick={(e) => {
if (isBeforeFreshStart) return;
e.stopPropagation();
if (!isEditing) handleStartEdit(m);
}}
>
{isBeforeFreshStart ? (
<span className="px-2"></span>
) : isEditing ? (
<Input
ref={inputRef}
type="number"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="h-7 w-36 text-right font-mono text-sm ml-auto"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded cursor-pointer hover:bg-muted/50',
m.cumulative_balance >= 0
? 'text-primary'
: 'text-destructive',
)}
>
{m.balance_overridden && (
<Pencil className="w-3 h-3 text-amber-500 shrink-0" />
)}
<span data-sensitive>
{m.cumulative_balance >= 0 ? '+' : ''}
{formatAmount(m.cumulative_balance, 'CRC')}
</span>
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,456 @@
import { TrendingDown, TrendingUp, Wallet, ArrowRightLeft } from "lucide-react";
// ── helpers ──────────────────────────────────────────────────────────────────
function fmtCRC(n: number | undefined | null) {
if (n == null) return "₡0";
return `${Math.round(n).toLocaleString("es-CR")}`;
}
function fmtCurrency(amount: number | undefined | null, currency: string | undefined | null) {
if (amount == null) return "₡0";
if (currency === "USD") return `$${amount.toFixed(2)}`;
if (currency === "EUR") return `${amount.toFixed(2)}`;
return fmtCRC(amount);
}
function sourceLabel(source: string | undefined | null) {
if (!source) return "Otro";
const map: Record<string, string> = {
CREDIT_CARD: "Tarjeta",
CASH: "Efectivo",
TRANSFER: "Transferencia",
SINPE: "SINPE",
OTHER: "Otro",
};
return map[source] ?? source.replace(/_/g, " ");
}
// ── Spinner ───────────────────────────────────────────────────────────────────
function Spinner() {
return (
<div className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin inline-block" />
);
}
// ── SpendingSummaryCard ───────────────────────────────────────────────────────
export interface SpendingBySource {
source: string;
total_crc: number;
count: number;
}
export interface SpendingByCategory {
category: string;
amount_crc: number;
count: number;
}
export interface SpendingSummaryArgs {
title?: string;
period?: string;
total_crc?: number;
by_source?: SpendingBySource[];
by_category?: SpendingByCategory[];
}
export function SpendingSummaryCard({
args,
status,
}: {
args: SpendingSummaryArgs;
status: string;
}) {
const { title, period, total_crc, by_source = [], by_category = [] } = args;
const max = Math.max(...by_category.map((c) => c.amount_crc), 1);
const PALETTE = [
"bg-primary",
"bg-chart-1",
"bg-chart-2",
"bg-chart-3",
"bg-chart-4",
"bg-chart-5",
];
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
{title ?? "Resumen de gastos"}
</p>
{period && (
<p className="text-xs text-muted-foreground mt-0.5">{period}</p>
)}
</div>
<div className="text-right shrink-0">
<p className="text-2xl font-bold tabular-nums">{fmtCRC(total_crc)}</p>
<p className="text-[11px] text-muted-foreground">total gastado</p>
</div>
</div>
{/* By source */}
{by_source.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{by_source.filter((s) => s?.source != null).map((s) => (
<div
key={s.source}
className="rounded-lg bg-secondary/40 border border-border/50 px-3 py-2"
>
<p className="text-[11px] text-muted-foreground">
{sourceLabel(s.source)}
</p>
<p className="font-semibold text-sm tabular-nums">
{fmtCRC(s.total_crc)}
</p>
<p className="text-[11px] text-muted-foreground">
{s.count} mov.
</p>
</div>
))}
</div>
)}
{/* By category */}
{by_category.length > 0 && (
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-2">
Por categoría
</p>
<div className="space-y-2">
{by_category.filter((c) => c?.category != null).slice(0, 7).map((c, i) => (
<div key={c.category}>
<div className="flex justify-between text-xs mb-1">
<span className="text-card-foreground">{c.category}</span>
<span className="text-muted-foreground tabular-nums">
{fmtCRC(c.amount_crc)}
<span className="text-[10px] ml-1">({c.count})</span>
</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${PALETTE[i % PALETTE.length]}`}
style={{
width: `${Math.round(((c.amount_crc ?? 0) / max) * 100)}%`,
}}
/>
</div>
</div>
))}
</div>
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Obteniendo datos
</div>
)}
</div>
);
}
// ── TransactionListCard ───────────────────────────────────────────────────────
export interface TransactionRow {
date: string;
merchant: string;
amount: number;
currency: string;
category: string | null;
source: string;
}
export interface TransactionListArgs {
title?: string;
transactions?: TransactionRow[];
}
const SOURCE_ICON: Record<string, React.ReactNode> = {
CREDIT_CARD: <Wallet className="w-3 h-3" />,
TRANSFER: <ArrowRightLeft className="w-3 h-3" />,
};
export function TransactionListCard({
args,
status,
}: {
args: TransactionListArgs;
status: string;
}) {
const { title, transactions = [] } = args;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 my-2 shadow-sm">
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-3">
{title ?? "Transacciones"}
</p>
{transactions.length === 0 && status !== "inProgress" && (
<p className="text-sm text-muted-foreground">Sin transacciones.</p>
)}
<div className="divide-y divide-border/50">
{transactions.map((t, i) => (
<div key={i} className="flex items-center justify-between py-2 gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-6 h-6 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
{SOURCE_ICON[t.source] ?? <Wallet className="w-3 h-3" />}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{t.merchant}</p>
<p className="text-[11px] text-muted-foreground">
{t.date}
{t.category && ` · ${t.category}`}
</p>
</div>
</div>
<p className="text-sm font-semibold tabular-nums shrink-0 text-destructive">
{fmtCurrency(t.amount, t.currency)}
</p>
</div>
))}
</div>
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-2">
<Spinner /> Cargando
</div>
)}
</div>
);
}
// ── NetWorthCard ──────────────────────────────────────────────────────────────
export interface AccountRow {
bank: string;
label: string;
balance_crc: number;
account_type: string;
currency: string;
}
export interface NetWorthArgs {
total_assets_crc?: number;
total_liabilities_crc?: number;
net_worth_crc?: number;
accounts?: AccountRow[];
}
export function NetWorthCard({
args,
status,
}: {
args: NetWorthArgs;
status: string;
}) {
const {
total_assets_crc = 0,
total_liabilities_crc = 0,
net_worth_crc = 0,
accounts = [],
} = args;
const isPositive = net_worth_crc >= 0;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
{/* Net worth headline */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
Patrimonio neto
</p>
<div className="flex items-center gap-1 mt-0.5">
{isPositive ? (
<TrendingUp className="w-4 h-4 text-green-500" />
) : (
<TrendingDown className="w-4 h-4 text-destructive" />
)}
<span
className={`text-2xl font-bold tabular-nums ${isPositive ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(net_worth_crc)}
</span>
</div>
</div>
<div className="text-right text-xs space-y-1">
<p>
<span className="text-muted-foreground">Activos </span>
<span className="font-semibold text-green-500">
{fmtCRC(total_assets_crc)}
</span>
</p>
<p>
<span className="text-muted-foreground">Pasivos </span>
<span className="font-semibold text-destructive">
{fmtCRC(total_liabilities_crc)}
</span>
</p>
</div>
</div>
{/* Asset bar */}
{total_assets_crc + total_liabilities_crc > 0 && (
<div className="h-2 bg-secondary rounded-full overflow-hidden flex">
<div
className="h-full bg-green-500 transition-all"
style={{
width: `${Math.round(
(total_assets_crc /
(total_assets_crc + total_liabilities_crc)) *
100,
)}%`,
}}
/>
<div className="h-full bg-destructive flex-1" />
</div>
)}
{/* Accounts */}
{accounts.length > 0 && (
<div className="divide-y divide-border/50">
{accounts.map((a, i) => (
<div key={i} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium">{a.label || a.bank}</p>
<p className="text-[11px] text-muted-foreground">
{a.bank} · {a.account_type} · {a.currency}
</p>
</div>
<p
className={`text-sm font-semibold tabular-nums ${a.balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(a.balance_crc)}
</p>
</div>
))}
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Calculando
</div>
)}
</div>
);
}
// ── BudgetMonthCard ───────────────────────────────────────────────────────────
const MONTH_NAMES = [
"", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre",
];
export interface BudgetMonthArgs {
year?: number;
month?: number;
projected_income_crc?: number;
projected_expenses_crc?: number;
actual_total_crc?: number;
net_balance_crc?: number;
}
export function BudgetMonthCard({
args,
status,
}: {
args: BudgetMonthArgs;
status: string;
}) {
const {
year,
month,
projected_income_crc = 0,
projected_expenses_crc = 0,
actual_total_crc = 0,
net_balance_crc = 0,
} = args;
const usedPct =
projected_expenses_crc > 0
? Math.min(Math.round((actual_total_crc / projected_expenses_crc) * 100), 100)
: 0;
const isOver = actual_total_crc > projected_expenses_crc;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
Presupuesto
</p>
{month != null && year != null && (
<p className="text-sm font-semibold mt-0.5">
{MONTH_NAMES[month]} {year}
</p>
)}
</div>
<div
className={`text-right text-2xl font-bold tabular-nums ${net_balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(net_balance_crc)}
<p className="text-[11px] font-normal text-muted-foreground">
balance neto
</p>
</div>
</div>
{/* Rows */}
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Ingresos proyectados</span>
<span className="font-medium tabular-nums text-green-500">
{fmtCRC(projected_income_crc)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Gastos proyectados</span>
<span className="font-medium tabular-nums">
{fmtCRC(projected_expenses_crc)}
</span>
</div>
<div className="flex justify-between border-t border-border/50 pt-1.5">
<span className="text-muted-foreground">Gastado real</span>
<span
className={`font-semibold tabular-nums ${isOver ? "text-destructive" : ""}`}
>
{fmtCRC(actual_total_crc)}
</span>
</div>
</div>
{/* Progress bar */}
{projected_expenses_crc > 0 && (
<div>
<div className="flex justify-between text-[11px] text-muted-foreground mb-1">
<span>Ejecución presupuestaria</span>
<span className={isOver ? "text-destructive font-semibold" : ""}>
{usedPct}%
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${isOver ? "bg-destructive" : "bg-primary"}`}
style={{ width: `${usedPct}%` }}
/>
</div>
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Cargando
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { type ColumnDef } from '@tanstack/react-table';
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { type Transaction } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
interface TransactionColumnOptions {
showCategory: boolean;
showSourceIcon?: boolean;
onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void;
onToggleDeferred?: (tx: Transaction) => void;
}
export function getTransactionColumns({
showCategory,
showSourceIcon,
onEdit,
onDelete,
onToggleDeferred,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = [
{
accessorKey: 'date',
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.original.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
),
},
{
accessorKey: 'merchant',
header: ({ column }) => <DataTableColumnHeader column={column} title="Merchant" />,
cell: ({ row }) => {
const tx = row.original;
return (
<div className="flex items-center gap-2">
<div
className={cn(
'w-6 h-6 rounded flex items-center justify-center shrink-0',
tx.transaction_type === 'COMPRA'
? 'bg-destructive/10 text-destructive'
: 'bg-primary/10 text-primary',
)}
>
{tx.transaction_type === 'COMPRA' ? (
<TrendingDown className="w-3 h-3" />
) : (
<TrendingUp className="w-3 h-3" />
)}
</div>
<span className="truncate">{tx.merchant}</span>
{showSourceIcon && tx.source === 'CASH' && (
<Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
)}
{showSourceIcon && tx.source === 'TRANSFER' && (
<ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
)}
{tx.deferred_to_next_cycle && (
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-amber-600 border-amber-300">
Diferida
</Badge>
)}
</div>
);
},
},
];
if (showCategory) {
columns.push({
accessorFn: (row) => row.category?.name ?? '',
id: 'category',
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
cell: ({ row }) => {
const category = row.original.category;
return category ? (
<Badge variant="secondary">{category.name}</Badge>
) : (
<span className="text-xs text-muted-foreground">&mdash;</span>
);
},
});
}
columns.push(
{
accessorKey: 'amount',
meta: { className: 'text-right' },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" className="justify-end" />
),
cell: ({ row }) => {
const tx = row.original;
return (
<span
data-sensitive
className={cn(
'font-mono font-medium',
tx.transaction_type !== 'COMPRA' && 'text-primary',
)}
>
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
{formatAmount(tx.amount, tx.currency)}
</span>
);
},
},
{
id: 'actions',
meta: { className: 'text-right' },
size: 80,
enableSorting: false,
cell: ({ row }) => {
const tx = row.original;
return (
<div className="flex items-center justify-end gap-1">
{onToggleDeferred && (
<Button
variant="ghost"
size="icon"
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
onClick={() => onToggleDeferred(tx)}
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
>
<ArrowRightFromLine className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
title="Edit transaction"
aria-label="Edit transaction"
onClick={() => onEdit(tx)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete transaction"
aria-label="Delete transaction"
onClick={() => onDelete(tx.id)}
className="hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
},
},
);
return columns;
}

View File

@@ -0,0 +1,72 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,356 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,41 @@
import { type Column } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface DataTableColumnHeaderProps<TData, TValue> {
column: Column<TData, TValue>;
title: string;
className?: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
const sorted = column.getIsSorted();
return (
<Button
variant="ghost"
size="sm"
className={cn('-ml-3 h-8', className)}
onClick={() => column.toggleSorting(sorted === 'asc')}
>
{title}
{sorted === 'desc' ? (
<ArrowDown className="ml-1 h-3.5 w-3.5" />
) : sorted === 'asc' ? (
<ArrowUp className="ml-1 h-3.5 w-3.5" />
) : (
<ArrowUpDown className="ml-1 h-3.5 w-3.5 text-muted-foreground/50" />
)}
</Button>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pagination?: boolean;
pageSize?: number;
emptyMessage?: React.ReactNode;
initialSorting?: SortingState;
}
export function DataTable<TData, TValue>({
columns,
data,
pagination = false,
pageSize = 25,
emptyMessage = 'No results.',
initialSorting = [],
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination && {
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize } },
}),
});
return (
<div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={(header.column.columnDef.meta as Record<string, string>)?.className}
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={(cell.column.columnDef.meta as Record<string, string>)?.className}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{pagination && table.getPageCount() > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<p className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted/60", className)}
{...props}
/>
);
}

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:flex-row data-[orientation=vertical]:items-start",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
const PrivacyContext = createContext<{
privacyMode: boolean;
togglePrivacy: () => void;
}>({ privacyMode: false, togglePrivacy: () => {} });
export function PrivacyProvider({ children }: { children: ReactNode }) {
const [privacyMode, setPrivacyMode] = useState<boolean>(false);
useEffect(() => {
setPrivacyMode(localStorage.getItem("privacyMode") === "true");
}, []);
useEffect(() => {
document.documentElement.classList.toggle("privacy", privacyMode);
localStorage.setItem("privacyMode", String(privacyMode));
}, [privacyMode]);
const togglePrivacy = () => setPrivacyMode((p) => !p);
return (
<PrivacyContext.Provider value={{ privacyMode, togglePrivacy }}>
{children}
</PrivacyContext.Provider>
);
}
export const usePrivacy = () => useContext(PrivacyContext);

View File

@@ -0,0 +1,40 @@
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({ theme: "dark", toggleTheme: () => {} });
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
// Initialize once on mount (localStorage + prefers-color-scheme).
useEffect(() => {
const saved = localStorage.getItem("theme") as Theme | null;
const initial: Theme = saved
? saved
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
setTheme(initial);
}, []);
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@@ -0,0 +1,103 @@
import { useState, useEffect, useCallback } from 'react';
import {
type YearlyProjection,
type MonthlyDetail,
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
getYearlyProjection,
getMonthlyDetail,
getRecurringItems,
createRecurringItem,
updateRecurringItem as apiUpdateItem,
deleteRecurringItem as apiDeleteItem,
upsertBalanceOverride,
deleteBalanceOverride,
} from '@/lib/api';
export function useBudget(initialYear: number) {
const [year, setYear] = useState(initialYear);
const [selectedMonth, setSelectedMonth] = useState<number>(new Date().getMonth() + 1);
const [projection, setProjection] = useState<YearlyProjection | null>(null);
const [monthDetail, setMonthDetail] = useState<MonthlyDetail | null>(null);
const [recurringItems, setRecurringItems] = useState<RecurringItem[]>([]);
const [loading, setLoading] = useState(true);
const [monthLoading, setMonthLoading] = useState(false);
const fetchProjection = useCallback(async () => {
setLoading(true);
try {
const { data } = await getYearlyProjection(year);
setProjection(data);
} finally {
setLoading(false);
}
}, [year]);
const fetchMonthDetail = useCallback(async () => {
setMonthLoading(true);
try {
const { data } = await getMonthlyDetail(year, selectedMonth);
setMonthDetail(data);
} finally {
setMonthLoading(false);
}
}, [year, selectedMonth]);
const fetchRecurringItems = useCallback(async () => {
const { data } = await getRecurringItems();
setRecurringItems(data);
}, []);
useEffect(() => {
fetchProjection();
fetchRecurringItems();
}, [fetchProjection, fetchRecurringItems]);
useEffect(() => {
fetchMonthDetail();
}, [fetchMonthDetail]);
const addItem = async (data: RecurringItemCreate) => {
await createRecurringItem(data);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
const updateItem = async (id: number, data: RecurringItemUpdate) => {
await apiUpdateItem(id, data);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
const deleteItem = async (id: number) => {
await apiDeleteItem(id);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
const saveBalanceOverride = async (overrideYear: number, month: number, value: number) => {
await upsertBalanceOverride(overrideYear, month, value);
await fetchProjection();
};
const clearBalanceOverride = async (overrideYear: number, month: number) => {
await deleteBalanceOverride(overrideYear, month);
await fetchProjection();
};
return {
year,
setYear,
selectedMonth,
setSelectedMonth,
projection,
monthDetail,
recurringItems,
loading,
monthLoading,
addItem,
updateItem,
deleteItem,
saveBalanceOverride,
clearBalanceOverride,
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
};
}

View File

@@ -1,46 +1,169 @@
@import 'tailwindcss';
@import "@fontsource-variable/noto-sans";
@import "@fontsource-variable/ibm-plex-sans";
@import "tailwindcss";
@import "tw-animate-css";
@import "@copilotkit/react-core/v2/styles.css";
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:is(.dark *));
@theme {
--color-surface: #FFFDF5;
--color-surface-secondary: #fefae0;
--color-surface-card: rgba(96, 108, 56, 0.08);
--color-surface-hover: rgba(96, 108, 56, 0.12);
--color-border: rgba(96, 108, 56, 0.25);
--color-border-subtle: rgba(96, 108, 56, 0.15);
--color-text-primary: #283618;
--color-text-secondary: #606C38;
--color-text-muted: #8a9462;
--color-text-faint: #c2c9a7;
--color-input-bg: #f5f1d0;
:root {
--font-sans: "Noto Sans Variable", sans-serif;
--font-heading: "IBM Plex Sans Variable", sans-serif;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.511 0.096 186.391);
--primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.55 0.16 145);
--chart-2: oklch(0.62 0.19 25);
--chart-3: oklch(0.58 0.14 250);
--chart-4: oklch(0.68 0.15 80);
--chart-5: oklch(0.52 0.13 320);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.6 0.118 184.704);
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--copilot-kit-primary-color: var(--primary);
}
.dark {
--color-surface: #020617;
--color-surface-secondary: #0f172a;
--color-surface-card: rgba(15, 23, 42, 0.6);
--color-surface-hover: rgba(30, 41, 59, 0.3);
--color-border: rgba(30, 41, 59, 0.6);
--color-border-subtle: rgba(30, 41, 59, 0.4);
--color-text-primary: #ffffff;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-faint: #334155;
--color-input-bg: rgba(15, 23, 42, 0.8);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.437 0.078 188.216);
--primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.60 0.16 145);
--chart-2: oklch(0.67 0.19 25);
--chart-3: oklch(0.63 0.14 250);
--chart-4: oklch(0.73 0.15 80);
--chart-5: oklch(0.57 0.13 320);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.704 0.14 182.503);
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--copilot-kit-primary-color: var(--primary);
}
body {
background-color: var(--color-surface);
color: var(--color-text-primary);
transition: background-color 0.2s, color 0.2s;
/* Wire CopilotKit v2 CSS variables to WealthySmart's dark palette.
The v2 CSS sets --background/--muted/etc directly on [data-copilotkit]
elements (unlayered), overriding inherited values from .dark on <html>.
Using html.dark [data-copilotkit] (specificity 0,2,1) beats the v2's
own .dark [data-copilotkit] (specificity 0,2,0) and restores dark mode. */
html.dark [data-copilotkit] {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.437 0.078 188.216);
--primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
@theme inline {
--font-sans: var(--font-sans);
--font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
.animate-fade-in {
animation: fade-in 0.4s ease-out both;
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
}
}
/* Privacy mode: blur sensitive financial data */
.privacy [data-sensitive] {
filter: blur(8px);
user-select: none;
transition: filter 0.2s ease;
}

470
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,470 @@
const BASE_URL = "/api/v1";
class ApiError extends Error {
response: { status: number; data: unknown };
constructor(status: number, data: unknown) {
super(`Request failed with status ${status}`);
this.response = { status, data };
}
}
interface RequestConfig {
params?: Record<string, string | number | boolean | undefined>;
}
async function request<T>(
method: string,
url: string,
body?: unknown,
config?: RequestConfig,
): Promise<{ data: T }> {
let fullUrl = `${BASE_URL}${url}`;
if (config?.params) {
const search = new URLSearchParams();
for (const [k, v] of Object.entries(config.params)) {
if (v !== undefined) search.set(k, String(v));
}
const qs = search.toString();
if (qs) fullUrl += `?${qs}`;
}
const headers: Record<string, string> = {};
let fetchBody: BodyInit | undefined;
if (body instanceof FormData || body instanceof URLSearchParams) {
fetchBody = body;
} else if (body !== undefined) {
headers["Content-Type"] = "application/json";
fetchBody = JSON.stringify(body);
}
const res = await fetch(fullUrl, {
method,
headers,
body: fetchBody,
credentials: "same-origin",
});
if (res.status === 401) {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
if (typeof window !== "undefined") window.location.replace("/login");
throw new ApiError(401, null);
}
if (!res.ok) {
let data: unknown = null;
try {
data = await res.json();
} catch {}
throw new ApiError(res.status, data);
}
if (res.status === 204) return { data: null as T };
const data = await res.json();
return { data };
}
const api = {
get<T = unknown>(url: string, config?: RequestConfig) {
return request<T>("GET", url, undefined, config);
},
post<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("POST", url, body, config);
},
patch<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("PATCH", url, body, config);
},
put<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("PUT", url, body, config);
},
delete<T = unknown>(url: string, config?: RequestConfig) {
return request<T>("DELETE", url, undefined, config);
},
};
export default api;
export async function login(username: string, password: string) {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "same-origin",
});
if (!res.ok) {
let data: unknown = null;
try {
data = await res.json();
} catch {}
throw new ApiError(res.status, data);
}
return res.json();
}
export async function logout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
}
// ─── 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 interface Category {
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export interface ImportResult {
imported: number;
duplicates: number;
errors: string[];
}
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;
}
// --- Budget / Recurring Items ---
export type RecurringItemType = "INCOME" | "EXPENSE";
export type RecurringFrequency =
| "WEEKLY"
| "MONTHLY"
| "QUARTERLY"
| "BIANNUAL"
| "YEARLY";
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 interface RecurringItemCreate {
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;
}
export interface RecurringItemUpdate {
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;
}
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 interface ActualsBySource {
source: string;
total_compra: number;
total_devolucion: number;
net: number;
count: number;
}
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 interface YearlyProjection {
year: number;
months: MonthlyProjection[];
annual_income: number;
annual_expenses: number;
annual_net: number;
}
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 }[];
}
// --- 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 interface SavingsAccrualCreate {
year: number;
month: number;
memp_amount?: number;
mpat_amount?: number;
trigger_transaction_id?: number | null;
notes?: string | null;
}
export interface SavingsAccrualUpdate {
memp_amount?: number;
mpat_amount?: number;
notes?: string | null;
}
export const getSavingsAccruals = () =>
api.get<SavingsAccrual[]>("/savings-accrual/");
export const createSavingsAccrual = (data: SavingsAccrualCreate) =>
api.post<SavingsAccrual>("/savings-accrual/", data);
export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) =>
api.patch<SavingsAccrual>(`/savings-accrual/${id}`, data);
export const deleteSavingsAccrual = (id: number) =>
api.delete(`/savings-accrual/${id}`);
// --- Budget ---
export const getRecurringItems = (params?: {
item_type?: string;
is_active?: boolean;
}) => api.get<RecurringItem[]>("/budget/recurring", { params });
export const createRecurringItem = (data: RecurringItemCreate) =>
api.post<RecurringItem>("/budget/recurring", data);
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
export const deleteRecurringItem = (id: number) =>
api.delete(`/budget/recurring/${id}`);
export const getYearlyProjection = (year: number) =>
api.get<YearlyProjection>(`/budget/projection/${year}`);
export const getMonthlyDetail = (year: number, month: number) =>
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
export const upsertBalanceOverride = (
year: number,
month: number,
override_balance: number,
) =>
api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
export const deleteBalanceOverride = (year: number, month: number) =>
api.delete(`/budget/balance-override/${year}/${month}`);
// --- Salarios ---
export interface SalariosSummary {
count: number;
total_amount: number;
latest_date: string | null;
}
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>("/salarios/", { params });
export const getSalariosSummary = () =>
api.get<SalariosSummary>("/salarios/summary");
// --- 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 interface PensionUploadResult {
imported: number;
updated: number;
duplicates: number;
errors: string[];
snapshots: PensionSnapshot[];
}
export interface PensionManualEntry {
fund: 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;
}
export const uploadPensionPDFs = (files: File[]) => {
const form = new FormData();
files.forEach((f) => form.append("files", f));
return api.post<PensionUploadResult>("/pensions/upload", form);
};
export const getPensionSnapshots = () =>
api.get<PensionSnapshot[]>("/pensions/snapshots");
export const getPensionFundSummary = () =>
api.get<PensionSnapshot[]>("/pensions/fund-summary");
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
api.post<PensionUploadResult>("/pensions/manual", { entries });
// --- Municipal Receipts ---
export interface MunicipalCharge {
detail: string;
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 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 interface MunicipalReceiptDetail extends MunicipalReceipt {
water_readings: WaterMeterReading[];
}
export interface MunicipalReceiptUploadResult {
imported: number;
updated: number;
errors: string[];
receipt: MunicipalReceipt | null;
}
export const uploadMunicipalReceipt = (file: File) => {
const form = new FormData();
form.append("file", file);
return api.post<MunicipalReceiptUploadResult>(
"/municipal-receipts/upload",
form,
);
};
export const getMunicipalReceipts = () =>
api.get<MunicipalReceipt[]>("/municipal-receipts/");
export const getMunicipalReceiptDetail = (id: number) =>
api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`);
export const getWaterConsumption = (months?: number) =>
api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", {
params: months ? { months } : undefined,
});

View File

@@ -0,0 +1,25 @@
export interface ColorClasses {
bg: string;
ring: string;
text: string;
borderLeft: string;
}
export const COLOR_MAP: Record<string, ColorClasses> = {
'primary': { bg: 'bg-primary/10', ring: 'ring-primary/20', text: 'text-primary', borderLeft: 'border-l-primary' },
'destructive': { bg: 'bg-destructive/10', ring: 'ring-destructive/20', text: 'text-destructive', borderLeft: 'border-l-destructive' },
'chart-1': { bg: 'bg-chart-1/10', ring: 'ring-chart-1/20', text: 'text-chart-1', borderLeft: 'border-l-chart-1' },
'chart-2': { bg: 'bg-chart-2/10', ring: 'ring-chart-2/20', text: 'text-chart-2', borderLeft: 'border-l-chart-2' },
'chart-3': { bg: 'bg-chart-3/10', ring: 'ring-chart-3/20', text: 'text-chart-3', borderLeft: 'border-l-chart-3' },
'chart-4': { bg: 'bg-chart-4/10', ring: 'ring-chart-4/20', text: 'text-chart-4', borderLeft: 'border-l-chart-4' },
'chart-5': { bg: 'bg-chart-5/10', ring: 'ring-chart-5/20', text: 'text-chart-5', borderLeft: 'border-l-chart-5' },
'accent': { bg: 'bg-accent/10', ring: 'ring-accent/20', text: 'text-accent-foreground', borderLeft: 'border-l-accent' },
'muted': { bg: 'bg-muted/50', ring: 'ring-muted', text: 'text-muted-foreground', borderLeft: 'border-l-muted' },
'secondary': { bg: 'bg-secondary/50', ring: 'ring-secondary', text: 'text-secondary-foreground', borderLeft: 'border-l-secondary' },
};
export const COLOR_OPTIONS = Object.keys(COLOR_MAP);
export function getColorClasses(colorName: string): ColorClasses {
return COLOR_MAP[colorName] ?? COLOR_MAP['primary'];
}

View File

@@ -0,0 +1,21 @@
export function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount);
if (currency === 'BTC') return abs.toFixed(8);
if (currency === 'XMR') return abs.toFixed(4);
if (currency === 'USD') {
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
if (currency === 'EUR') {
return `${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
return `${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function formatLocalDatetime(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}

View File

@@ -0,0 +1,179 @@
export interface PensionParsedEntry {
fund: string;
period_start: string; // YYYY-MM-DD
period_end: string;
saldo_anterior: number;
aportes: number;
rendimientos: number;
retiros: number;
traslados: number;
comision: number;
correccion: number;
bonificacion: number;
saldo_final: number;
}
function parseAmount(raw: string): number {
const cleaned = raw.replace(/[¢\s]/g, '').replace(/,/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? 0 : num;
}
function parseDateDMY(raw: string): string {
const m = raw.match(/(\d{2})\/(\d{2})\/(\d{4})/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]}`;
}
function extractAmounts(line: string): number[] {
const matches = line.match(/¢\s*-?[\d,.]+/g);
if (!matches) return [];
return matches.map(parseAmount);
}
// Field labels in the order they appear in the bank statement
const FIELD_LABELS: [RegExp, string][] = [
[/saldo\s*anterior/i, 'saldo_anterior'],
[/aportes/i, 'aportes'],
[/rendimientos/i, 'rendimientos'],
[/retiros/i, 'retiros'],
[/traslados/i, 'traslados'],
[/comisi[oó]n/i, 'comision'],
[/bonificaci[oó]n/i, 'bonificacion'],
];
interface BlockResult {
funds: string[];
fields: Record<string, number[]>;
period_start: string;
period_end: string;
}
function parseBlock(lines: string[]): BlockResult | null {
const result: BlockResult = {
funds: [],
fields: {},
period_start: '',
period_end: '',
};
// Detect fund columns from header
const headerLine = lines.find((l) => /resumen del per[ií]odo/i.test(l));
if (!headerLine) return null;
if (/\bROP\b/i.test(headerLine) && /\bFCL\b/i.test(headerLine)) {
result.funds = ['ROP', 'FCL'];
} else if (/voluntario/i.test(headerLine) || /\bVOL\b/i.test(headerLine)) {
result.funds = ['VOL'];
} else {
return null;
}
// Strategy 1: Try same-line parsing (label + amounts on same line)
// Strategy 2: Collect standalone amount lines for split-format parsing
const detectedFieldOrder: string[] = [];
const standaloneAmounts: number[] = [];
for (const line of lines) {
// Check for period
const periodMatch = line.match(/del\s+(\d{2}\/\d{2}\/\d{4})\s+al\s+(\d{2}\/\d{2}\/\d{4})/i);
if (periodMatch) {
result.period_start = parseDateDMY(periodMatch[1]);
result.period_end = parseDateDMY(periodMatch[2]);
continue;
}
// Check for "Saldo Actual" line (always has amounts inline)
if (/saldo\s*actual/i.test(line)) {
const amounts = extractAmounts(line);
if (amounts.length > 0) {
result.fields['saldo_final'] = amounts;
}
continue;
}
// Check if this line matches a field label
let matchedLabel = false;
for (const [regex, key] of FIELD_LABELS) {
if (regex.test(line)) {
matchedLabel = true;
const amounts = extractAmounts(line);
if (amounts.length > 0) {
// Strategy 1: amounts on same line as label
result.fields[key] = amounts;
} else {
// Strategy 2: label-only line, record the order
detectedFieldOrder.push(key);
}
break;
}
}
// If not a label line, check if it's a standalone amount line
if (!matchedLabel) {
const amounts = extractAmounts(line);
if (amounts.length === 1) {
standaloneAmounts.push(amounts[0]);
}
}
}
// If we have standalone amounts and field labels, map them
// Format: N labels, then N amounts for fund1, then N amounts for fund2, ...
if (detectedFieldOrder.length > 0 && standaloneAmounts.length > 0) {
const numFields = detectedFieldOrder.length;
const numFunds = result.funds.length;
if (standaloneAmounts.length >= numFields * numFunds) {
for (let f = 0; f < numFunds; f++) {
for (let i = 0; i < numFields; i++) {
const key = detectedFieldOrder[i];
if (!result.fields[key]) result.fields[key] = [];
result.fields[key].push(standaloneAmounts[f * numFields + i]);
}
}
}
}
return result;
}
export function parsePensionPaste(text: string): PensionParsedEntry[] {
// Split into blocks by "---" or multiple blank lines
const blocks = text.split(/(?:^|\n)-{3,}(?:\n|$)|\n{3,}/);
const entries: PensionParsedEntry[] = [];
for (const block of blocks) {
const lines = block.split('\n').filter((l) => l.trim());
if (lines.length < 3) continue;
const parsed = parseBlock(lines);
if (!parsed || !parsed.period_start || !parsed.period_end) continue;
for (let i = 0; i < parsed.funds.length; i++) {
const fund = parsed.funds[i];
const get = (key: string): number => {
const vals = parsed.fields[key];
if (!vals) return 0;
return vals[i] ?? vals[0] ?? 0;
};
entries.push({
fund,
period_start: parsed.period_start,
period_end: parsed.period_end,
saldo_anterior: get('saldo_anterior'),
aportes: get('aportes'),
rendimientos: get('rendimientos'),
retiros: get('retiros'),
traslados: get('traslados'),
comision: get('comision'),
correccion: 0,
bonificacion: get('bonificacion'),
saldo_final: get('saldo_final'),
});
}
}
return entries;
}

View File

@@ -0,0 +1,51 @@
import api from './api';
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export async function subscribeToPush(): Promise<void> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
return;
}
try {
const { data } = await api.get<{ publicKey: string }>('/notifications/vapid-public-key');
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) {
await sendSubscriptionToServer(existing);
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.publicKey),
});
await sendSubscriptionToServer(subscription);
} catch (err) {
console.warn('Push subscription failed:', err);
}
}
async function sendSubscriptionToServer(subscription: PushSubscription): Promise<void> {
const json = subscription.toJSON();
await api.post('/notifications/subscribe', {
endpoint: json.endpoint,
keys: json.keys,
});
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,14 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}

View File

@@ -7,16 +7,20 @@ import {
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
} from 'recharts';
import { BarChart3 } from 'lucide-react';
import api from '../api';
import BillingCycleSelector from '../components/BillingCycleSelector';
import { useTheme } from '../ThemeContext';
import api from '@/lib/api';
import BillingCycleSelector from '@/components/BillingCycleSelector';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
interface CategorySpending {
category_id: number | null;
@@ -42,18 +46,29 @@ interface DailySpending {
}
const COLORS = [
'#606C38', '#BC6C25', '#8b5cf6', '#DDA15E', '#ef4444',
'#ec4899', '#283618', '#f97316', '#6366f1', '#8a9462',
'#e879f9', '#c2c9a7', '#fb923c', '#a78bfa', '#7a8a4a',
'#fbbf24',
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
];
function formatCRC(value: number) {
return `${Math.round(value).toLocaleString('es-CR')}`;
}
const trendChartConfig = {
total_crc: {
label: 'Total CRC',
color: 'var(--chart-1)',
},
} satisfies ChartConfig;
const dailyChartConfig = {
total: {
label: 'Daily Spending',
color: 'var(--chart-2)',
},
} satisfies ChartConfig;
export default function Analytics() {
const { theme } = useTheme();
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const [byCategory, setByCategory] = useState<CategorySpending[]>([]);
const [trend, setTrend] = useState<MonthlyTrend[]>([]);
@@ -79,194 +94,216 @@ export default function Analytics() {
.catch(console.error);
}, [cycle]);
const tooltipStyle = {
background: theme === 'dark' ? '#1e293b' : '#FEFAE0',
border: `1px solid ${theme === 'dark' ? '#334155' : 'rgba(96,108,56,0.25)'}`,
borderRadius: '8px',
fontSize: '12px',
color: theme === 'dark' ? '#e2e8f0' : '#283618',
};
const tickColor = theme === 'dark' ? '#64748b' : '#8a9462';
// Build dynamic chart config for pie chart
const pieChartConfig = byCategory.reduce<ChartConfig>((acc, cat, i) => {
acc[cat.category_name] = {
label: cat.category_name,
color: COLORS[i % COLORS.length],
};
return acc;
}, {});
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a]" />
<h1 className="text-2xl font-bold">Analytics</h1>
<BarChart3 className="w-5 h-5 text-primary" />
<h1 className="text-2xl font-bold font-heading">Analytics</h1>
</div>
<p className="text-sm text-text-muted mt-1">Spending breakdown and trends</p>
<p className="text-sm text-muted-foreground mt-1">Spending breakdown and trends</p>
</div>
<BillingCycleSelector value={cycle} onChange={setCycle} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Spending by Category - Donut */}
<div className="bg-surface-card border border-border rounded-xl p-5">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Spending by Category
</h2>
{byCategory.length === 0 ? (
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
No data for this period
</div>
) : (
<div className="flex flex-col items-center">
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={byCategory}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
strokeWidth={0}
>
{byCategory.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: any) => formatCRC(Number(value))}
/>
</PieChart>
</ResponsiveContainer>
{/* Legend */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
{byCategory.slice(0, 10).map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-text-secondary truncate">{cat.category_name}</span>
<span className="text-text-faint ml-auto">{cat.percentage}%</span>
</div>
))}
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
Spending by Category
</CardTitle>
</CardHeader>
<CardContent>
{byCategory.length === 0 ? (
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
No data for this period
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center">
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
<PieChart>
<Pie
data={byCategory}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
strokeWidth={0}
>
{byCategory.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatCRC(Number(value))}
/>
}
/>
</PieChart>
</ChartContainer>
{/* Legend */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
{byCategory.slice(0, 10).map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-muted-foreground truncate">{cat.category_name}</span>
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Monthly Trend - Bar */}
<div className="bg-surface-card border border-border rounded-xl p-5">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Monthly Spending (CRC)
</h2>
{trend.length === 0 ? (
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
No data
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={trend}>
<XAxis
dataKey="label"
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: any) => formatCRC(Number(value))}
/>
<Bar dataKey="total_crc" fill="#606C38" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
Monthly Spending (CRC)
</CardTitle>
</CardHeader>
<CardContent>
{trend.length === 0 ? (
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
No data
</div>
) : (
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
<BarChart data={trend}>
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
/>
<YAxis
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatCRC(Number(value))}
/>
}
/>
<Bar dataKey="total_crc" fill="var(--color-total_crc)" radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
{/* Daily Spending - Line */}
<div className="bg-surface-card border border-border rounded-xl p-5 lg:col-span-2">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Daily Spending
</h2>
{daily.length === 0 ? (
<div className="h-48 flex items-center justify-center text-text-faint text-sm">
No data for this period
</div>
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={daily}>
<XAxis
dataKey="date"
tick={{ fill: tickColor, fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => {
const d = new Date(v);
return `${d.getMonth() + 1}/${d.getDate()}`;
}}
/>
<YAxis
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: any) => formatCRC(Number(value))}
labelFormatter={(label) => new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
/>
<Line
type="monotone"
dataKey="total"
stroke="#BC6C25"
strokeWidth={2}
dot={{ fill: '#BC6C25', r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
Daily Spending
</CardTitle>
</CardHeader>
<CardContent>
{daily.length === 0 ? (
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm">
No data for this period
</div>
) : (
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
<LineChart data={daily}>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tickFormatter={(v) => {
const d = new Date(v);
return `${d.getMonth() + 1}/${d.getDate()}`;
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatCRC(Number(value))}
labelFormatter={(label) =>
new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
/>
}
/>
<Line
type="monotone"
dataKey="total"
stroke="var(--color-total)"
strokeWidth={2}
dot={{ fill: 'var(--color-total)', r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
</div>
{/* Top categories summary */}
{byCategory.length > 0 && (
<div className="bg-surface-card border border-border rounded-xl p-5">
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Top Categories
</h2>
<div className="space-y-3">
{byCategory.slice(0, 8).map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-sm flex-1">{cat.category_name}</span>
<span className="text-xs text-text-muted">{cat.count} txns</span>
<span className="text-sm font-mono font-medium w-32 text-right">
{formatCRC(cat.total)}
</span>
<div className="w-24 bg-surface-hover rounded-full h-1.5">
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
Top Categories
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{byCategory.slice(0, 8).map((cat, i) => (
<div key={cat.category_name} className="flex items-center gap-3">
<div
className="h-1.5 rounded-full"
style={{
width: `${cat.percentage}%`,
background: COLORS[i % COLORS.length],
}}
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-sm flex-1">{cat.category_name}</span>
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
<span data-sensitive className="text-sm font-mono font-medium w-32 text-right">
{formatCRC(cat.total)}
</span>
<div data-sensitive className="w-24 bg-muted rounded-full h-1.5">
<div
className="h-1.5 rounded-full"
style={{
width: `${cat.percentage}%`,
background: COLORS[i % COLORS.length],
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);

View File

@@ -0,0 +1,80 @@
import { CopilotChat, useConfigureSuggestions } from "@copilotkit/react-core/v2";
import { useCopilotAction } from "@copilotkit/react-core";
import { Sparkles } from "lucide-react";
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
const STATIC_SUGGESTIONS = {
available: "before-first-message" as const,
suggestions: [
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
],
};
export default function Asistente() {
useConfigureSuggestions(STATIC_SUGGESTIONS);
useCopilotAction({
name: "render_spending_summary",
description:
"Render a visual spending summary card with source breakdown and category progress bars. " +
"Call this for any cycle summary, spending totals, or category breakdown.",
parameters: [
{ name: "title", type: "string", description: "Card title (e.g. 'Ciclo actual')" },
{ name: "period", type: "string", description: "Human-readable period (e.g. '18 mar → 18 abr 2026')" },
{ name: "total_crc", type: "number", description: "Total spend in CRC" },
{
name: "by_source",
type: "object[]",
description: "Breakdown by payment source",
attributes: [
{ name: "source", type: "string" },
{ name: "total_crc", type: "number" },
{ name: "count", type: "number" },
],
},
{
name: "by_category",
type: "object[]",
required: false,
description: "Top spending categories with CRC amounts",
attributes: [
{ name: "category", type: "string" },
{ name: "amount_crc", type: "number" },
{ name: "count", type: "number" },
],
},
],
handler: async () => "ok",
render: (props) => (
<SpendingSummaryCard args={props.args as SpendingSummaryArgs} status={props.status} />
),
});
return (
<div className="flex flex-col h-[calc(100vh-105px)]">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
<Sparkles className="w-5 h-5 text-primary" />
Asistente
</h1>
<p className="text-sm text-muted-foreground">
Pregúntale a WealthySmart sobre tus finanzas.
</p>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat
className="h-full"
labels={{
modalHeaderTitle: "WealthySmart",
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
chatInputPlaceholder: "Escribe tu pregunta…",
}}
/>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More