From 31f8274b8d55f6541651904285fdc592b5fd4242 Mon Sep 17 00:00:00 2001 From: JoeBot Date: Thu, 19 Mar 2026 16:11:32 -0400 Subject: [PATCH 1/8] Upload files to "load-tests" load test files --- load-tests/auth-dashboard-flow.js | 183 +++++++++++++++++++++ load-tests/baseline.json | 45 ++++++ load-tests/crud-flow.js | 259 ++++++++++++++++++++++++++++++ load-tests/cycle-template.md | 117 ++++++++++++++ load-tests/environments.json | 38 +++++ 5 files changed, 642 insertions(+) create mode 100644 load-tests/auth-dashboard-flow.js create mode 100644 load-tests/baseline.json create mode 100644 load-tests/crud-flow.js create mode 100644 load-tests/cycle-template.md create mode 100644 load-tests/environments.json diff --git a/load-tests/auth-dashboard-flow.js b/load-tests/auth-dashboard-flow.js new file mode 100644 index 0000000..aaf5a90 --- /dev/null +++ b/load-tests/auth-dashboard-flow.js @@ -0,0 +1,183 @@ +/** + * HOALedgerIQ – Auth + Dashboard Load Test + * Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout + * + * Covers the highest-frequency production flow: a treasurer or admin + * opening the app, loading the dashboard, and reviewing financial reports. + */ + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { SharedArray } from 'k6/data'; +import { Trend, Rate, Counter } from 'k6/metrics'; + +// ── Custom metrics ────────────────────────────────────────────────────────── +const loginDuration = new Trend('login_duration', true); +const dashboardDuration = new Trend('dashboard_duration', true); +const refreshDuration = new Trend('refresh_duration', true); +const authErrorRate = new Rate('auth_error_rate'); +const dashboardErrorRate = new Rate('dashboard_error_rate'); +const tokenRefreshCount = new Counter('token_refresh_count'); + +// ── User pool ──────────────────────────────────────────────────────────────── +const users = new SharedArray('users', function () { + return open('../config/user-pool.csv') + .split('\n') + .slice(1) // skip header row + .filter(line => line.trim()) + .map(line => { + const [email, password, orgId, role] = line.split(','); + return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() }; + }); +}); + +// ── Environment config ─────────────────────────────────────────────────────── +const ENV = __ENV.TARGET_ENV || 'staging'; +const envConfig = JSON.parse(open('../config/environments.json'))[ENV]; +const BASE_URL = envConfig.baseUrl; + +// ── Test options ───────────────────────────────────────────────────────────── +export const options = { + scenarios: { + auth_dashboard: { + executor: 'ramping-vus', + stages: [ + { duration: '2m', target: 20 }, // warm up + { duration: '5m', target: 100 }, // ramp to target load + { duration: '5m', target: 100 }, // sustained load + { duration: '3m', target: 200 }, // peak spike + { duration: '2m', target: 0 }, // ramp down + ], + }, + }, + thresholds: { + // Latency targets per environment (overridden by environments.json) + 'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`], + 'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`], + 'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`], + 'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`], + 'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`], + 'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`], + 'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`], + }, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── +function authHeaders(token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; +} + +// ── Main scenario ──────────────────────────────────────────────────────────── +export default function () { + const user = users[__VU % users.length]; + let accessToken = null; + + // ── 1. Login ──────────────────────────────────────────────────────────── + group('auth:login', () => { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email: user.email, password: user.password }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } } + ); + + loginDuration.add(res.timings.duration); + const ok = check(res, { + 'login 200': r => r.status === 200, + 'has access_token': r => r.json('access_token') !== undefined, + 'has orgId in body': r => r.json('user.orgId') !== undefined, + }); + authErrorRate.add(!ok); + if (!ok) { sleep(1); return; } + + accessToken = res.json('access_token'); + // httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar + }); + + if (!accessToken) return; + sleep(1.5); // think time – user lands on dashboard + + // ── 2. Load dashboard & key reports in parallel ───────────────────────── + group('dashboard:load', () => { + const requests = { + dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`], + balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`], + income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`], + profile: ['GET', `${BASE_URL}/api/auth/profile`], + accounts: ['GET', `${BASE_URL}/api/accounts`], + }; + + const responses = http.batch( + Object.entries(requests).map(([name, [method, url]]) => ({ + method, url, + params: { headers: authHeaders(accessToken), tags: { name } }, + })) + ); + + let allOk = true; + responses.forEach((res, i) => { + const name = Object.keys(requests)[i]; + dashboardDuration.add(res.timings.duration, { endpoint: name }); + const ok = check(res, { + [`${name} 200`]: r => r.status === 200, + [`${name} has body`]: r => r.body && r.body.length > 0, + }); + if (!ok) allOk = false; + }); + dashboardErrorRate.add(!allOk); + }); + + sleep(2); // user reads the dashboard + + // ── 3. Simulate token refresh (happens automatically in-app at 55min) ──── + // In the load test we trigger it early to validate the refresh path under load + group('auth:refresh', () => { + const res = http.post( + `${BASE_URL}/api/auth/refresh`, + null, + { + headers: authHeaders(accessToken), + tags: { name: 'refresh' }, + // k6 sends the httpOnly cookie from the jar automatically + } + ); + + refreshDuration.add(res.timings.duration); + tokenRefreshCount.add(1); + const ok = check(res, { + 'refresh 200': r => r.status === 200, + 'new access_token': r => r.json('access_token') !== undefined, + }); + authErrorRate.add(!ok); + if (ok) accessToken = res.json('access_token'); + }); + + sleep(1); + + // ── 4. Drill into one report (cash-flow forecast – typically slowest) ──── + group('dashboard:drill', () => { + const res = http.get( + `${BASE_URL}/api/reports/cash-flow-forecast`, + { headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } } + ); + dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' }); + dashboardErrorRate.add(res.status !== 200); + check(res, { 'forecast 200': r => r.status === 200 }); + }); + + sleep(2); + + // ── 5. Logout ──────────────────────────────────────────────────────────── + group('auth:logout', () => { + const res = http.post( + `${BASE_URL}/api/auth/logout`, + null, + { headers: authHeaders(accessToken), tags: { name: 'logout' } } + ); + check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 }); + }); + + sleep(1); +} diff --git a/load-tests/baseline.json b/load-tests/baseline.json new file mode 100644 index 0000000..4fb7065 --- /dev/null +++ b/load-tests/baseline.json @@ -0,0 +1,45 @@ +{ + "_meta": { + "description": "Baseline p50/p95/p99 latency targets per endpoint. Update after each cycle where improvements are confirmed. Claude Code will tighten k6 thresholds in environments.json to match.", + "last_updated": "YYYY-MM-DD", + "last_run_cycle": 0, + "units": "milliseconds" + }, + "auth": { + "POST /api/auth/login": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/auth/refresh": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/auth/logout": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/auth/profile": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "reports": { + "GET /api/reports/dashboard": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/balance-sheet": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/income-statement": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/cash-flow": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/cash-flow-forecast": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/aging": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/quarterly": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "accounts": { + "GET /api/accounts": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "journal_entries": { + "GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "budgets": { + "GET /api/budgets/:year": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/budgets/:year/vs-actual": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "invoices": { + "GET /api/invoices": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/invoices/generate-preview": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "payments": { + "GET /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null } + } +} diff --git a/load-tests/crud-flow.js b/load-tests/crud-flow.js new file mode 100644 index 0000000..affb72c --- /dev/null +++ b/load-tests/crud-flow.js @@ -0,0 +1,259 @@ +/** + * HOALedgerIQ – Core CRUD Workflow Load Test + * Journey: Login → Create Journal Entry → Post It → Create Invoice → + * Record Payment → View Accounts → Budget vs Actual → Logout + * + * This scenario exercises write-heavy paths gated by WriteAccessGuard + * and the TenantMiddleware schema-switch. Run this alongside + * auth-dashboard-flow.js to simulate a realistic mixed workload. + * + * Role used: treasurer (has full write access, most common power user) + */ + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { SharedArray } from 'k6/data'; +import { Trend, Rate } from 'k6/metrics'; +import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; + +// ── Custom metrics ────────────────────────────────────────────────────────── +const journalEntryDuration = new Trend('journal_entry_duration', true); +const invoiceDuration = new Trend('invoice_duration', true); +const paymentDuration = new Trend('payment_duration', true); +const accountsReadDuration = new Trend('accounts_read_duration', true); +const budgetDuration = new Trend('budget_vs_actual_duration',true); +const crudErrorRate = new Rate('crud_error_rate'); +const writeGuardErrorRate = new Rate('write_guard_error_rate'); + +// ── User pool (treasurer + admin roles only for write access) ──────────────── +const users = new SharedArray('users', function () { + return open('../config/user-pool.csv') + .split('\n') + .slice(1) + .filter(line => line.trim()) + .map(line => { + const [email, password, orgId, role] = line.split(','); + return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() }; + }) + .filter(u => ['treasurer', 'admin', 'president', 'manager'].includes(u.role)); +}); + +// ── Environment config ─────────────────────────────────────────────────────── +const ENV = __ENV.TARGET_ENV || 'staging'; +const envConfig = JSON.parse(open('../config/environments.json'))[ENV]; +const BASE_URL = envConfig.baseUrl; + +// ── Test options ───────────────────────────────────────────────────────────── +export const options = { + scenarios: { + crud_workflow: { + executor: 'ramping-vus', + stages: [ + { duration: '2m', target: 10 }, // warm up (writes need more care) + { duration: '5m', target: 50 }, // ramp to target + { duration: '5m', target: 50 }, // sustained + { duration: '3m', target: 100 }, // peak + { duration: '2m', target: 0 }, // ramp down + ], + }, + }, + thresholds: { + 'journal_entry_duration': [`p(95)<${envConfig.thresholds.write_p95}`], + 'invoice_duration': [`p(95)<${envConfig.thresholds.write_p95}`], + 'payment_duration': [`p(95)<${envConfig.thresholds.write_p95}`], + 'accounts_read_duration': [`p(95)<${envConfig.thresholds.read_p95}`], + 'budget_vs_actual_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`], + 'crud_error_rate': [`rate<${envConfig.thresholds.error_rate}`], + 'write_guard_error_rate': ['rate<0.001'], // write-guard failures should be near-zero + 'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`], + 'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`], + }, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── +function jsonHeaders(token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; +} + +function currentYear() { + return new Date().getFullYear(); +} + +// ── Main scenario ──────────────────────────────────────────────────────────── +export default function () { + const user = users[__VU % users.length]; + let accessToken = null; + + // ── 1. Login ──────────────────────────────────────────────────────────── + group('auth:login', () => { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email: user.email, password: user.password }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } } + ); + const ok = check(res, { + 'login 200': r => r.status === 200, + 'has access_token': r => r.json('access_token') !== undefined, + }); + crudErrorRate.add(!ok); + if (!ok) { sleep(1); return; } + accessToken = res.json('access_token'); + }); + + if (!accessToken) return; + sleep(1); + + // ── 2. Read accounts (needed to pick valid account IDs for journal entry) ─ + let debitAccountId = null; + let creditAccountId = null; + + group('accounts:list', () => { + const res = http.get( + `${BASE_URL}/api/accounts`, + { headers: jsonHeaders(accessToken), tags: { name: 'accounts_list' } } + ); + accountsReadDuration.add(res.timings.duration); + const ok = check(res, { + 'accounts 200': r => r.status === 200, + 'accounts non-empty': r => Array.isArray(r.json()) && r.json().length > 0, + }); + crudErrorRate.add(!ok); + + if (ok) { + const accounts = res.json(); + // Pick first two distinct accounts for the journal entry + debitAccountId = accounts[0]?.id; + creditAccountId = accounts[1]?.id; + } + }); + + if (!debitAccountId || !creditAccountId) { sleep(1); return; } + sleep(1.5); + + // ── 3. Create journal entry (draft) ──────────────────────────────────── + let journalEntryId = null; + + group('journal:create', () => { + const payload = { + date: new Date().toISOString().split('T')[0], + description: `Load test entry ${uuidv4().slice(0, 8)}`, + lines: [ + { accountId: debitAccountId, type: 'debit', amount: 100.00, description: 'Load test debit' }, + { accountId: creditAccountId, type: 'credit', amount: 100.00, description: 'Load test credit' }, + ], + }; + + const res = http.post( + `${BASE_URL}/api/journal-entries`, + JSON.stringify(payload), + { headers: jsonHeaders(accessToken), tags: { name: 'journal_create' } } + ); + + journalEntryDuration.add(res.timings.duration); + // Watch for WriteAccessGuard rejections (403) + writeGuardErrorRate.add(res.status === 403); + const ok = check(res, { + 'journal create 201': r => r.status === 201, + 'journal has id': r => r.json('id') !== undefined, + }); + crudErrorRate.add(!ok); + if (ok) journalEntryId = res.json('id'); + }); + + sleep(1); + + // ── 4. Post the journal entry ──────────────────────────────────────────── + if (journalEntryId) { + group('journal:post', () => { + const res = http.post( + `${BASE_URL}/api/journal-entries/${journalEntryId}/post`, + null, + { headers: jsonHeaders(accessToken), tags: { name: 'journal_post' } } + ); + journalEntryDuration.add(res.timings.duration); + writeGuardErrorRate.add(res.status === 403); + const ok = check(res, { 'journal post 200': r => r.status === 200 }); + crudErrorRate.add(!ok); + }); + sleep(1.5); + } + + // ── 5. Generate invoice preview ───────────────────────────────────────── + let invoicePreviewOk = false; + group('invoice:preview', () => { + const res = http.post( + `${BASE_URL}/api/invoices/generate-preview`, + JSON.stringify({ period: currentYear() }), + { headers: jsonHeaders(accessToken), tags: { name: 'invoice_preview' } } + ); + invoiceDuration.add(res.timings.duration); + invoicePreviewOk = check(res, { 'invoice preview 200': r => r.status === 200 }); + crudErrorRate.add(!invoicePreviewOk); + }); + + sleep(2); // user reviews invoice preview + + // ── 6. Create a payment record ─────────────────────────────────────────── + group('payment:create', () => { + const payload = { + amount: 150.00, + date: new Date().toISOString().split('T')[0], + method: 'check', + description: `Load test payment ${uuidv4().slice(0, 8)}`, + }; + + const res = http.post( + `${BASE_URL}/api/payments`, + JSON.stringify(payload), + { headers: jsonHeaders(accessToken), tags: { name: 'payment_create' } } + ); + paymentDuration.add(res.timings.duration); + writeGuardErrorRate.add(res.status === 403); + const ok = check(res, { + 'payment create 201 or 200': r => r.status === 201 || r.status === 200, + }); + crudErrorRate.add(!ok); + }); + + sleep(1.5); + + // ── 7. Budget vs actual (typically the heaviest read query) ───────────── + group('budget:vs-actual', () => { + const year = currentYear(); + const res = http.get( + `${BASE_URL}/api/budgets/${year}/vs-actual`, + { headers: jsonHeaders(accessToken), tags: { name: 'budget_vs_actual' } } + ); + budgetDuration.add(res.timings.duration); + const ok = check(res, { 'budget vs-actual 200': r => r.status === 200 }); + crudErrorRate.add(!ok); + }); + + sleep(1); + + // ── 8. Trial balance read ──────────────────────────────────────────────── + group('accounts:trial-balance', () => { + const res = http.get( + `${BASE_URL}/api/accounts/trial-balance`, + { headers: jsonHeaders(accessToken), tags: { name: 'trial_balance' } } + ); + accountsReadDuration.add(res.timings.duration); + check(res, { 'trial balance 200': r => r.status === 200 }); + }); + + sleep(1); + + // ── 9. Logout ──────────────────────────────────────────────────────────── + group('auth:logout', () => { + http.post( + `${BASE_URL}/api/auth/logout`, + null, + { headers: jsonHeaders(accessToken), tags: { name: 'logout' } } + ); + }); + + sleep(1); +} diff --git a/load-tests/cycle-template.md b/load-tests/cycle-template.md new file mode 100644 index 0000000..e2b6901 --- /dev/null +++ b/load-tests/cycle-template.md @@ -0,0 +1,117 @@ +# HOALedgerIQ – Load Test Improvement Report +**Cycle:** 001 +**Date:** YYYY-MM-DD +**Test window:** HH:MM – HH:MM UTC +**Environments:** Staging (`staging.hoaledgeriq.com`) +**Scenarios run:** `auth-dashboard-flow.js` + `crud-flow.js` +**Peak VUs:** 200 (dashboard) / 100 (CRUD) +**New Relic app:** `HOALedgerIQ_App` + +--- + +## Executive Summary + +> _[One paragraph: what load the system handled, what broke first, at what VU threshold, and the estimated user-facing impact. Written by Claude Code from New Relic data.]_ + +**Threshold breaches this cycle:** + +| Metric | Target | Actual | Status | +|--------|--------|--------|--------| +| login p95 | < 300ms | — | 🔴 / 🟢 | +| dashboard p95 | < 1000ms | — | 🔴 / 🟢 | +| budget vs-actual p95 | < 1000ms | — | 🔴 / 🟢 | +| journal entry write p95 | < 1200ms | — | 🔴 / 🟢 | +| error rate | < 1% | — | 🔴 / 🟢 | + +--- + +## Findings + +### 🔴 P0 – Fix Before Next Deploy + +#### Finding 001 – [Short title] +- **Symptom:** _e.g., `GET /api/reports/cash-flow-forecast` p95 = 3,400ms at 100 VUs_ +- **New Relic evidence:** _e.g., DatastoreSegment shows 47 sequential DB calls per request_ +- **Root cause hypothesis:** _e.g., N+1 on `reserve_components` — each component triggers a separate `SELECT` for `monthly_actuals`_ +- **File:** `backend/src/modules/reports/cash-flow.service.ts:83` +- **Recommended fix:** + ```typescript + // BEFORE – N+1: one query per component + for (const component of components) { + const actuals = await this.actualsRepo.findBy({ componentId: component.id }); + } + + // AFTER – batch load with WHERE IN + const actuals = await this.actualsRepo.findBy({ + componentId: In(components.map(c => c.id)) + }); + ``` +- **Expected improvement:** ~70% latency reduction on this endpoint +- **Effort:** Low (1–2 hours) + +--- + +### 🟠 P1 – Fix Within This Sprint + +#### Finding 002 – [Short title] +- **Symptom:** +- **New Relic evidence:** +- **Root cause hypothesis:** +- **File:** +- **Recommended fix:** +- **Expected improvement:** +- **Effort:** + +#### Finding 003 – [Short title] +- _(same structure)_ + +--- + +### 🟡 P2 – Backlog + +#### Finding 004 – [Short title] +- **Symptom:** +- **Root cause hypothesis:** +- **Recommended fix:** +- **Effort:** + +--- + +## Regression Net — Re-Test Criteria + +After implementing P0 + P1 fixes, the next BlazeMeter run must pass these gates before merging to staging: + +| Endpoint | Previous p95 | Target p95 | k6 Threshold | +|----------|-------------|------------|-------------| +| `GET /api/reports/cash-flow-forecast` | — | — | `p(95) **Claude Code update command (run after confirming fixes):** +> ```bash +> claude "Update load-tests/analysis/baseline.json with the p95 values from +> load-tests/reports/cycle-001.md findings. Tighten the k6 thresholds in +> load-tests/config/environments.json staging block to match. Do not loosen +> any threshold that already passes." +> ``` + +--- + +## Baseline Delta + +| Endpoint | Cycle 000 p95 | Cycle 001 p95 | Δ | +|----------|--------------|--------------|---| +| _(populated after first run)_ | — | — | — | + +--- + +## Notes & Observations + +- _Any anomalies, flaky tests, or infrastructure events during the run_ +- _Redis / BullMQ queue depth observations_ +- _Rate limiter (Throttler) trip count — if >0, note which endpoints and at what VU count_ +- _TenantMiddleware cache hit rate (if observable via New Relic custom attributes)_ + +--- + +_Generated by Claude Code. Source data in `load-tests/analysis/raw/`. Next cycle target: implement P0+P1, re-run at same peak VUs, update baselines._ diff --git a/load-tests/environments.json b/load-tests/environments.json new file mode 100644 index 0000000..cb997a3 --- /dev/null +++ b/load-tests/environments.json @@ -0,0 +1,38 @@ +{ + "local": { + "baseUrl": "http://localhost:3000", + "thresholds": { + "auth_p95": 500, + "refresh_p95": 300, + "read_p95": 1000, + "write_p95": 1500, + "dashboard_p95": 1500, + "global_p99": 3000, + "error_rate": 0.05 + } + }, + "staging": { + "baseUrl": "https://staging.hoaledgeriq.com", + "thresholds": { + "auth_p95": 300, + "refresh_p95": 200, + "read_p95": 800, + "write_p95": 1200, + "dashboard_p95": 1000, + "global_p99": 2000, + "error_rate": 0.01 + } + }, + "production": { + "baseUrl": "https://app.hoaledgeriq.com", + "thresholds": { + "auth_p95": 200, + "refresh_p95": 150, + "read_p95": 500, + "write_p95": 800, + "dashboard_p95": 700, + "global_p99": 1500, + "error_rate": 0.005 + } + } +} From ae856bfb2f23ef6d2cc8b81c0645e18fe1dd98ca Mon Sep 17 00:00:00 2001 From: JoeBot Date: Thu, 19 Mar 2026 16:12:09 -0400 Subject: [PATCH 2/8] Upload files to "load-tests" --- load-tests/nrql-queries.sql | 274 ++++++++++++++++++++++++++++++++++++ load-tests/user-pool.csv | 15 ++ 2 files changed, 289 insertions(+) create mode 100644 load-tests/nrql-queries.sql create mode 100644 load-tests/user-pool.csv diff --git a/load-tests/nrql-queries.sql b/load-tests/nrql-queries.sql new file mode 100644 index 0000000..c8c99a6 --- /dev/null +++ b/load-tests/nrql-queries.sql @@ -0,0 +1,274 @@ +-- ============================================================ +-- HOALedgerIQ – New Relic NRQL Query Library +-- App name: HOALedgerIQ_App +-- Usage: Run in New Relic Query Builder. Replace time windows as needed. +-- ============================================================ + + +-- ── SECTION 1: OVERVIEW HEALTH ──────────────────────────────────────────── + +-- 1.1 Apdex score over last test window +SELECT apdex(duration, t: 0.5) AS 'Apdex' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +SINCE 1 hour ago +TIMESERIES 1 minute + +-- 1.2 Overall throughput (requests per minute) +SELECT rate(count(*), 1 minute) AS 'RPM' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +SINCE 1 hour ago +TIMESERIES 1 minute + +-- 1.3 Error rate over time +SELECT percentage(count(*), WHERE error IS true) AS 'Error %' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +SINCE 1 hour ago +TIMESERIES 1 minute + + +-- ── SECTION 2: LATENCY BY ENDPOINT ──────────────────────────────────────── + +-- 2.1 p50 / p95 / p99 latency by transaction name +SELECT percentile(duration, 50, 95, 99) AS 'ms' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +FACET name +SINCE 1 hour ago +LIMIT 30 + +-- 2.2 Slowest endpoints (p95) during load test window +SELECT percentile(duration, 95) AS 'p95 ms' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +FACET name +SINCE 1 hour ago +ORDER BY percentile(duration, 95) DESC +LIMIT 20 + +-- 2.3 Auth endpoint latency breakdown +SELECT percentile(duration, 50, 95, 99) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND name LIKE '%auth%' +FACET name +SINCE 1 hour ago + +-- 2.4 Report endpoint latency (typically slowest reads) +SELECT percentile(duration, 50, 95, 99) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND name LIKE '%reports%' +FACET name +SINCE 1 hour ago + +-- 2.5 Write endpoint latency (journal-entries, payments, invoices) +SELECT percentile(duration, 50, 95, 99) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND (name LIKE '%journal-entries%' OR name LIKE '%payments%' OR name LIKE '%invoices%') +FACET name +SINCE 1 hour ago + +-- 2.6 Latency heatmap over time for dashboard load +SELECT histogram(duration, width: 100, buckets: 20) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND name LIKE '%reports/dashboard%' +SINCE 1 hour ago + + +-- ── SECTION 3: DATABASE PERFORMANCE ────────────────────────────────────── + +-- 3.1 Slowest database queries (top 20) +SELECT average(duration) AS 'avg ms', count(*) AS 'calls' +FROM DatastoreSegment +WHERE appName = 'HOALedgerIQ_App' +FACET statement +SINCE 1 hour ago +ORDER BY average(duration) DESC +LIMIT 20 + +-- 3.2 Database call count by operation type +SELECT count(*) +FROM DatastoreSegment +WHERE appName = 'HOALedgerIQ_App' +FACET operation +SINCE 1 hour ago + +-- 3.3 N+1 detection – high-call-count queries +SELECT count(*) AS 'call count', average(duration) AS 'avg ms' +FROM DatastoreSegment +WHERE appName = 'HOALedgerIQ_App' +FACET statement +SINCE 1 hour ago +ORDER BY count(*) DESC +LIMIT 20 + +-- 3.4 DB time as % of total transaction time (per endpoint) +SELECT average(databaseDuration) / average(duration) * 100 AS '% DB time' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND databaseDuration IS NOT NULL +FACET name +SINCE 1 hour ago +ORDER BY average(databaseDuration) / average(duration) DESC +LIMIT 20 + +-- 3.5 Connection pool pressure (slow queries that may indicate pool exhaustion) +SELECT count(*) AS 'slow queries (>500ms)' +FROM DatastoreSegment +WHERE appName = 'HOALedgerIQ_App' + AND duration > 0.5 +FACET statement +SINCE 1 hour ago + +-- 3.6 Multi-tenant schema switch overhead (TenantMiddleware) +SELECT average(duration) AS 'avg ms' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND name NOT LIKE '%auth/login%' + AND name NOT LIKE '%auth/refresh%' +FACET name +SINCE 1 hour ago +ORDER BY average(duration) DESC +LIMIT 20 + + +-- ── SECTION 4: ERROR ANALYSIS ───────────────────────────────────────────── + +-- 4.1 All errors by class and message +SELECT count(*), latest(errorMessage) +FROM TransactionError +WHERE appName = 'HOALedgerIQ_App' +FACET errorClass, errorMessage +SINCE 1 hour ago +LIMIT 30 + +-- 4.2 Error rate by HTTP status code +SELECT count(*) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND httpResponseCode >= 400 +FACET httpResponseCode +SINCE 1 hour ago +TIMESERIES 1 minute + +-- 4.3 403 errors (WriteAccessGuard rejections under load) +SELECT count(*) AS '403 Forbidden' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND httpResponseCode = 403 +FACET name +SINCE 1 hour ago + +-- 4.4 429 errors (rate limiter – Throttler) +SELECT count(*) AS '429 Rate Limited' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND httpResponseCode = 429 +TIMESERIES 1 minute +SINCE 1 hour ago + +-- 4.5 500 errors by endpoint +SELECT count(*), latest(errorMessage) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND httpResponseCode = 500 +FACET name, errorMessage +SINCE 1 hour ago + +-- 4.6 JWT / auth failures +SELECT count(*) +FROM TransactionError +WHERE appName = 'HOALedgerIQ_App' + AND (errorMessage LIKE '%jwt%' OR errorMessage LIKE '%token%' OR errorMessage LIKE '%unauthorized%') +FACET errorMessage +SINCE 1 hour ago + + +-- ── SECTION 5: INFRASTRUCTURE (during test window) ─────────────────────── + +-- 5.1 CPU utilization +SELECT average(cpuPercent) AS 'CPU %' +FROM SystemSample +WHERE hostname LIKE '%hoaledgeriq%' +SINCE 1 hour ago +TIMESERIES 1 minute + +-- 5.2 Memory utilization +SELECT average(memoryUsedPercent) AS 'Memory %' +FROM SystemSample +WHERE hostname LIKE '%hoaledgeriq%' +SINCE 1 hour ago +TIMESERIES 1 minute + +-- 5.3 Network I/O +SELECT average(transmitBytesPerSecond) AS 'TX bytes/s', + average(receiveBytesPerSecond) AS 'RX bytes/s' +FROM NetworkSample +WHERE hostname LIKE '%hoaledgeriq%' +SINCE 1 hour ago +TIMESERIES 1 minute + + +-- ── SECTION 6: REDIS / BULLMQ ───────────────────────────────────────────── + +-- 6.1 External call latency (Redis) +SELECT average(duration) AS 'avg ms', count(*) AS 'calls' +FROM ExternalSegment +WHERE appName = 'HOALedgerIQ_App' + AND (name LIKE '%redis%' OR host LIKE '%redis%') +FACET name +SINCE 1 hour ago + +-- 6.2 All external service latency +SELECT average(duration) AS 'avg ms', count(*) AS 'calls' +FROM ExternalSegment +WHERE appName = 'HOALedgerIQ_App' +FACET host +SINCE 1 hour ago +ORDER BY average(duration) DESC + + +-- ── SECTION 7: BASELINE COMPARISON ─────────────────────────────────────── + +-- 7.1 Compare this run vs last run (adjust SINCE/UNTIL for your windows) +SELECT percentile(duration, 95) AS 'p95 this run' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +FACET name +SINCE '2025-01-01 10:00:00' UNTIL '2025-01-01 11:00:00' +-- Run again with previous window dates to compare + +-- 7.2 Regression check – endpoints that crossed p95 threshold +SELECT percentile(duration, 95) AS 'p95 ms' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND percentile(duration, 95) > 800 -- adjust to your staging threshold +FACET name +SINCE 1 hour ago + + +-- ── SECTION 8: TENANT-AWARE ANALYSIS ────────────────────────────────────── + +-- 8.1 Performance by org (if orgId is in custom attributes) +SELECT percentile(duration, 95) AS 'p95 ms', count(*) AS 'requests' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +FACET custom.orgId +SINCE 1 hour ago +LIMIT 20 + +-- 8.2 Transactions without orgId (potential TenantMiddleware misses) +SELECT count(*) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND custom.orgId IS NULL + AND name NOT LIKE '%auth/login%' + AND name NOT LIKE '%auth/register%' + AND name NOT LIKE '%health%' +FACET name +SINCE 1 hour ago diff --git a/load-tests/user-pool.csv b/load-tests/user-pool.csv new file mode 100644 index 0000000..847555a --- /dev/null +++ b/load-tests/user-pool.csv @@ -0,0 +1,15 @@ +email,password,orgId,role +treasurer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,treasurer +treasurer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,treasurer +treasurer03@loadtest.hoaledgeriq.com,LoadTest123!,org-003,treasurer +admin01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,admin +admin02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,admin +president01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,president +president02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,president +manager01@loadtest.hoaledgeriq.com,LoadTest123!,org-003,manager +manager02@loadtest.hoaledgeriq.com,LoadTest123!,org-004,manager +viewer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,viewer +viewer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,viewer +homeowner01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,homeowner +homeowner02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,homeowner +member01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,member_at_large From 2b331bb3ef33e431f97eb1fb635c09692dfcd27f Mon Sep 17 00:00:00 2001 From: olsch01 Date: Tue, 24 Mar 2026 14:41:02 -0400 Subject: [PATCH 3/8] feat: investment chart alignment, auto-renew records, fund transfers, capital planning report, and upcoming activities (v2026.3.24) - Lock InvestmentTimeline and ProjectionChart to shared X axis range - Auto-create renewal scenario_investments records when auto_renew is true - Add fund transfer mechanism between asset accounts with journal entries - Add Capital Planning Report (5-year forecast grouped by category) - Add Upcoming Investment Activities dashboard card (maturities + planned purchases) - Bump version to 2026.3.24 Co-Authored-By: Claude Opus 4.6 --- backend/package.json | 2 +- .../modules/accounts/accounts.controller.ts | 8 + .../src/modules/accounts/accounts.service.ts | 56 +++++ .../board-planning-projection.service.ts | 60 +++++- .../src/modules/reports/reports.controller.ts | 12 ++ .../src/modules/reports/reports.service.ts | 188 +++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 1 + frontend/src/pages/accounts/AccountsPage.tsx | 112 +++++++++- .../InvestmentScenarioDetailPage.tsx | 40 +++- .../components/InvestmentTimeline.tsx | 21 +- .../components/ProjectionChart.tsx | 19 +- .../src/pages/dashboard/DashboardPage.tsx | 103 +++++++++ .../src/pages/reports/CapitalPlanningPage.tsx | 196 ++++++++++++++++++ 15 files changed, 801 insertions(+), 21 deletions(-) create mode 100644 frontend/src/pages/reports/CapitalPlanningPage.tsx diff --git a/backend/package.json b/backend/package.json index b91551f..47d0896 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.3.19", + "version": "2026.3.24", "description": "HOA LedgerIQ - Backend API", "private": true, "scripts": { diff --git a/backend/src/modules/accounts/accounts.controller.ts b/backend/src/modules/accounts/accounts.controller.ts index 038ce4f..2c07733 100644 --- a/backend/src/modules/accounts/accounts.controller.ts +++ b/backend/src/modules/accounts/accounts.controller.ts @@ -58,6 +58,14 @@ export class AccountsController { return this.accountsService.adjustBalance(id, dto); } + @Post('transfer') + @ApiOperation({ summary: 'Transfer funds between asset accounts' }) + transferFunds( + @Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string }, + ) { + return this.accountsService.transferFunds(dto); + } + @Get(':id') @ApiOperation({ summary: 'Get account by ID' }) findOne(@Param('id') id: string) { diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts index 911b457..b99c77b 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -360,6 +360,62 @@ export class AccountsService { return journalEntry; } + async transferFunds(dto: { + fromAccountId: string; + toAccountId: string; + amount: number; + transferDate: string; + memo?: string; + }) { + if (dto.amount <= 0) throw new BadRequestException('Transfer amount must be positive'); + if (dto.fromAccountId === dto.toAccountId) throw new BadRequestException('Cannot transfer to the same account'); + + const fromAccount = await this.findOne(dto.fromAccountId); + const toAccount = await this.findOne(dto.toAccountId); + + if (fromAccount.account_type !== 'asset') throw new BadRequestException('Source account must be an asset account'); + if (toAccount.account_type !== 'asset') throw new BadRequestException('Destination account must be an asset account'); + + // Find fiscal period + const asOf = new Date(dto.transferDate); + const year = asOf.getFullYear(); + const month = asOf.getMonth() + 1; + const periods = await this.tenant.query( + 'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2', + [year, month], + ); + if (!periods.length) { + throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`); + } + + const memo = dto.memo || `Transfer from ${fromAccount.name} to ${toAccount.name}`; + + // Create journal entry: debit destination (increase), credit source (decrease) + const jeRows = await this.tenant.query( + `INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, 'transfer', $3, true, NOW(), $4) + RETURNING *`, + [dto.transferDate, memo, periods[0].id, '00000000-0000-0000-0000-000000000000'], + ); + const je = jeRows[0]; + + // Credit source account (reduces asset balance) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, 0, $3, $4)`, + [je.id, dto.fromAccountId, dto.amount, memo], + ); + + // Debit destination account (increases asset balance) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, $3, 0, $4)`, + [je.id, dto.toAccountId, dto.amount, memo], + ); + + return je; + } + async getTrialBalance(asOfDate?: string) { const dateFilter = asOfDate ? `AND je.entry_date <= $1` diff --git a/backend/src/modules/board-planning/board-planning-projection.service.ts b/backend/src/modules/board-planning/board-planning-projection.service.ts index ebd63ac..3a8172b 100644 --- a/backend/src/modules/board-planning/board-planning-projection.service.ts +++ b/backend/src/modules/board-planning/board-planning-projection.service.ts @@ -25,12 +25,15 @@ export class BoardPlanningProjectionService { return this.computeProjection(scenarioId); } - /** Compute full projection for a scenario. */ + /** Compute full projection for a scenario. Also auto-creates renewal records for auto_renew investments. */ async computeProjection(scenarioId: string) { const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]); if (!scenarioRows.length) throw new NotFoundException('Scenario not found'); const scenario = scenarioRows[0]; + // Auto-create renewal investment records for auto_renew investments that have maturity dates + await this.ensureRenewalRecords(scenarioId); + const investments = await this.tenant.query( 'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId], ); @@ -152,6 +155,53 @@ export class BoardPlanningProjectionService { // ── Private Helpers ── + /** + * For each auto_renew investment with a maturity_date, ensure a corresponding + * renewal investment record exists (starting at maturity_date, same term). + * The renewal record has auto_renew=false so it won't create infinite chains. + */ + private async ensureRenewalRecords(scenarioId: string) { + const autoRenewInvestments = await this.tenant.query( + `SELECT * FROM scenario_investments + WHERE scenario_id = $1 AND auto_renew = true AND maturity_date IS NOT NULL AND executed_investment_id IS NULL`, + [scenarioId], + ); + + for (const inv of autoRenewInvestments) { + // Check if a renewal record already exists (linked by notes convention or same label pattern) + const renewalLabel = `${inv.label} (Renewal)`; + const existing = await this.tenant.query( + `SELECT id FROM scenario_investments WHERE scenario_id = $1 AND label = $2 AND purchase_date = $3`, + [scenarioId, renewalLabel, inv.maturity_date], + ); + + if (existing.length > 0) continue; // Already created + + // Compute new maturity date from original term + let newMaturityDate: string | null = null; + const termMonths = parseInt(inv.term_months) || 0; + if (termMonths > 0 && inv.maturity_date) { + const d = new Date(inv.maturity_date); + d.setMonth(d.getMonth() + termMonths); + newMaturityDate = d.toISOString().split('T')[0]; + } + + await this.tenant.query( + `INSERT INTO scenario_investments + (scenario_id, label, investment_type, fund_type, principal, interest_rate, + term_months, institution, purchase_date, maturity_date, auto_renew, notes, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, false, $11, $12)`, + [ + scenarioId, renewalLabel, inv.investment_type, inv.fund_type, + inv.principal, inv.interest_rate, inv.term_months || null, + inv.institution, inv.maturity_date, newMaturityDate, + `Auto-created renewal of "${inv.label}". Modify as needed.`, + (parseInt(inv.sort_order) || 0) + 1, + ], + ); + } + } + private async getBaselineState(startYear: number, months: number) { // Current balances from asset accounts const opCashRows = await this.tenant.query(` @@ -403,11 +453,9 @@ export class BoardPlanningProjectionService { if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; } else { resCashFlow += maturityTotal; resInvChange -= principal; } - // Auto-renew: immediately reinvest - if (inv.auto_renew) { - if (isOp) { opCashFlow -= principal; opInvChange += principal; } - else { resCashFlow -= principal; resInvChange += principal; } - } + // Note: auto_renew investments now create separate renewal records + // (via ensureRenewalRecords), so the renewal purchase is handled by + // that record's purchase_date logic above — no inline reinvest needed. } } } diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index 8f9e270..9fc2294 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -65,6 +65,11 @@ export class ReportsController { return this.reportsService.getDashboardKPIs(); } + @Get('upcoming-investment-activities') + getUpcomingInvestmentActivities() { + return this.reportsService.getUpcomingInvestmentActivities(); + } + @Get('cash-flow-forecast') getCashFlowForecast( @Query('startYear') startYear?: string, @@ -75,6 +80,13 @@ export class ReportsController { return this.reportsService.getCashFlowForecast(yr, mo); } + @Get('capital-planning') + getCapitalPlanningReport(@Query('startYear') startYear?: string) { + return this.reportsService.getCapitalPlanningReport( + parseInt(startYear || '') || undefined, + ); + } + @Get('quarterly') getQuarterlyFinancial( @Query('year') year?: string, diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index 8df2e58..a1e9554 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -780,6 +780,78 @@ export class ReportsService { }; } + async getUpcomingInvestmentActivities() { + const now = new Date(); + const in45Days = new Date(now); + in45Days.setDate(in45Days.getDate() + 45); + const in60Days = new Date(now); + in60Days.setDate(in60Days.getDate() + 60); + + // 1. Investments maturing within 45 days + const maturingInvestments = await this.tenant.query(` + SELECT id, name, institution, investment_type, fund_type, current_value, principal, + interest_rate, maturity_date, purchase_date + FROM investment_accounts + WHERE is_active = true + AND maturity_date IS NOT NULL + AND maturity_date BETWEEN CURRENT_DATE AND $1::date + ORDER BY maturity_date ASC + `, [in45Days.toISOString().split('T')[0]]); + + // Compute interest earned and days remaining for each + const maturing = maturingInvestments.map((inv: any) => { + const principal = parseFloat(inv.principal) || parseFloat(inv.current_value) || 0; + const rate = parseFloat(inv.interest_rate) || 0; + const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : now; + const maturityDate = new Date(inv.maturity_date); + const daysHeld = Math.max((maturityDate.getTime() - purchaseDate.getTime()) / 86400000, 1); + const interestEarned = principal * (rate / 100) * (daysHeld / 365); + const daysRemaining = Math.max(Math.ceil((maturityDate.getTime() - now.getTime()) / 86400000), 0); + return { + ...inv, + interest_earned: interestEarned.toFixed(2), + maturity_value: (principal + interestEarned).toFixed(2), + days_remaining: daysRemaining, + activity_type: 'maturity', + }; + }); + + // 2. Approved scenario investments due to execute within 60 days + let scenarioItems: any[] = []; + try { + scenarioItems = await this.tenant.query(` + SELECT si.id, si.label, si.investment_type, si.fund_type, si.principal, + si.interest_rate, si.purchase_date, si.maturity_date, si.institution, + bs.name as scenario_name, bs.status as scenario_status + FROM scenario_investments si + JOIN board_scenarios bs ON bs.id = si.scenario_id + WHERE bs.status = 'approved' + AND si.executed_investment_id IS NULL + AND si.purchase_date IS NOT NULL + AND si.purchase_date BETWEEN CURRENT_DATE AND $1::date + ORDER BY si.purchase_date ASC + `, [in60Days.toISOString().split('T')[0]]); + } catch { + // scenario tables may not exist + } + + const upcoming = scenarioItems.map((si: any) => { + const purchaseDate = new Date(si.purchase_date); + const daysUntil = Math.max(Math.ceil((purchaseDate.getTime() - now.getTime()) / 86400000), 0); + return { + ...si, + days_until: daysUntil, + activity_type: 'planned_purchase', + }; + }); + + return { + maturing_investments: maturing, + upcoming_scenario_investments: upcoming, + total_activities: maturing.length + upcoming.length, + }; + } + /** * Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future). * Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments. @@ -1264,4 +1336,120 @@ export class ReportsService { over_budget_items: overBudgetItems, }; } + + async getCapitalPlanningReport(startYear?: number) { + const baseYear = startYear || new Date().getFullYear(); + const years = [baseYear, baseYear + 1, baseYear + 2, baseYear + 3, baseYear + 4]; + + // Get all active projects + const projects = await this.tenant.query( + `SELECT id, name, description, category, estimated_cost, target_year, target_month, + useful_life_years, last_replacement_date, next_replacement_date, fund_source, + status, priority, condition_rating + FROM projects + WHERE is_active = true + ORDER BY category NULLS LAST, priority, name`, + ); + + // Also try capital_projects table + let capitalProjects: any[] = []; + try { + capitalProjects = await this.tenant.query( + `SELECT id, name, description, estimated_cost, target_year, target_month, + fund_source, status, priority, notes + FROM capital_projects + WHERE status NOT IN ('cancelled') + ORDER BY priority, name`, + ); + } catch { + // Table may not exist + } + + // Merge and group by category + const allProjects = [ + ...projects.map((p: any) => ({ + id: p.id, + name: p.name, + description: p.description, + category: p.category || 'Uncategorized', + estimated_cost: parseFloat(p.estimated_cost) || 0, + target_year: parseInt(p.target_year) || null, + useful_life_years: parseInt(p.useful_life_years) || null, + last_replacement_date: p.last_replacement_date, + fund_source: p.fund_source || 'reserve', + status: p.status, + priority: parseInt(p.priority) || 3, + condition_rating: parseInt(p.condition_rating) || null, + })), + ...capitalProjects + .filter((cp: any) => !projects.some((p: any) => p.name === cp.name && p.target_year === cp.target_year)) + .map((cp: any) => ({ + id: cp.id, + name: cp.name, + description: cp.description, + category: 'Capital Projects', + estimated_cost: parseFloat(cp.estimated_cost) || 0, + target_year: parseInt(cp.target_year) || null, + useful_life_years: null, + last_replacement_date: null, + fund_source: cp.fund_source || 'reserve', + status: cp.status, + priority: parseInt(cp.priority) || 3, + condition_rating: null, + })), + ]; + + // Group by category + const categories: Record = {}; + for (const project of allProjects) { + const cat = project.category; + if (!categories[cat]) categories[cat] = []; + categories[cat].push(project); + } + + // Build year columns for each project + const categoryData = Object.entries(categories).map(([category, items]) => ({ + category, + projects: items.map((p) => { + const yearAmounts: Record = {}; + let beyond = 0; + if (p.target_year) { + if (p.target_year >= years[0] && p.target_year <= years[4]) { + yearAmounts[p.target_year] = p.estimated_cost; + } else if (p.target_year > years[4]) { + beyond = p.estimated_cost; + } + } + return { + ...p, + year_amounts: yearAmounts, + beyond, + }; + }), + })); + + // Compute totals per year + const yearTotals: Record = {}; + let beyondTotal = 0; + for (const y of years) yearTotals[y] = 0; + for (const cat of categoryData) { + for (const p of cat.projects) { + for (const y of years) { + yearTotals[y] += p.year_amounts[y] || 0; + } + beyondTotal += p.beyond; + } + } + + return { + title: `${years[4] - years[0] + 1}-YEAR CAPITAL PROJECT FORECAST`, + start_year: years[0], + years, + categories: categoryData, + year_totals: yearTotals, + beyond_total: beyondTotal, + grand_total: Object.values(yearTotals).reduce((a, b) => a + b, 0) + beyondTotal, + generated_at: new Date().toISOString(), + }; + } } diff --git a/frontend/package.json b/frontend/package.json index 1fb3b5f..3e9e175 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.19", + "version": "2026.3.24", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc7ae07..d21e25b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { CashFlowPage } from './pages/reports/CashFlowPage'; import { AgingReportPage } from './pages/reports/AgingReportPage'; import { YearEndPage } from './pages/reports/YearEndPage'; import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage'; +import { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage'; import { SettingsPage } from './pages/settings/SettingsPage'; import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; @@ -167,6 +168,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7785502..d07d371 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -94,6 +94,7 @@ const navSections = [ { label: 'Sankey Diagram', path: '/reports/sankey' }, { label: 'Year-End', path: '/reports/year-end' }, { label: 'Quarterly Financial', path: '/reports/quarterly' }, + { label: 'Capital Planning', path: '/reports/capital-planning' }, ], }, ], diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index dd614e4..162811c 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -37,6 +37,7 @@ import { IconStarFilled, IconAdjustments, IconInfoCircle, + IconArrowsTransferDown, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -126,6 +127,7 @@ export function AccountsPage() { const [search, setSearch] = useState(''); const [filterType, setFilterType] = useState(null); const [showArchived, setShowArchived] = useState(false); + const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false); const queryClient = useQueryClient(); const isReadOnly = useIsReadOnly(); @@ -283,6 +285,39 @@ export function AccountsPage() { }, }); + // ── Transfer form ── + const transferForm = useForm({ + initialValues: { + fromAccountId: '', + toAccountId: '', + amount: 0, + transferDate: new Date() as Date | null, + memo: '', + }, + validate: { + fromAccountId: (v) => (v ? null : 'Required'), + toAccountId: (v, values) => !v ? 'Required' : v === values.fromAccountId ? 'Must be different from source' : null, + amount: (v) => (v > 0 ? null : 'Must be greater than 0'), + transferDate: (v) => (v ? null : 'Required'), + }, + }); + + const transferMutation = useMutation({ + mutationFn: (values: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo: string }) => + api.post('/accounts/transfer', values), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + queryClient.invalidateQueries({ queryKey: ['trial-balance'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + notifications.show({ message: 'Transfer completed successfully', color: 'green' }); + closeTransfer(); + transferForm.reset(); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Transfer failed', color: 'red' }); + }, + }); + // ── Investment edit form ── const invForm = useForm({ initialValues: { @@ -408,6 +443,9 @@ export function AccountsPage() { const activeAccounts = filtered.filter((a) => a.is_active); const archivedAccounts = filtered.filter((a) => !a.is_active); + // Asset accounts for transfer modal (all active asset accounts, not just filtered by search) + const assetAccounts = accounts.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset'); + // ── Investments split by fund type ── const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active); const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active); @@ -505,9 +543,14 @@ export function AccountsPage() { size="sm" /> {!isReadOnly && ( - + <> + + + )} @@ -854,6 +897,69 @@ export function AccountsPage() { )} + {/* Transfer Funds Modal */} + +
{ + transferMutation.mutate({ + ...values, + transferDate: values.transferDate ? values.transferDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0], + }); + })}> + + } color="blue" variant="light"> + This creates a journal entry transferring funds between asset accounts. + Both accounts will be updated in the general ledger. + + a.id !== transferForm.values.fromAccountId) + .map((a) => ({ + value: a.id, + label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`, + }))} + searchable + {...transferForm.getInputProps('toAccountId')} + /> + + + + + +
+
+ {/* Investment Edit Modal */} {editingInvestment && ( diff --git a/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx b/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx index 83e487f..4e89659 100644 --- a/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx +++ b/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon, Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip, @@ -106,6 +106,34 @@ export function InvestmentScenarioDetailPage() { const investments = scenario.investments || []; const summary = projection?.summary; + // Compute shared time range for aligned charts + const { sharedStartDate, sharedEndDate } = useMemo(() => { + const allDates: Date[] = []; + + // Dates from investments + for (const inv of investments) { + if (inv.purchase_date) allDates.push(new Date(inv.purchase_date)); + if (inv.maturity_date) allDates.push(new Date(inv.maturity_date)); + } + + // Dates from projection datapoints + const dps = projection?.datapoints || []; + if (dps.length > 0) { + allDates.push(new Date(dps[0].year, dps[0].monthNum - 1, 1)); + const last = dps[dps.length - 1]; + allDates.push(new Date(last.year, last.monthNum - 1, 1)); + } + + if (allDates.length === 0) return { sharedStartDate: undefined, sharedEndDate: undefined }; + + const min = new Date(Math.min(...allDates.map((d) => d.getTime()))); + const max = new Date(Math.max(...allDates.map((d) => d.getTime()))); + return { + sharedStartDate: new Date(min.getFullYear(), min.getMonth(), 1), + sharedEndDate: new Date(max.getFullYear(), max.getMonth(), 1), + }; + }, [investments, projection]); + // Build a lookup of per-investment interest from the projection const interestDetailMap: Record = {}; if (summary?.investment_interest_details) { @@ -259,7 +287,13 @@ export function InvestmentScenarioDetailPage() { {/* Investment Timeline */} - {investments.length > 0 && } + {investments.length > 0 && ( + + )} {/* Projection Chart */} {projection && ( @@ -267,6 +301,8 @@ export function InvestmentScenarioDetailPage() { datapoints={projection.datapoints || []} title="Scenario Projection" summary={projection.summary} + sharedStartDate={sharedStartDate} + sharedEndDate={sharedEndDate} /> )} {projLoading &&
} diff --git a/frontend/src/pages/board-planning/components/InvestmentTimeline.tsx b/frontend/src/pages/board-planning/components/InvestmentTimeline.tsx index 49e23f2..890a5d1 100644 --- a/frontend/src/pages/board-planning/components/InvestmentTimeline.tsx +++ b/frontend/src/pages/board-planning/components/InvestmentTimeline.tsx @@ -13,9 +13,12 @@ const typeColors: Record = { interface Props { investments: any[]; + /** Optional shared time range to align with ProjectionChart */ + sharedStartDate?: Date; + sharedEndDate?: Date; } -export function InvestmentTimeline({ investments }: Props) { +export function InvestmentTimeline({ investments, sharedStartDate, sharedEndDate }: Props) { const { items, startDate, endDate, totalMonths } = useMemo(() => { const now = new Date(); const items = investments @@ -28,16 +31,24 @@ export function InvestmentTimeline({ investments }: Props) { if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 }; - const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[]; - const startDate = new Date(Math.min(...allDates.map((d) => d.getTime()))); - const endDate = new Date(Math.max(...allDates.map((d) => d.getTime()))); + // Use shared range if provided (to align with ProjectionChart), otherwise compute from investments + let startDate: Date; + let endDate: Date; + if (sharedStartDate && sharedEndDate) { + startDate = sharedStartDate; + endDate = sharedEndDate; + } else { + const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[]; + startDate = new Date(Math.min(...allDates.map((d) => d.getTime()))); + endDate = new Date(Math.max(...allDates.map((d) => d.getTime()))); + } const totalMonths = Math.max( (endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1, 1, ); return { items, startDate, endDate, totalMonths }; - }, [investments]); + }, [investments, sharedStartDate, sharedEndDate]); if (!items.length) return null; diff --git a/frontend/src/pages/board-planning/components/ProjectionChart.tsx b/frontend/src/pages/board-planning/components/ProjectionChart.tsx index 5fb904f..6309f76 100644 --- a/frontend/src/pages/board-planning/components/ProjectionChart.tsx +++ b/frontend/src/pages/board-planning/components/ProjectionChart.tsx @@ -23,18 +23,31 @@ interface Props { datapoints: Datapoint[]; title?: string; summary?: any; + /** Optional shared time range to align with InvestmentTimeline */ + sharedStartDate?: Date; + sharedEndDate?: Date; } -export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) { +export function ProjectionChart({ datapoints, title = 'Financial Projection', summary, sharedStartDate, sharedEndDate }: Props) { const [fundFilter, setFundFilter] = useState('all'); const chartData = useMemo(() => { - return datapoints.map((d) => ({ + let filtered = datapoints; + // If shared range provided, filter datapoints to match + if (sharedStartDate && sharedEndDate) { + const startKey = sharedStartDate.getFullYear() * 12 + sharedStartDate.getMonth(); + const endKey = sharedEndDate.getFullYear() * 12 + sharedEndDate.getMonth(); + filtered = datapoints.filter((d) => { + const dpKey = d.year * 12 + (d.monthNum - 1); + return dpKey >= startKey && dpKey <= endKey; + }); + } + return filtered.map((d) => ({ ...d, label: `${d.month}`, total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments, })); - }, [datapoints]); + }, [datapoints, sharedStartDate, sharedEndDate]); // Find first forecast month for reference line const forecastStart = chartData.findIndex((d) => d.is_forecast); diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index e46dd9b..9ebff28 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -15,6 +15,8 @@ import { IconHeartbeat, IconRefresh, IconInfoCircle, + IconCoin, + IconCalendarEvent, } from '@tabler/icons-react'; import { useState, useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -362,6 +364,16 @@ export function DashboardPage() { enabled: !!currentOrg, }); + const { data: investmentActivities } = useQuery<{ + maturing_investments: any[]; + upcoming_scenario_investments: any[]; + total_activities: number; + }>({ + queryKey: ['upcoming-investment-activities'], + queryFn: async () => { const { data } = await api.get('/reports/upcoming-investment-activities'); return data; }, + enabled: !!currentOrg, + }); + const { data: healthScores } = useQuery({ queryKey: ['health-scores'], queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; }, @@ -531,6 +543,97 @@ export function DashboardPage() { + {/* Upcoming Investment Activities */} + {(investmentActivities?.total_activities || 0) > 0 && ( + + + + + + + Upcoming Investment Activities + + {investmentActivities?.total_activities} upcoming + + + + + Activity + Type + Fund + Amount + Date + Timeline + + + + {(investmentActivities?.maturing_investments || []).map((inv: any) => ( + + + + + {inv.name} + + {inv.institution && {inv.institution}} + + + Maturing + + + + {inv.fund_type} + + + + {fmt(inv.maturity_value)} + +{fmt(inv.interest_earned)} interest + + + {new Date(inv.maturity_date).toLocaleDateString()} + + + + {inv.days_remaining} days + + + + ))} + {(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => ( + + + + + {si.label} + + Scenario: {si.scenario_name} + + + Planned Purchase + + + + {si.fund_type} + + + + {fmt(si.principal)} + {si.interest_rate && {parseFloat(si.interest_rate).toFixed(2)}% APY} + + + {new Date(si.purchase_date).toLocaleDateString()} + + + + {si.days_until} days + + + + ))} + +
+
+ )} + Quick Stats diff --git a/frontend/src/pages/reports/CapitalPlanningPage.tsx b/frontend/src/pages/reports/CapitalPlanningPage.tsx new file mode 100644 index 0000000..e009c57 --- /dev/null +++ b/frontend/src/pages/reports/CapitalPlanningPage.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react'; +import { + Title, Text, Card, Table, Group, Stack, Badge, Loader, Center, + Button, NumberInput, +} from '@mantine/core'; +import { IconPrinter } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface ProjectItem { + id: string; + name: string; + description: string; + category: string; + estimated_cost: number; + target_year: number | null; + useful_life_years: number | null; + last_replacement_date: string | null; + fund_source: string; + status: string; + priority: number; + condition_rating: number | null; + year_amounts: Record; + beyond: number; +} + +interface CategoryGroup { + category: string; + projects: ProjectItem[]; +} + +interface CapitalPlanningData { + title: string; + start_year: number; + years: number[]; + categories: CategoryGroup[]; + year_totals: Record; + beyond_total: number; + grand_total: number; + generated_at: string; +} + +const fmt = (v: number) => + v === 0 ? '-' : v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); + +export function CapitalPlanningPage() { + const [startYear, setStartYear] = useState(new Date().getFullYear()); + + const { data, isLoading } = useQuery({ + queryKey: ['capital-planning', startYear], + queryFn: async () => { + const { data } = await api.get(`/reports/capital-planning?startYear=${startYear}`); + return data; + }, + }); + + if (isLoading) return
; + + const years = data?.years || []; + const hasProjects = (data?.categories || []).some((c) => c.projects.length > 0); + + return ( + + +
+ Capital Planning Report + {data?.title || '5-Year Capital Project Forecast'} +
+ + v && setStartYear(Number(v))} + min={2020} + max={2050} + /> + + +
+ + {!hasProjects ? ( + + + No capital projects found. Add projects on the Projects page to generate this report. + + + ) : ( + + {data?.title} + + Generated {new Date(data?.generated_at || '').toLocaleDateString()} + + + + + + Description + Life (yr) + Last Done + {years.map((y) => ( + {y} + ))} + Beyond + + + + {(data?.categories || []).map((cat) => { + const catTotals: Record = {}; + let catBeyond = 0; + for (const y of years) catTotals[y] = 0; + for (const p of cat.projects) { + for (const y of years) catTotals[y] += p.year_amounts[y] || 0; + catBeyond += p.beyond; + } + + return [ + + + {cat.category} + + , + ...cat.projects.map((p) => ( + + + {p.name} + {p.status !== 'planned' && ( + + {p.status} + + )} + + + {p.useful_life_years || '-'} + + + + {p.last_replacement_date + ? new Date(p.last_replacement_date).getFullYear() + : '-'} + + + {years.map((y) => ( + + {fmt(p.year_amounts[y] || 0)} + + ))} + + {fmt(p.beyond)} + + + )), + + + Subtotal — {cat.category} + + {years.map((y) => ( + + {fmt(catTotals[y])} + + ))} + + {fmt(catBeyond)} + + , + ]; + })} + + + + + TOTAL + + {years.map((y) => ( + + {fmt(data?.year_totals[y] || 0)} + + ))} + + {fmt(data?.beyond_total || 0)} + + + +
+
+ )} +
+ ); +} From 121b8138e305c5194399d0ef7333153857dcb25a Mon Sep 17 00:00:00 2001 From: olsch01 Date: Tue, 24 Mar 2026 15:04:14 -0400 Subject: [PATCH 4/8] fix: investment scenario detail blank screen and auto-renew refresh Move useMemo hook above early returns to satisfy React Rules of Hooks, fixing blank screen when navigating to scenario detail. Also re-fetch scenario after projection updates so auto-renew renewal records appear automatically without requiring manual navigation. Co-Authored-By: Claude Opus 4.6 --- backend/package-lock.json | 4 ++-- frontend/package-lock.json | 4 ++-- .../InvestmentScenarioDetailPage.tsx | 23 ++++++++++++++----- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 9a43a93..848adb0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.3.17", + "version": "2026.3.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoa-ledgeriq-backend", - "version": "2026.3.17", + "version": "2026.3.19", "dependencies": { "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a53fd5..dfcd69e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.17", + "version": "2026.3.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.17", + "version": "2026.3.19", "dependencies": { "@mantine/core": "^7.15.3", "@mantine/dates": "^7.15.3", diff --git a/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx b/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx index 4e89659..e3ad1c6 100644 --- a/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx +++ b/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx @@ -40,7 +40,7 @@ export function InvestmentScenarioDetailPage() { }, }); - const { data: projection, isLoading: projLoading } = useQuery({ + const { data: projection, isLoading: projLoading, dataUpdatedAt: projUpdatedAt } = useQuery({ queryKey: ['board-planning-projection', id], queryFn: async () => { const { data } = await api.get(`/board-planning/scenarios/${id}/projection`); @@ -49,6 +49,17 @@ export function InvestmentScenarioDetailPage() { enabled: !!id, }); + // When projection refreshes (which may create auto-renew records on the backend), + // re-fetch the scenario so the investments list picks up any new renewal records. + const [lastProjUpdate, setLastProjUpdate] = useState(0); + if (projUpdatedAt && projUpdatedAt !== lastProjUpdate) { + setLastProjUpdate(projUpdatedAt); + if (lastProjUpdate > 0) { + // Only re-fetch after a real update (not the initial load) + queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] }); + } + } + const addMutation = useMutation({ mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto), onSuccess: () => { @@ -100,13 +111,10 @@ export function InvestmentScenarioDetailPage() { }, }); - if (isLoading) return
; - if (!scenario) return
Scenario not found
; - - const investments = scenario.investments || []; + // Compute shared time range for aligned charts (must be above early returns to satisfy Rules of Hooks) + const investments = scenario?.investments || []; const summary = projection?.summary; - // Compute shared time range for aligned charts const { sharedStartDate, sharedEndDate } = useMemo(() => { const allDates: Date[] = []; @@ -134,6 +142,9 @@ export function InvestmentScenarioDetailPage() { }; }, [investments, projection]); + if (isLoading) return
; + if (!scenario) return
Scenario not found
; + // Build a lookup of per-investment interest from the projection const interestDetailMap: Record = {}; if (summary?.investment_interest_details) { From 2f6297ae68b0bd99270f7e4d0c40773ff596290d Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 25 Mar 2026 14:28:37 -0400 Subject: [PATCH 5/8] fix: reserve fund health AI prompt uses planned dates instead of remaining life years Remaining life years is documentation-only reference info. The board's planned project date is the authoritative timeline for urgency assessment. Updates data gathering, prompt construction, and system instructions to base all urgency on target_year/target_month instead of remaining_life_years. Co-Authored-By: Claude Opus 4.6 --- .../health-scores/health-scores.service.ts | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/backend/src/modules/health-scores/health-scores.service.ts b/backend/src/modules/health-scores/health-scores.service.ts index 6d0e3c9..78049cf 100644 --- a/backend/src/modules/health-scores/health-scores.service.ts +++ b/backend/src/modules/health-scores/health-scores.service.ts @@ -625,14 +625,16 @@ export class HealthScoresService { .filter((b: any) => b.account_type === 'expense') .reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0); - // Components needing replacement within 5 years — use whichever source has data - const urgentComponents = useComponentsTable - ? reserveComponents.filter( - (c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5, - ) - : reserveProjects.filter( - (p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5, - ); + // Projects due within 5 years — based on planned date (target_year/target_month), + // NOT remaining_life_years. The planned date is the board's decision on when to act; + // remaining life is documentation-only reference info. + const now = new Date(); + const fiveYearsFromNow = new Date(now.getFullYear() + 5, now.getMonth(), 1); + const urgentProjects = reserveProjects.filter((p: any) => { + if (!p.target_year) return false; + const targetDate = new Date(parseInt(p.target_year), (parseInt(p.target_month) || 6) - 1, 1); + return targetDate <= fiveYearsFromNow; + }); // ── Build 12-month forward reserve cash flow projection ── @@ -773,7 +775,7 @@ export class HealthScoresService { totalProjectCost, annualReserveContribution, annualReserveExpenses, - urgentComponents, + urgentProjects, monthlySpecialAssessmentIncome, year, forecast, @@ -940,12 +942,13 @@ SCORING GUIDELINES: KEY FACTORS TO EVALUATE: 1. Percent funded (total reserve assets vs total replacement costs) -2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?) -3. Component urgency (components due within 5 years and their funding status) -4. Capital project readiness (are planned projects adequately funded?) +2. Annual contribution adequacy (is annual contribution enough to keep pace with planned projects?) +3. Project urgency — based ONLY on the "Planned Date" field. The Planned Date is the board's decision on when a project will be executed. Do NOT use "Useful Life" or "Remaining Life" to determine urgency — those are reference information only. A project is only urgent if its Planned Date falls within the next 1-3 years. +4. Capital project readiness (are planned projects adequately funded by their planned dates?) 5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?) -6. Diversity of reserve components (is the full building covered?) +6. Diversity of reserve components (is the full scope of community infrastructure tracked?) 7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low. +8. IMPORTANT — Projects with no Planned Date or with "Not scheduled" should be noted but NOT treated as urgent or imminent. Only assess urgency for projects with actual planned dates. RESPONSE FORMAT: Respond with ONLY valid JSON (no markdown, no code fences): @@ -974,7 +977,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar `- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`, ).join('\n'); - // Build component lines from reserve_components if available, otherwise from reserve-funded projects + // Build component lines from reserve_components if available, otherwise from reserve-funded projects. + // Use planned date (target_year/target_month) as the authoritative timeline, not remaining_life_years. const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects; const componentLines = componentSource.length === 0 ? 'No reserve components or reserve projects tracked.' @@ -982,7 +986,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0'); const funded = parseFloat(c.current_fund_balance || '0'); const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0'; - return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`; + const plannedDate = c.target_year ? `${c.target_year}/${c.target_month || '?'}` : 'Not scheduled'; + return `- ${c.name} [${c.category || 'N/A'}] | Planned Date: ${plannedDate} | Useful Life: ${c.useful_life_years || '?'}yr (reference only) | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`; }).join('\n'); const projectLines = data.projects.length === 0 @@ -995,13 +1000,14 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar .map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`) .join('\n') || 'No reserve budget line items.'; - const urgentLines = data.urgentComponents.length === 0 - ? 'None — no components due within 5 years.' - : data.urgentComponents.map((c: any) => { - const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0'); - const funded = parseFloat(c.current_fund_balance || '0'); + const urgentLines = data.urgentProjects.length === 0 + ? 'None — no reserve projects planned within 5 years.' + : data.urgentProjects.map((p: any) => { + const cost = parseFloat(p.estimated_cost || '0'); + const funded = parseFloat(p.current_fund_balance || '0'); const gap = cost - funded; - return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`; + const targetDate = `${p.target_year}/${p.target_month || '?'}`; + return `- ${p.name}: planned for ${targetDate}, Cost: $${cost.toFixed(0)}, $${gap.toFixed(0)} funding gap`; }).join('\n'); const userPrompt = `Evaluate this HOA's reserve fund health. @@ -1027,10 +1033,10 @@ ${accountLines} === RESERVE INVESTMENTS === ${investmentLines} -=== RESERVE COMPONENTS (ordered by urgency) === +=== RESERVE COMPONENTS (ordered by planned date) === ${componentLines} -=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) === +=== PROJECTS PLANNED WITHIN 5 YEARS (by planned date) === ${urgentLines} === CAPITAL PROJECTS === From 140cd7acb705e6e0712adbbc2df997bc71478224 Mon Sep 17 00:00:00 2001 From: JoeBot Date: Thu, 2 Apr 2026 17:20:37 -0400 Subject: [PATCH 6/8] feat: add ideation feature with per-tenant toggle Adds idea submission capability gated by a per-tenant feature flag. Super admins can enable/disable ideation for specific tenants via the admin tenant detail drawer. Users see a lightbulb icon in the header when enabled, opening a modal to submit ideas (title + description). Ideas are stored in shared schema for cross-tenant backlog querying. - Database: shared.ideas table (018-ideas.sql migration) - Backend: Ideas NestJS module (entity, service, controller) - Admin API: GET /admin/ideas, PUT /admin/ideas/:id/status, PUT /admin/organizations/:id/settings - Frontend: IdeaModal component, lightbulb ActionIcon in header - Admin UI: Feature Toggles card with ideation Switch in drawer Co-Authored-By: Claude Opus 4.6 --- backend/src/app.module.ts | 2 + backend/src/modules/auth/admin.controller.ts | 32 ++++++++ backend/src/modules/auth/auth.module.ts | 2 + .../src/modules/ideas/dto/create-idea.dto.ts | 12 +++ .../src/modules/ideas/entities/idea.entity.ts | 46 +++++++++++ backend/src/modules/ideas/ideas.controller.ts | 27 +++++++ backend/src/modules/ideas/ideas.module.ts | 14 ++++ backend/src/modules/ideas/ideas.service.ts | 78 +++++++++++++++++++ db/migrations/018-ideas.sql | 15 ++++ frontend/src/components/ideas/IdeaModal.tsx | 69 ++++++++++++++++ frontend/src/components/layout/AppLayout.tsx | 16 ++++ frontend/src/pages/admin/AdminPage.tsx | 33 +++++++- 12 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/ideas/dto/create-idea.dto.ts create mode 100644 backend/src/modules/ideas/entities/idea.entity.ts create mode 100644 backend/src/modules/ideas/ideas.controller.ts create mode 100644 backend/src/modules/ideas/ideas.module.ts create mode 100644 backend/src/modules/ideas/ideas.service.ts create mode 100644 db/migrations/018-ideas.sql create mode 100644 frontend/src/components/ideas/IdeaModal.tsx diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3154b6d..e05aafd 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -33,6 +33,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod import { BillingModule } from './modules/billing/billing.module'; import { EmailModule } from './modules/email/email.module'; import { OnboardingModule } from './modules/onboarding/onboarding.module'; +import { IdeasModule } from './modules/ideas/ideas.module'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ @@ -88,6 +89,7 @@ import { ScheduleModule } from '@nestjs/schedule'; BillingModule, EmailModule, OnboardingModule, + IdeasModule, ScheduleModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/modules/auth/admin.controller.ts b/backend/src/modules/auth/admin.controller.ts index 4a4a68e..aaf8c2d 100644 --- a/backend/src/modules/auth/admin.controller.ts +++ b/backend/src/modules/auth/admin.controller.ts @@ -5,6 +5,7 @@ import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { OrganizationsService } from '../organizations/organizations.service'; import { AdminAnalyticsService } from './admin-analytics.service'; +import { IdeasService } from '../ideas/ideas.service'; import * as bcrypt from 'bcryptjs'; @ApiTags('admin') @@ -17,6 +18,7 @@ export class AdminController { private usersService: UsersService, private orgService: OrganizationsService, private analyticsService: AdminAnalyticsService, + private ideasService: IdeasService, ) {} private async requireSuperadmin(req: any) { @@ -196,4 +198,34 @@ export class AdminController { return { success: true, organization: org }; } + + // ── Ideation ── + + @Get('ideas') + async listAllIdeas(@Req() req: any) { + await this.requireSuperadmin(req); + return this.ideasService.findAll(); + } + + @Put('ideas/:id/status') + async updateIdeaStatus( + @Req() req: any, + @Param('id') id: string, + @Body() body: { status: string }, + ) { + await this.requireSuperadmin(req); + const idea = await this.ideasService.updateStatus(id, body.status); + return { success: true, idea }; + } + + @Put('organizations/:id/settings') + async updateOrgSettings( + @Req() req: any, + @Param('id') id: string, + @Body() body: Record, + ) { + await this.requireSuperadmin(req); + const org = await this.orgService.updateSettings(id, body); + return { success: true, organization: org }; + } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 66bb361..188ebd9 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; import { UsersModule } from '../users/users.module'; import { OrganizationsModule } from '../organizations/organizations.module'; +import { IdeasModule } from '../ideas/ideas.module'; @Module({ imports: [ UsersModule, OrganizationsModule, + IdeasModule, PassportModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/modules/ideas/dto/create-idea.dto.ts b/backend/src/modules/ideas/dto/create-idea.dto.ts new file mode 100644 index 0000000..ab7a255 --- /dev/null +++ b/backend/src/modules/ideas/dto/create-idea.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; + +export class CreateIdeaDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + title: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend/src/modules/ideas/entities/idea.entity.ts b/backend/src/modules/ideas/entities/idea.entity.ts new file mode 100644 index 0000000..2d96657 --- /dev/null +++ b/backend/src/modules/ideas/entities/idea.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Organization } from '../../organizations/entities/organization.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity({ schema: 'shared', name: 'ideas' }) +export class Idea { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'org_id' }) + orgId: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ length: 255 }) + title: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ length: 20, default: 'new' }) + status: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'org_id' }) + organization: Organization; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User; +} diff --git a/backend/src/modules/ideas/ideas.controller.ts b/backend/src/modules/ideas/ideas.controller.ts new file mode 100644 index 0000000..b4a395b --- /dev/null +++ b/backend/src/modules/ideas/ideas.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { IdeasService } from './ideas.service'; +import { CreateIdeaDto } from './dto/create-idea.dto'; + +@ApiTags('ideas') +@Controller('ideas') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +export class IdeasController { + constructor(private ideasService: IdeasService) {} + + @Post() + async create(@Req() req: any, @Body() dto: CreateIdeaDto) { + const orgId = req.user.orgId; + const userId = req.user.userId || req.user.sub; + const idea = await this.ideasService.create(orgId, userId, dto); + return { success: true, idea }; + } + + @Get() + async findByOrg(@Req() req: any) { + const orgId = req.user.orgId; + return this.ideasService.findByOrg(orgId); + } +} diff --git a/backend/src/modules/ideas/ideas.module.ts b/backend/src/modules/ideas/ideas.module.ts new file mode 100644 index 0000000..c3a7fcc --- /dev/null +++ b/backend/src/modules/ideas/ideas.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Idea } from './entities/idea.entity'; +import { Organization } from '../organizations/entities/organization.entity'; +import { IdeasController } from './ideas.controller'; +import { IdeasService } from './ideas.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Idea, Organization])], + controllers: [IdeasController], + providers: [IdeasService], + exports: [IdeasService], +}) +export class IdeasModule {} diff --git a/backend/src/modules/ideas/ideas.service.ts b/backend/src/modules/ideas/ideas.service.ts new file mode 100644 index 0000000..c081986 --- /dev/null +++ b/backend/src/modules/ideas/ideas.service.ts @@ -0,0 +1,78 @@ +import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Idea } from './entities/idea.entity'; +import { Organization } from '../organizations/entities/organization.entity'; +import { CreateIdeaDto } from './dto/create-idea.dto'; + +@Injectable() +export class IdeasService { + constructor( + @InjectRepository(Idea) + private ideasRepository: Repository, + @InjectRepository(Organization) + private orgRepository: Repository, + ) {} + + async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise { + const org = await this.orgRepository.findOne({ where: { id: orgId } }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + if (org.settings?.ideationEnabled !== true) { + throw new ForbiddenException('Ideation is not enabled for this organization'); + } + + const idea = this.ideasRepository.create({ + orgId, + userId, + title: dto.title, + description: dto.description, + }); + return this.ideasRepository.save(idea); + } + + async findByOrg(orgId: string): Promise { + return this.ideasRepository.find({ + where: { orgId }, + order: { createdAt: 'DESC' }, + }); + } + + async findAll(): Promise { + return this.ideasRepository + .createQueryBuilder('idea') + .leftJoin('idea.organization', 'org') + .leftJoin('idea.user', 'user') + .select([ + 'idea.id AS id', + 'idea.title AS title', + 'idea.description AS description', + 'idea.status AS status', + 'idea.createdAt AS "createdAt"', + 'org.id AS "orgId"', + 'org.name AS "orgName"', + 'user.id AS "userId"', + 'user.email AS "userEmail"', + 'user.firstName AS "userFirstName"', + 'user.lastName AS "userLastName"', + ]) + .orderBy('idea.createdAt', 'DESC') + .getRawMany(); + } + + async updateStatus(id: string, status: string): Promise { + const validStatuses = ['new', 'reviewed', 'accepted', 'rejected']; + if (!validStatuses.includes(status)) { + throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`); + } + + const idea = await this.ideasRepository.findOne({ where: { id } }); + if (!idea) { + throw new NotFoundException('Idea not found'); + } + + idea.status = status; + return this.ideasRepository.save(idea); + } +} diff --git a/db/migrations/018-ideas.sql b/db/migrations/018-ideas.sql new file mode 100644 index 0000000..9d1f9d6 --- /dev/null +++ b/db/migrations/018-ideas.sql @@ -0,0 +1,15 @@ +-- Ideation feature: shared ideas table for cross-tenant idea submissions +CREATE TABLE IF NOT EXISTS shared.ideas ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'new', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id); +CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status); +CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC); diff --git a/frontend/src/components/ideas/IdeaModal.tsx b/frontend/src/components/ideas/IdeaModal.tsx new file mode 100644 index 0000000..ff74887 --- /dev/null +++ b/frontend/src/components/ideas/IdeaModal.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { useMutation } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface IdeaModalProps { + opened: boolean; + onClose: () => void; +} + +export function IdeaModal({ opened, onClose }: IdeaModalProps) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + + const submitIdea = useMutation({ + mutationFn: async () => { + const { data } = await api.post('/ideas', { title, description }); + return data; + }, + onSuccess: () => { + notifications.show({ message: 'Idea submitted — thank you!', color: 'green' }); + setTitle(''); + setDescription(''); + onClose(); + }, + onError: (err: any) => { + notifications.show({ + message: err.response?.data?.message || 'Failed to submit idea', + color: 'red', + }); + }, + }); + + const handleClose = () => { + setTitle(''); + setDescription(''); + onClose(); + }; + + return ( + + + setTitle(e.currentTarget.value)} + maxLength={255} + /> +