79 Commits

Author SHA1 Message Date
93eeacfe8f Merge branch 'claude/reverent-moore' into feature/board-planning 2026-03-16 16:28:52 -04:00
17bdebfb52 chore: reorganize sidebar navigation and bump version to 2026.03.16
Remove the Planning section. Move Projects and Capital Planning (as
sub-item) into Board Planning. Move Investment Planning with Investment
Scenarios as sub-item into Board Planning. Move Vendors into new Board
Reference section. Board Planning order: Budget Planning, Projects >
Capital Planning, Assessment Scenarios, Investment Planning > Investment
Scenarios, Compare Scenarios. Sidebar now supports parent items with
their own route plus nested children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:28:45 -04:00
267d92933e chore: reorganize sidebar navigation and bump version to 2026.03.16
Remove the Planning section. Move Projects and Capital Planning (as
sub-item) into Board Planning. Move Investment Planning with Investment
Scenarios as sub-item into Board Planning. Move Vendors into new Board
Reference section. Board Planning order: Budget Planning, Projects >
Capital Planning, Assessment Scenarios, Investment Planning > Investment
Scenarios, Compare Scenarios. Sidebar now supports parent items with
their own route plus nested children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:21:58 -04:00
159c59734e feat: investment scenario UX improvements and interest calculations
- Refresh Recommendations now shows inline processing banner with
  animated progress bar while keeping existing results visible (dimmed).
  Auto-scrolls to AI section and shows titled notification on completion.
- Investment recommendations now auto-calculate purchase and maturity
  dates from a configurable start date (defaults to today) in the
  "Add to Plan" modal, so scenarios build projections immediately.
- Projection engine computes per-investment and total interest earned,
  ROI percentage, and total principal invested. Summary cards on the
  Investment Scenario detail page display these metrics prominently.
- Replaced dropdown action menu with inline Edit/Execute/Remove
  icon buttons matching the assessment scenarios pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:18:40 -04:00
7ba5c414b1 fix: handle multi-component investment recommendations (CD ladders)
When adding a multi-stage investment strategy (e.g. CD ladder) from AI
recommendations to a board planning scenario, all component investments
are now created as separate rows instead of collapsing into a single
investment. The AI prompt now instructs inclusion of a components array,
the backend loops through components to create individual scenario
investments, and the frontend passes and displays component details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:59:56 -04:00
a98a7192bb fix: assessment scenarios UX tweaks and projection improvements
- Reorder sidebar: Assessment Scenarios now directly under Budget Planning
- Simplify special assessment form: remove Total Amount, keep Per Unit only
- Replace Duration field from free-text installments to dropdown (one-time/quarterly/6mo/annual)
- Update Change column display to show total per-unit with duration label
- Fix Reserve Coverage to use planned capital project costs instead of budget expenses
- Include capital_projects table in projection engine alongside projects table
- Replace actions dropdown menu with inline Edit/Remove icon buttons
- Remove Refresh Projection button (projection auto-refreshes on changes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:28:33 -04:00
1d1073cba1 style: add white glow outline to logo in dark mode
Use CSS drop-shadow filter on the logo img in dark mode to create a
subtle white outline that helps the transparent-background logo stand
out against the dark header and login page backgrounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:02:04 -04:00
cf061c1505 fix: Budget Manager shows stale data when switching years
The budgetData was stored in a separate useState and updated inside
queryFn. When switching years, React Query served cached data with
isLoading=false but the local state still held the previous year's
data, causing the "no budget" empty state to flash intermittently.

Fix: Use query data directly as source of truth. Local state (editData)
is only used when actively editing. Added a small spinner indicator
when refetching in the background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:59:46 -04:00
5ebfc4f3aa chore: replace SVG logo with transparent-background PNG for dark theme support
- Add new logo.png (2090x512) with transparent background
- Update AppLayout and LoginPage imports from .svg to .png
- Old SVG had opaque background that clashed with dark theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:52:46 -04:00
f20f54b128 fix: compound inflation, budget planner CSV import, simplify budget manager
- Fix compound inflation: use Math.pow(1 + rate/100, yearsGap) instead of
  flat rate so multi-year gaps (e.g., 2026→2029) compound annually
- Budget Planner: add CSV import flow + Download Template button; show proper
  empty state when no base budget exists with Create/Import options
- Budget Manager: remove CSV import, Download Template, and Save buttons;
  redirect users to Budget Planner when no budget exists for selected year
- Fix getAvailableYears to return null latestBudgetYear when no budgets exist
  and include current year in year selector for fresh tenants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:39:31 -04:00
f2b0b57535 fix: use correct column name for accounts table in budget planning query
The accounts table uses 'name' not 'account_name'. Alias it correctly
in the getPlan JOIN query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:37:47 -04:00
e6fe2314de feat: add Future Year Budget Planning with inflation-adjusted projections
Adds budget planning capability under Board Planning, allowing HOA boards
to model future year budgets with configurable per-year inflation rates.

Backend:
- New budget_plans + budget_plan_lines tables (migration 014)
- BudgetPlanningService: CRUD, inflation generation (per-month preservation),
  status workflow (planning → approved → ratified), ratify-to-official copy
- 8 new API endpoints on board-planning controller
- Projection engine (both board-planning and reports) now falls back to
  planned budgets via UNION ALL query when no official budget exists
- Extended year range from 3 to dynamic based on projection months

Frontend:
- BudgetPlanningPage with monthly grid table (mirrors BudgetsPage pattern)
- Year selector, inflation rate control, status progression buttons
- Inline editing with save, confirmation modals for status changes
- Manual edit tracking with visual indicator
- Summary cards for income/expense totals

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:24:18 -04:00
c8d77aaa48 feat: add Board Planning module with investment/assessment scenario modeling
Implements Phase 11 Forecasting Tools - a "what-if" scenario planning system
for HOA boards to model financial decisions before committing.

Backend:
- 3 new tenant-scoped tables: board_scenarios, scenario_investments, scenario_assessments
- Migration script (013) for existing tenants
- Full CRUD service for scenarios, investments, and assessments
- Projection engine adapted from cash flow forecast with investment/assessment deltas
- Scenario comparison endpoint (up to 4 scenarios)
- Investment execution flow: converts planned → real investment_accounts + journal entry

Frontend:
- New "Board Planning" sidebar section with 3 pages
- Investment Scenarios: list, create, detail with investments table + timeline
- Assessment Scenarios: list, create, detail with changes table
- Scenario Comparison: multi-select overlay chart + summary metrics
- Shared components: ProjectionChart, InvestmentTimeline, ScenarioCard, forms
- AI Recommendation → Investment Plan integration (Story 1A)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:52:10 -04:00
b13fbfe8c7 Merge branch 'claude/ecstatic-elgamal' 2026-03-13 14:52:59 -04:00
280a5996f6 fix: use rate-based estimate for interest YoY projection
The projected interest was extrapolating from sparse YTD journal entries,
producing inaccurate results early in the year. Now uses the same
rate-based est_monthly_interest calculation (from account balances and
investment rates) for remaining months, consistent with the dashboard KPI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:52:54 -04:00
9a082d2950 Merge branch 'claude/ecstatic-elgamal' 2026-03-13 14:41:20 -04:00
82433955bd feat: dashboard quick stats enhancements and monthly actuals read/edit mode
Dashboard Quick Stats:
- Create Capital Projects section with "Planned Capital Spend 2026"
- Fix Interest Earned YTD to pull from actual journal entries on
  interest income accounts instead of unrealized investment gains
- Add Interest Earned YoY showing projected current year vs last year
  actuals with percentage change badge

Monthly Actuals:
- Default to read-only view when actuals are already reconciled
- Show "Edit Actuals" button instead of "Save Actuals" for reconciled months
- Add confirmation modal warning that editing will void existing journal
  entry before allowing edits
- New months without actuals open directly in edit mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:41:14 -04:00
8e2456dcae Merge branch 'claude/ecstatic-elgamal' 2026-03-11 15:51:12 -04:00
1acd8c3bff fix: check reserve-funded projects instead of unused reserve_components table
The missing-data warning was checking the reserve_components table,
which users never populate. All reserve data lives in the projects
table. Now only warns when no reserve-funded projects exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:51:12 -04:00
2de0cde94c Merge branch 'claude/ecstatic-elgamal' 2026-03-11 15:47:02 -04:00
94c7c90b91 fix: use project estimated_cost for reserve funded ratio calculation
The health score funded ratio was only reading from the reserve_components
table (replacement_cost), but users enter their reserve data on the
Projects page using estimated_cost. When reserve_components is empty,
the funded ratio now falls back to reserve-funded projects for:
- Total replacement cost (estimated_cost)
- Component funding status (current_fund_balance)
- Urgent components due within 5 years (remaining_life_years)
- AI prompt component detail lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:46:56 -04:00
f47fbfcf93 Merge branch 'claude/ecstatic-elgamal' 2026-03-11 15:42:24 -04:00
04771f370c fix: clarify reserve health score when no components are entered
- Add missing-data warning when reserve_components table is empty so
  users see "No reserve components found" on the dashboard
- Change AI prompt to show "N/A" instead of "0.0%" for funded ratio
  when no components exist, preventing misleading "0% funded" reports
- Instruct AI not to report 0% funded when data is simply missing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:42:15 -04:00
208c1dd7bc security: address assessment findings and bump to v2026.3.11
- C1: Disable Swagger UI in production (env gate)
- M1+M2: Add Helmet.js for security headers (CSP, X-Frame-Options,
  X-Content-Type-Options, Referrer-Policy) and remove X-Powered-By
- H2: Add @nestjs/throttler rate limiting (5 req/min on login/register)
- M4: Remove orgSchema from JWT payload and client-side storage;
  tenant middleware now resolves schema from orgId via cached DB lookup
- L1: Fix Chatwoot user identification (read from auth store on ready)
- Remove schemaName from frontend Organization type and UI displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:32:51 -04:00
61a4f27af4 security: address assessment findings and bump to v2026.3.11
- C1: Disable Swagger UI in production (env gate)
- M1+M2: Add Helmet.js for security headers (CSP, X-Frame-Options,
  X-Content-Type-Options, Referrer-Policy) and remove X-Powered-By
- H2: Add @nestjs/throttler rate limiting (5 req/min on login/register)
- M4: Remove orgSchema from JWT payload and client-side storage;
  tenant middleware now resolves schema from orgId via cached DB lookup
- L1: Fix Chatwoot user identification (read from auth store on ready)
- Remove schemaName from frontend Organization type and UI displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:22:58 -04:00
a047144922 Added userID and URL to Chatwoot Script 2026-03-10 14:49:50 -04:00
508a86d16c fix: resolve Vite parse5 HTML error in index.html
Fix malformed Chatwoot chat widget script that caused Vite's parse5
HTML parser to throw "eof-in-element-that-can-contain-only-text".
Also fix broken URL (https// -> https://) for the chat widget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:32:35 -04:00
16e1ada261 fix: budget save error and add read-only view mode (v2026.03.10)
Fix budget save 500 error caused by three data mismatches between
frontend and backend: wrapped payload ({lines:[...]}) vs expected
raw array, snake_case vs camelCase field names (account_id vs
accountId), and dec_amt vs dec for December values.

Add read-only budget view as default for existing budgets with an
"Edit Budget" button to enter edit mode, and Cancel to discard
changes - reducing accidental edits.

Bump version to 2026.03.10 across all packages and settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:28:09 -04:00
6bd080f8c4 Merge branch 'claude/practical-rhodes' 2026-03-10 14:22:14 -04:00
be3a5191c5 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:22:08 -04:00
b0282b7f8b fix: show P&L debit/credit totals on journal entries list
The previous aggregation used simple SUM(debit)/SUM(credit) which
always produced equal values for balanced entries. This was misleading
for entries with income/expense lines (e.g., monthly actuals).

Now, when an entry has income/expense lines, the totals reflect only
P&L account activity (expenses as debits, income as credits), excluding
the cash offset. For balance-sheet-only entries (opening balances,
adjustments), the full entry totals are shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:41:26 -04:00
ac72905ecb fix: add total_debit/total_credit aggregations to journal entries list
The findAll query was missing SUM aggregations, so the frontend received
no total_debit/total_credit fields and fell back to displaying $0.00.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:17:08 -04:00
7d4df25d16 Update frontend/index.html 2026-03-09 14:17:04 -04:00
538828b91a Merge pull request 'fix: dark mode styling across 5 pages' (#4) from fix/dark-mode-styling into main 2026-03-09 14:04:50 -04:00
14160854b9 fix: resolve hardcoded light backgrounds breaking dark mode across 5 pages
Replace hardcoded light colors (#e6f9e6, #fde8e8, white, #e9ecef) with
theme-aware alternatives using usePreferencesStore. Affected pages:
- CashFlowForecastPage: forecast row and striped row backgrounds
- MonthlyActualsPage: sticky column backgrounds, borders, section headers
- BudgetsPage: sticky column backgrounds, borders, section headers
- BudgetVsActualPage: income/expense section header backgrounds
- QuarterlyReportPage: income/expense and total row backgrounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:02:46 -04:00
36d486d78c Add Chat Widget for support
added support chat widget to index.html
2026-03-09 13:31:17 -04:00
3bf6b8c6c9 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:49:23 -04:00
4759374883 feat: add dark mode with persistent user preference
Add dark mode support using Mantine's built-in color scheme system,
persisted via a new Zustand preferences store. Includes a quick toggle
in the app header and an enabled switch in User Preferences. Also
removes the "AI Health Scores" title from the dashboard to reclaim
vertical space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:36:11 -04:00
cb6e34d5ce feat: add password reset utility script
Usage: ./scripts/reset-password.sh <email> <new-password>
Generates bcrypt hash via bcryptjs in the backend container,
updates the database, and verifies the hash matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:19:22 -05:00
2b72951e66 chore: bump version to 2026.3.7 (Beta)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
69dad7cc74 fix: resolve 5 invoice/payment issues from user feedback
- Replace misleading 'sent' status with 'pending' (no email capability)
- Show assessment group name instead of raw 'regular_assessment' type
- Add owner last name to invoice table
- Fix payment creation Internal Server Error (PostgreSQL $2 type cast)
- Add edit/delete capability for payment records with invoice recalc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
efa5aca35f feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly,
annual) with configurable due months and due day. Invoice generation
respects each group's schedule - only generating invoices when the
selected month is a billing month for that group. Adds a generation
preview showing which groups will be billed, period tracking on
invoices, and billing period context in the payments UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
c429dcc033 Merge pull request 'fix: improve AI health score accuracy and consistency' (#1) from ai-improvements into main
Reviewed-on: #1
2026-03-06 14:44:39 -05:00
9146118df1 feat: async AI calls, 10-min timeout, and failure messaging
- Make all AI endpoints (health scores + investment recommendations)
  fire-and-forget: POST returns immediately, frontend polls for results
- Extend AI API timeout from 2-5 min to 10 min for both services
- Add "last analysis failed — showing cached data" message to the
  Investment Recommendations panel (matches health score widgets)
- Add status/error_message columns to ai_recommendations table
- Remove nginx AI timeout overrides (no longer needed)
- Users can now navigate away during AI processing without interruption

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:42:53 -05:00
07d15001ae fix: improve AI health score accuracy and consistency
Address 4 issues identified in AI feature audit:

1. Reduce temperature from 0.3 to 0.1 for health score calculations
   to reduce 16-40 point score volatility across runs

2. Add explicit cash runway classification rules to operating prompt
   preventing the model from rating sub-3-month runway as "positive"

3. Pre-compute total special assessment income in both operating and
   reserve prompts, eliminating per-unit vs total confusion ($300
   vs $20,100)

4. Make YTD budget comparison actuals-aware: only compare months with
   posted journal entries, show current month budget separately, and
   add prompt guidance about month-end posting cadence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:44:12 -05:00
a0b366e94a fix: resolve critical SQL and display bugs across 5 financial reports
- Fix systemic LEFT JOIN date filter bug in Balance Sheet, Income Statement,
  and Cash Flow Statement by using parenthesized INNER JOIN pattern so
  SUM(jel.debit/credit) respects date parameters
- Add Current Year Net Income synthetic equity line to Balance Sheet to
  satisfy the accounting equation (A = L + E) during open fiscal periods
- Add investment_accounts balances to Balance Sheet assets and corresponding
  equity lines for reserve/operating investment holdings
- Fix Cash Flow Statement beginning/ending cash always showing $0 by
  replacing LIKE '%Cash%' filter with account_type = 'asset'
- Fix Year-End Package HTTP 500 by replacing broken invoices.vendor_id
  query with journal-entry-based vendor payment lookup
- Fix Quarterly Report defaulting to previous quarter instead of current
- Fix Quarterly Report date subtitle off-by-one day from UTC parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:15:01 -05:00
3790a3bd9e docs: add scaling guide for production infrastructure
Covers vertical tuning, managed service offloading, horizontal scaling
with replicas, and multi-node strategies. Includes resource budgets for
the current 4-core/24GB VM, monitoring thresholds for New Relic alerts,
PostgreSQL/Redis tuning values, and a scaling decision tree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:06:22 -05:00
0a07c61ca3 perf: remove unnecessary postgres/redis host port mappings in production
Backend reaches postgres and redis over the Docker network (hoanet),
so host port mappings are unnecessary. Removing them eliminates 4
docker-proxy processes and closes 0.0.0.0:5432 and 0.0.0.0:6379
which were publicly reachable — a security and performance fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:52:09 -05:00
337b6061b2 feat: reliability enhancements for AI services and capital planning
1. Health Scores — separate operating/reserve refresh
   - Added POST /health-scores/calculate/operating and /calculate/reserve
   - Each health card now has its own Refresh button
   - On failure, shows cached (last good) data with "last analysis failed"
     watermark instead of blank "Error calculating score"
   - Backend getLatestScores returns latest complete score + failure flag

2. Investment Planning — increased AI timeout to 5 minutes
   - Backend callAI timeout: 180s → 300s
   - Frontend axios timeout: set explicitly to 300s (was browser default)
   - Host nginx proxy_read_timeout: 180s → 300s
   - Loading message updated to reflect longer wait times

3. Capital Planning — Unscheduled column moved to rightmost position
   - Kanban column order: current year → future → unscheduled (was leftmost)
   - Puts immediate/near-term projects front and center

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 12:02:30 -05:00
467fdd2a6c fix: auto-detect system Chromium for puppeteer on Linux servers
Puppeteer's bundled Chrome often fails on Linux servers due to
architecture mismatches. Now auto-detects system-installed Chromium
at common paths (/usr/bin/chromium-browser, /usr/bin/chromium), or
honors PUPPETEER_EXECUTABLE_PATH env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:05:53 -05:00
c12ad94b7f fix: rewrite Bankrate scraper to extract actual bank names from offer cards
The previous scraper was picking up Bankrate's summary table
(.wealth-product-rate-list) which only has "best rates" per term with
no bank names, resulting in entries like "Top CD Rate - 1 year".

Now targets the actual bank offer cards in .wrt-RateSections-sponsoredoffers
and .wrt-RateSections-additionaloffers sections. Key changes:

- Extract bank names from img[alt] (logo) with text-based fallbacks
- Fix APY parsing to avoid Bankrate score leaking in (e.g. "4.5" score
  concatenated with "4.00%" APY was parsed as 0.4%)
- Handle both "Min. deposit" (CDs) and "Min. balance for APY" (savings/MM)
- Parse abbreviated terms from Bankrate (e.g. "1yr", "14mo")
- Strip product suffixes from bank names (e.g. "Synchrony Bank CD" → "Synchrony Bank")
- Filter out entries that aren't real banks (terms, dollar amounts)
- Keep a fallback strategy for future Bankrate layout changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:44:58 -05:00
05e241c792 fix: allow null planned_date when updating projects
Empty string date values from the frontend were being passed directly
to PostgreSQL, which cannot cast "" to DATE. Normalize empty strings
to null for all date columns in the update method and the dedicated
updatePlannedDate endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:05:29 -05:00
5ee4c71fc1 chore: update package-lock.json with newrelic dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:54:12 -05:00
81908e48ea feat: add New Relic APM instrumentation to backend
Add Node.js New Relic agent with an on/off switch via NEW_RELIC_ENABLED
in .env. Uses a preload script (-r newrelic-preload.js) so the agent
loads before all other modules. Configured entirely through environment
variables — no newrelic.js config file needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:31:56 -05:00
6230558b91 fix: remove backend/frontend host port mappings from base compose
Docker Compose merges port arrays from base + override files, so the
base 3000:3000 and the prod overlay 127.0.0.1:3000:3000 conflicted.
Removed direct host port mappings from the base — dev traffic already
routes through the Docker nginx on port 80 via the bridge network.
The prod overlay cleanly adds loopback-only mappings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:14:24 -05:00
2c215353d4 refactor: remove Docker nginx from production, use host nginx directly
The production stack no longer runs a Docker nginx container. Instead,
the host-level nginx handles SSL termination AND request routing:
  /api/* → 127.0.0.1:3000 (backend)
  /*     → 127.0.0.1:3001 (frontend)

Changes:
- docker-compose.prod.yml: set nginx replicas to 0, expose backend and
  frontend on 127.0.0.1 only (loopback)
- nginx/host-production.conf: new ready-to-copy host nginx config with
  SSL, rate limiting, proxy buffering, and AI endpoint timeouts
- docs/DEPLOYMENT.md: rewritten production deployment and SSL sections
  to reflect the simplified single-nginx architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:08:32 -05:00
d526025926 fix: map Docker nginx to port 8080 to avoid conflict with host reverse proxy
The base docker-compose.yml maps nginx to 80:80, which conflicts with
the host-level nginx that handles SSL termination on production servers.
The production overlay now explicitly maps to 8080:80 so the host proxy
can forward to localhost:8080. Updated DEPLOYMENT.md with host reverse
proxy setup instructions and corrected architecture diagrams.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:59:24 -05:00
411239bea4 Prod infra: frontend on port 3001, remove certbot from compose
- Frontend container nginx listens on 3001 instead of 80 to avoid
  conflicts with the host-level reverse proxy
- Removed certbot service, volumes, and SSL config from
  docker-compose.prod.yml — SSL/certbot is managed at the host level
- Updated nginx/production.conf: HTTP-only (host handles TLS),
  upstream frontend points to port 3001
- Updated nginx/ssl.conf frontend upstream to 3001 for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:43:14 -05:00
7e6c4c16ce Update backend/src/main.ts 2026-03-02 17:56:01 -05:00
ea0e3d6f29 Fix TS error: guard null target_year in KanbanCard comparison
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:00:55 -05:00
8db89373e0 Add production infrastructure: compiled builds, clustering, connection pooling
Root cause of 502 errors under 30 concurrent users: the production server
was running dev-mode infrastructure (Vite dev server, NestJS --watch,
no DB connection pooling, single Node.js process).

Changes:
- backend/Dockerfile: multi-stage prod build (compiled JS, no devDeps)
- frontend/Dockerfile: multi-stage prod build (static assets served by nginx)
- frontend/nginx.conf: SPA routing config for frontend container
- docker-compose.prod.yml: production overlay with tuned Postgres, memory
  limits, health checks, restart policies
- nginx/production.conf: keepalive upstreams, proxy buffering, rate limiting
- backend/src/main.ts: Node.js clustering (1 worker per CPU, up to 4),
  conditional request logging, production CORS
- backend/src/app.module.ts: TypeORM connection pool (max 30, min 5)
- docs/DEPLOYMENT.md: new Production Deployment section

Deploy with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:55:30 -05:00
e719f593de Update frontend/vite.config.ts
Adding ssl hostname
2026-03-02 15:13:36 -05:00
16adfd6f26 Fix: add react-joyride to frontend dependencies
The package was imported by AppTour.tsx but missing from package.json,
causing a build failure on fresh installs / production deploys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:06:27 -05:00
704f29362a Add database backup/restore script with auto-pruning
scripts/db-backup.sh provides three commands:
- backup: creates a timestamped, gzipped pg_dump (custom format) in ./backups/
- restore: drops, recreates, and loads a backup with confirmation prompt
- list: shows available backups with sizes and dates

Supports --keep N flag for automatic pruning of backups older than N days,
making it cron-friendly for daily automated backups. Also adds backups/
and *.dump.gz to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:40:51 -05:00
42767e3119 Add SSL/TLS support with Certbot and update deployment guide
- nginx/ssl.conf: full HTTPS config with HTTP→HTTPS redirect, modern TLS
  settings, HSTS header, and ACME challenge passthrough for renewals
- nginx/certbot-init.conf: minimal HTTP config for initial cert provisioning
- docker-compose.ssl.yml: compose override adding port 443, certbot volumes,
  and auto-renewal sidecar container
- docs/DEPLOYMENT.md: comprehensive 3-phase SSL walkthrough (obtain cert,
  enable SSL, auto-renewal) with day-to-day usage and revert instructions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:28:01 -05:00
a550a8d0be Add deployment guide for staging Docker servers with DB backup/restore
Covers fresh server setup, environment configuration, database backup
(full and per-tenant), restore into staged environment, migration
execution, and verification steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:09:32 -05:00
063741adc7 Capital Planning: add Unscheduled bucket for imported projects without target_year
Projects imported via CSV that lack a target_year were invisible in Capital
Planning because findForPlanning() filtered on target_year IS NOT NULL. This
removes that filter and adds an "Unscheduled" Kanban column (orange background,
2-col layout) so users can drag unscheduled projects into year buckets.
Also bumps app version to 2026.3.2 (beta).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:03:39 -05:00
ad2f16d93b Capital Planning: show beyond-window projects in Future bucket, 2-col layout
Projects with target years beyond the 5-year planning window now
appear in the Future column of the Kanban board (previously they
were invisible). Cards for these projects show their specific target
year as a badge. The Future column uses a 2-column grid layout when
it has more than 3 projects to maximize screen utilization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:16:20 -05:00
b0b36df4e4 Reserve health: add projected cash flow with special assessments; add Last Updated to cards
Reserve fund analysis now includes 12-month forward projection with
special assessment income (by frequency), monthly budget data,
capital project costs, and investment maturities. AI prompt updated
to evaluate projected reserve liquidity and timing risks.

Both health score dashboard cards now show a subtle "Last updated"
timestamp at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:18:34 -05:00
aa7f2dab32 Add 12-month projected cash flow to operating health score analysis
The operating health score now includes forward-looking cash flow
projections using monthly budget data, assessment income schedules,
and operating project costs. AI prompt updated to evaluate projected
liquidity, timing risks, and year-end cash position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:10:51 -05:00
d2d553eed6 Fix health scores: use correct invoices column name (amount, not amount_due)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:00:23 -05:00
2ca277b6e6 Phase 8: AI-driven operating and reserve fund health scores
Add daily AI health score calculation (0-100) for both operating and
reserve funds. Scores include trajectory tracking, factor analysis,
recommendations, and data readiness checks. Dashboard displays
graphical RingProgress gauges with color-coded scores, trend
indicators, and expandable detail popovers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:56:56 -05:00
bfcbe086f2 Fix WriteAccessGuard: use req.userRole from middleware (runs before guards)
The global WriteAccessGuard was checking req.user.role, but req.user is
set by JwtAuthGuard (a per-controller guard) which runs AFTER global guards.
TenantMiddleware sets req.userRole from the JWT before guards execute,
so we now check that property first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:21:09 -05:00
c92eb1b57b RBAC: Enforce read-only viewer role across backend and frontend
- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role
- Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations)
- Add useIsReadOnly hook to auth store for frontend role checks
- Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers
- Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers
- Skip onboarding wizard for viewer role users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:18:32 -05:00
07347a644f QoL tweaks: Cash Flow cards, auto-primary accounts, investment projections, Sankey filters
- Dashboard: Remove tenant name/role subtitle
- Cash Flow: Replace Operating/Reserve net cards with inflow vs outflow
  breakdown showing In/Out amounts and signed net; replace Ending Cash
  card with AI Financial Health status from saved recommendation
- Accounts: Auto-set first asset account per fund_type as primary on creation
- Investments: Add 5th summary card for projected annual interest earnings
- Sankey: Add Actuals/Budget/Forecast data source toggle and
  All Funds/Operating/Reserve fund filter SegmentedControls with
  backend support for budget-based and forecast (actuals+budget) queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:22:37 -05:00
f1e66966f3 Phase 7: Add user onboarding tour and tenant setup wizard
Feature 1 - How-To Intro Tour (react-joyride):
- 8-step guided walkthrough highlighting Dashboard, Accounts, Assessments,
  Transactions, Budgets, Reports, and AI Investment Planning
- Runs automatically on first login, tracked via has_seen_intro flag on user
- Centralized step config in config/tourSteps.ts for easy text editing
- data-tour attributes on Sidebar nav items and Dashboard for targeting

Feature 2 - Tenant Onboarding Wizard:
- 3-step modal wizard: create operating account, assessment group + units,
  import budget CSV
- Runs after tour completes, tracked via onboardingComplete in org settings JSONB
- Reuses existing API endpoints (POST /accounts, /assessment-groups, /units,
  /budgets/:year/import)

Backend changes:
- Add has_seen_intro column to shared.users + migration
- Add PATCH /auth/intro-seen endpoint to mark tour complete
- Add PATCH /organizations/settings endpoint for org settings updates
- Include hasSeenIntro in login response, settings in switch-org response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:47:45 -05:00
d1c40c633f Fix dashboard KPIs: resolve nested aggregate and missing column errors
- Wrap account interest query in subquery to avoid SUM(SUM(...)) nesting
- Replace nonexistent interest_earned column with current_value - principal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:11:37 -05:00
0e82e238c1 Bug & tweak sprint: fix financial calculations, add quarterly report, enhance dashboard
- Fix Accounts page: include investment accounts in Est. Monthly Interest calc,
  add Fund column to investment table, split summary cards into Operating/Reserve
- Fix Cash Flow: ending balance now respects includeInvestments toggle
- Fix Budget Manager: separate operating/reserve income in summary cards
- Fix Projects: default sort by planned_date instead of name
- Add Vendors: last_negotiated date field with migration, CSV import/export
- New Quarterly Financial Report: budget vs actuals, over-budget flagging, YTD
- Enhance Dashboard: separate Operating/Reserve fund cards, expanded Quick Stats
  with monthly interest, YTD interest earned, planned capital spend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:17:30 -05:00
2fed5d6ce1 Phase 6: Expand market rates and enhance AI investment recommendations
- Rate fetcher now scrapes CD, Money Market, and High Yield Savings rates
  from Bankrate.com with pauses between fetches to avoid rate limiting
- Historical rate data is preserved (no longer deleted on each fetch)
- Database migration adds rate_type column and tenant ai_recommendations table
- Backend returns market rates grouped by type with latest-batch-only queries
- AI prompt now includes all three rate types for comprehensive analysis
- AI recommendations are saved per-tenant for retrieval on page load
- Frontend: "Market CD Rates" replaced with "Today's Market Rates" tabbed view
- Rates section is collapsible (expanded by default) to save screen space
- Saved recommendations load automatically with "Last Updated" timestamp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:39:53 -05:00
117 changed files with 17575 additions and 1272 deletions

View File

@@ -12,3 +12,8 @@ AI_API_KEY=your_nvidia_api_key_here
AI_MODEL=qwen/qwen3.5-397b-a17b AI_MODEL=qwen/qwen3.5-397b-a17b
# Set to 'true' to enable detailed AI prompt/response logging # Set to 'true' to enable detailed AI prompt/response logging
AI_DEBUG=false AI_DEBUG=false
# New Relic APM — set ENABLED=true and provide your license key to activate
NEW_RELIC_ENABLED=false
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here
NEW_RELIC_APP_NAME=HOALedgerIQ_App

5
.gitignore vendored
View File

@@ -24,6 +24,11 @@ postgres_data/
redis_data/ redis_data/
pgdata/ pgdata/
# Database backups
backups/
*.dump
*.dump.gz
# SSL # SSL
letsencrypt/ letsencrypt/

136
PLAN.md Normal file
View File

@@ -0,0 +1,136 @@
# Phase 2 Bug Fix & Tweaks - Implementation Plan
## 1. Admin Panel: Tenant Creation, Contract/Plan Fields, Disable/Archive
### Database Changes
- Add `contract_number VARCHAR(100)` and `plan_level VARCHAR(50) DEFAULT 'standard'` to `shared.organizations` (live DB ALTER + init SQL)
- Add `archived` to the status CHECK constraint: `('active', 'suspended', 'trial', 'archived')`
- Add to Organization entity: `contractNumber`, `planLevel` columns
### Backend Changes
- **admin.controller.ts**: Add two new endpoints:
- `POST /admin/tenants` — Creates org + first user + tenant schema in one call. Accepts: org name, email, address, contractNumber, planLevel, plus first user's email/password/firstName/lastName. Calls OrganizationsService.create() then sets up the user.
- `PUT /admin/organizations/:id/status` — Sets status to 'active', 'suspended', or 'archived'
- **auth.module.ts**: Import OrganizationsModule so AdminController can inject OrganizationsService
- **auth.service.ts**: In `login()`, after loading user with orgs, check if the default org's status is 'suspended' or 'archived' → throw UnauthorizedException("Your organization has been suspended/archived")
- **users.service.ts**: Update `findAllOrganizations()` query to include `contract_number, plan_level` in the SELECT
### Frontend Changes
- **AdminPage.tsx**:
- Add "Create Tenant" button → opens a modal with: org name, address, email, phone, contract number, plan level (select: standard/premium/enterprise), first admin email, first admin password, first/last name
- Orgs table: add Contract #, Plan Level columns
- Orgs table: add Status dropdown/buttons (Active/Suspended/Archived) per row with confirmation
- Show status colors: active=green, trial=yellow, suspended=orange, archived=red
## 2. Units/Homeowners: Delete + Assessment Group Binding
### Backend Changes
- **units.controller.ts**: Add `@Delete(':id')` route
- **units.service.ts**:
- Add `delete(id)` method — checks for outstanding invoices first, then deletes
- Add `assessment_group_id` to `create()` INSERT and `update()` UPDATE queries
- Update `findAll()` to JOIN assessment_groups and return `assessment_group_name`
### Frontend Changes
- **UnitsPage.tsx**:
- Add delete button (trash icon) per row with confirmation dialog
- Add Assessment Group dropdown (Select) in create/edit modal, populated from `/assessment-groups` query
- Show assessment group name in table
- When an assessment group is selected and no manual monthly_assessment is set, auto-fill from the group's regular_assessment
## 3. Assessment Groups: Frequency Field
### Database Changes
- Add `frequency VARCHAR(20) DEFAULT 'monthly'` to `assessment_groups` table (live DB ALTER + tenant-schema DDL)
- CHECK constraint: `('monthly', 'quarterly', 'annual')`
### Backend Changes
- **assessment-groups.service.ts**:
- Add `frequency` to `create()` INSERT
- Add `frequency` to `update()` dynamic sets
- Update `findAll()` and `getSummary()` income calculations to adjust by frequency:
- monthly → multiply by 1 (×12/year)
- quarterly → amounts are per quarter, so monthly = amount/3
- annual → amounts are per year, so monthly = amount/12
- Summary labels should change to reflect "Monthly Equivalent" for mixed frequencies
### Frontend Changes
- **AssessmentGroupsPage.tsx**:
- Add frequency Select in create/edit modal: Monthly, Quarterly, Annual
- Show frequency badge in table
- Update summary cards: labels → "Monthly Equivalent Operating" etc.
- Assessment amount label changes based on frequency ("Per Month" / "Per Quarter" / "Per Year")
## 4. UI Streamlining: Sidebar Grouping, Rename, Logo
### Sidebar Restructure
Group nav items into labeled sections:
```
Dashboard
─── FINANCIALS ───
Accounts (renamed from "Chart of Accounts")
Budgets
Investments
─── ASSESSMENTS ───
Units / Homeowners
Assessment Groups
─── TRANSACTIONS ───
Transactions
Invoices
Payments
─── PLANNING ───
Capital Projects
Reserves
Vendors
─── REPORTS ───
(collapsible with sub-items)
─── ADMIN ───
Year-End
Settings
─── PLATFORM ADMIN ─── (superadmin only)
Admin Panel
```
### Logo
- Copy SVG to `frontend/src/assets/logo.svg`
- In AppLayout.tsx: Replace `<Title order={3} c="blue">HOA LedgerIQ</Title>` with an `<img>` tag loading the SVG, sized to fit the 60px header (height ~40px with padding)
- SVG will be served directly (Vite handles SVG imports natively), no PNG conversion needed since browsers render SVG natively and it's cleaner
## 5. Capital Projects: PDF Table Export, Kanban Default, Future Category
### Frontend Changes
- **CapitalProjectsPage.tsx**:
- Change default viewMode from `'table'` to `'kanban'`
- PDF export: temporarily switch to table view for print, then restore. Use `@media print` CSS to always show table layout regardless of current view
- Add "Future" column in kanban: projects with `target_year = 9999` (sentinel value) display as "Future"
- Update the form: Target Year select should include a "Future (Beyond 5-Year)" option that maps to year 9999
- Kanban year list: always include current year through +5, plus "Future" if any projects exist there
- Table view: group "Future" projects under a "Future" header
- Title: "Capital Projects" (remove "(5-Year Plan)" since we now have Future)
### Backend
- No backend changes needed — target_year=9999 works with existing schema (integer column, no constraint)
## File Change Summary
| File | Action |
|------|--------|
| `db/init/00-init.sql` | Add contract_number, plan_level, update status CHECK |
| `backend/src/modules/organizations/entities/organization.entity.ts` | Add contractNumber, planLevel columns |
| `backend/src/modules/organizations/dto/create-organization.dto.ts` | Add contractNumber, planLevel fields |
| `backend/src/modules/auth/admin.controller.ts` | Add POST /admin/tenants, PUT /admin/organizations/:id/status |
| `backend/src/modules/auth/auth.module.ts` | Import OrganizationsModule |
| `backend/src/modules/auth/auth.service.ts` | Add org status check on login |
| `backend/src/modules/users/users.service.ts` | Update findAllOrganizations query |
| `backend/src/modules/units/units.controller.ts` | Add DELETE route |
| `backend/src/modules/units/units.service.ts` | Add delete(), assessment_group_id support |
| `backend/src/modules/assessment-groups/assessment-groups.service.ts` | Add frequency support + adjust income calcs |
| `backend/src/database/tenant-schema.service.ts` | Add frequency to assessment_groups DDL |
| `frontend/src/assets/logo.svg` | New — copy from /Users/claw/Downloads/logo_house.svg |
| `frontend/src/components/layout/AppLayout.tsx` | Replace text with logo |
| `frontend/src/components/layout/Sidebar.tsx` | Restructure with grouped sections |
| `frontend/src/pages/admin/AdminPage.tsx` | Create tenant modal, status management, new columns |
| `frontend/src/pages/units/UnitsPage.tsx` | Delete, assessment group dropdown |
| `frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx` | Frequency field |
| `frontend/src/pages/capital-projects/CapitalProjectsPage.tsx` | Kanban default, table PDF, Future category |
| Live DB | ALTER TABLE commands for contract_number, plan_level, frequency, status CHECK |

32
backend/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# ---- Production Dockerfile for NestJS backend ----
# Multi-stage build: compile TypeScript, then run with minimal image
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
# Only install production dependencies
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Copy compiled output and New Relic preload from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/newrelic-preload.js ./newrelic-preload.js
# New Relic agent — configured entirely via environment variables
ENV NEW_RELIC_NO_CONFIG_FILE=true
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
ENV NEW_RELIC_LOG=stdout
EXPOSE 3000
# Preload the New Relic agent (activates only when NEW_RELIC_ENABLED=true)
CMD ["node", "-r", "./newrelic-preload.js", "dist/main"]

View File

@@ -7,6 +7,11 @@ RUN npm install
COPY . . COPY . .
# New Relic agent — configured entirely via environment variables
ENV NEW_RELIC_NO_CONFIG_FILE=true
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
ENV NEW_RELIC_LOG=stdout
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "start:dev"] CMD ["npm", "run", "start:dev"]

View File

@@ -0,0 +1,7 @@
// Conditionally load the New Relic agent before any other modules.
// Controlled by the NEW_RELIC_ENABLED environment variable (.env).
'use strict';
if (process.env.NEW_RELIC_ENABLED === 'true') {
require('newrelic');
}

1453
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "0.2.0", "version": "2026.03.16",
"description": "HOA LedgerIQ - Backend API", "description": "HOA LedgerIQ - Backend API",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -8,7 +8,7 @@
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node -r ./newrelic-preload.js dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@@ -23,12 +23,16 @@
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2", "@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"helmet": "^8.1.0",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"newrelic": "latest",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",

View File

@@ -1,9 +1,12 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware'; import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module'; import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -24,6 +27,9 @@ import { ProjectsModule } from './modules/projects/projects.module';
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module'; import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
import { AttachmentsModule } from './modules/attachments/attachments.module'; import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module'; import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({ @Module({
imports: [ imports: [
@@ -39,8 +45,19 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
autoLoadEntities: true, autoLoadEntities: true,
synchronize: false, synchronize: false,
logging: false, logging: false,
// Connection pool — reuse connections instead of creating new ones per query
extra: {
max: 30, // max pool size (across all concurrent requests)
min: 5, // keep at least 5 idle connections warm
idleTimeoutMillis: 30000, // close idle connections after 30s
connectionTimeoutMillis: 5000, // fail fast if pool is exhausted
},
}), }),
}), }),
ThrottlerModule.forRoot([{
ttl: 60000, // 1-minute window
limit: 100, // 100 requests per minute (global default)
}]),
DatabaseModule, DatabaseModule,
AuthModule, AuthModule,
OrganizationsModule, OrganizationsModule,
@@ -62,8 +79,17 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
MonthlyActualsModule, MonthlyActualsModule,
AttachmentsModule, AttachmentsModule,
InvestmentPlanningModule, InvestmentPlanningModule,
HealthScoresModule,
BoardPlanningModule,
ScheduleModule.forRoot(),
], ],
controllers: [AppController], controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ALLOW_VIEWER_KEY = 'allowViewer';
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);

View File

@@ -0,0 +1,35 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator';
@Injectable()
export class WriteAccessGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const method = request.method;
// Allow all read methods
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true;
// Determine role from either req.userRole (set by TenantMiddleware which runs
// before guards) or req.user.role (set by JwtAuthGuard Passport strategy).
const role = request.userRole || request.user?.role;
if (!role) return true; // unauthenticated endpoints like login/register
// Check for @AllowViewer() exemption on handler or class
const allowViewer = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_KEY, [
context.getHandler(),
context.getClass(),
]);
if (allowViewer) return true;
// Block viewer role from write operations
if (role === 'viewer') {
throw new ForbiddenException('Read-only users cannot modify data');
}
return true;
}
}

View File

@@ -112,6 +112,8 @@ export class TenantSchemaService {
special_assessment DECIMAL(10,2) DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0, unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')), frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}',
due_day INTEGER DEFAULT 1,
is_default BOOLEAN DEFAULT FALSE, is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -155,8 +157,11 @@ export class TenantSchemaService {
amount DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00, amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ( status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off' 'draft', 'pending', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
)), )),
period_start DATE,
period_end DATE,
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
journal_entry_id UUID REFERENCES "${s}".journal_entries(id), journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
sent_at TIMESTAMPTZ, sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ, paid_at TIMESTAMPTZ,
@@ -202,6 +207,7 @@ export class TenantSchemaService {
default_account_id UUID REFERENCES "${s}".accounts(id), default_account_id UUID REFERENCES "${s}".accounts(id),
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
ytd_payments DECIMAL(15,2) DEFAULT 0.00, ytd_payments DECIMAL(15,2) DEFAULT 0.00,
last_negotiated DATE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
)`, )`,
@@ -316,6 +322,38 @@ export class TenantSchemaService {
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
)`, )`,
// AI Investment Recommendations (saved per tenant)
`CREATE TABLE "${s}".ai_recommendations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recommendations_json JSONB NOT NULL,
overall_assessment TEXT,
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
status VARCHAR(20) DEFAULT 'complete',
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Health Scores (AI-derived operating / reserve fund health)
`CREATE TABLE "${s}".health_scores (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')),
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
previous_score INTEGER,
trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')),
label VARCHAR(30),
summary TEXT,
factors JSONB,
recommendations JSONB,
missing_data JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')),
response_time_ms INTEGER,
calculated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`,
// Attachments (file storage for receipts/invoices) // Attachments (file storage for receipts/invoices)
`CREATE TABLE "${s}".attachments ( `CREATE TABLE "${s}".attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
@@ -328,6 +366,99 @@ export class TenantSchemaService {
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
)`, )`,
// Board Planning - Scenarios
`CREATE TABLE "${s}".board_scenarios (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN ('investment', 'assessment')),
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'approved', 'archived')),
projection_months INTEGER DEFAULT 36,
projection_cache JSONB,
projection_cached_at TIMESTAMPTZ,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Board Planning - Scenario Investments
`CREATE TABLE "${s}".scenario_investments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
source_recommendation_id UUID,
label VARCHAR(255) NOT NULL,
investment_type VARCHAR(50) CHECK (investment_type IN ('cd', 'money_market', 'treasury', 'savings', 'other')),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
principal DECIMAL(15,2) NOT NULL,
interest_rate DECIMAL(6,4),
term_months INTEGER,
institution VARCHAR(255),
purchase_date DATE,
maturity_date DATE,
auto_renew BOOLEAN DEFAULT FALSE,
executed_investment_id UUID,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Board Planning - Scenario Assessments
`CREATE TABLE "${s}".scenario_assessments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
change_type VARCHAR(30) NOT NULL CHECK (change_type IN ('dues_increase', 'special_assessment', 'dues_decrease')),
label VARCHAR(255) NOT NULL,
target_fund VARCHAR(20) CHECK (target_fund IN ('operating', 'reserve', 'both')),
percentage_change DECIMAL(6,3),
flat_amount_change DECIMAL(10,2),
special_total DECIMAL(15,2),
special_per_unit DECIMAL(10,2),
special_installments INTEGER DEFAULT 1,
effective_date DATE NOT NULL,
end_date DATE,
applies_to_group_id UUID,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Budget Plans
`CREATE TABLE "${s}".budget_plans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fiscal_year INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'approved', 'ratified')),
base_year INTEGER NOT NULL,
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
notes TEXT,
created_by UUID,
approved_by UUID,
approved_at TIMESTAMPTZ,
ratified_by UUID,
ratified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(fiscal_year)
)`,
// Budget Plan Lines
`CREATE TABLE "${s}".budget_plan_lines (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
budget_plan_id UUID NOT NULL REFERENCES "${s}".budget_plans(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES "${s}".accounts(id),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
is_manually_adjusted BOOLEAN DEFAULT FALSE,
notes TEXT,
UNIQUE(budget_plan_id, account_id, fund_type)
)`,
// Indexes // Indexes
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`, `CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`, `CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
@@ -340,6 +471,12 @@ export class TenantSchemaService {
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`, `CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`, `CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`, `CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
`CREATE INDEX "idx_${s}_bs_type_status" ON "${s}".board_scenarios(scenario_type, status)`,
`CREATE INDEX "idx_${s}_si_scenario" ON "${s}".scenario_investments(scenario_id)`,
`CREATE INDEX "idx_${s}_sa_scenario" ON "${s}".scenario_assessments(scenario_id)`,
`CREATE INDEX "idx_${s}_bp_year" ON "${s}".budget_plans(fiscal_year)`,
`CREATE INDEX "idx_${s}_bp_status" ON "${s}".budget_plans(status)`,
`CREATE INDEX "idx_${s}_bpl_plan" ON "${s}".budget_plan_lines(budget_plan_id)`,
]; ];
} }

View File

@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
@Injectable() @Injectable()
export class TenantMiddleware implements NestMiddleware { export class TenantMiddleware implements NestMiddleware {
// In-memory cache for org status to avoid DB hit per request // In-memory cache for org info to avoid DB hit per request
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>(); private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
private static readonly CACHE_TTL = 60_000; // 60 seconds private static readonly CACHE_TTL = 60_000; // 60 seconds
constructor( constructor(
@@ -30,23 +30,25 @@ export class TenantMiddleware implements NestMiddleware {
const token = authHeader.substring(7); const token = authHeader.substring(7);
const secret = this.configService.get<string>('JWT_SECRET'); const secret = this.configService.get<string>('JWT_SECRET');
const decoded = jwt.verify(token, secret!) as any; const decoded = jwt.verify(token, secret!) as any;
if (decoded?.orgSchema) { if (decoded?.orgId) {
// Check if the org is still active (catches post-JWT suspension) // Look up org info (status + schema) from orgId with caching
if (decoded.orgId) { const orgInfo = await this.getOrgInfo(decoded.orgId);
const status = await this.getOrgStatus(decoded.orgId); if (orgInfo) {
if (status && ['suspended', 'archived'].includes(status)) { if (['suspended', 'archived'].includes(orgInfo.status)) {
res.status(403).json({ res.status(403).json({
statusCode: 403, statusCode: 403,
message: `This organization has been ${status}. Please contact your administrator.`, message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
}); });
return; return;
} }
req.tenantSchema = orgInfo.schemaName;
} }
req.tenantSchema = decoded.orgSchema;
req.orgId = decoded.orgId; req.orgId = decoded.orgId;
req.userId = decoded.sub; req.userId = decoded.sub;
req.userRole = decoded.role; req.userRole = decoded.role;
} else if (decoded?.sub) {
// Superadmin or user without org — still set userId
req.userId = decoded.sub;
} }
} catch { } catch {
// Token invalid or expired - let Passport handle the auth error // Token invalid or expired - let Passport handle the auth error
@@ -55,19 +57,23 @@ export class TenantMiddleware implements NestMiddleware {
next(); next();
} }
private async getOrgStatus(orgId: string): Promise<string | null> { private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
const cached = this.orgStatusCache.get(orgId); const cached = this.orgCache.get(orgId);
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) { if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
return cached.status; return { status: cached.status, schemaName: cached.schemaName };
} }
try { try {
const result = await this.dataSource.query( const result = await this.dataSource.query(
`SELECT status FROM shared.organizations WHERE id = $1`, `SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
[orgId], [orgId],
); );
if (result.length > 0) { if (result.length > 0) {
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() }); this.orgCache.set(orgId, {
return result[0].status; status: result[0].status,
schemaName: result[0].schemaName,
cachedAt: Date.now(),
});
return { status: result[0].status, schemaName: result[0].schemaName };
} }
} catch { } catch {
// Non-critical — don't block requests on cache miss errors // Non-critical — don't block requests on cache miss errors

View File

@@ -1,18 +1,72 @@
import * as _cluster from 'node:cluster';
import * as os from 'node:os';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
const isProduction = process.env.NODE_ENV === 'production';
// ---------------------------------------------------------------------------
// Clustering — fork one worker per CPU core in production
// ---------------------------------------------------------------------------
const WORKERS = isProduction
? Math.min(os.cpus().length, 4) // cap at 4 workers to stay within DB pool
: 1; // single process in dev
if (WORKERS > 1 && cluster.isPrimary) {
console.log(`Primary ${process.pid} forking ${WORKERS} workers ...`);
for (let i = 0; i < WORKERS; i++) {
cluster.fork();
}
cluster.on('exit', (worker: any, code: number) => {
console.warn(`Worker ${worker.process.pid} exited (code ${code}), restarting ...`);
cluster.fork();
});
} else {
bootstrap();
}
// ---------------------------------------------------------------------------
// NestJS bootstrap
// ---------------------------------------------------------------------------
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, {
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
});
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
// Request logging // Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
app.use((req: any, _res: any, next: any) => { // Referrer-Policy, Permissions-Policy, and removes X-Powered-By
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`); app.use(
next(); helmet({
}); contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://chat.hoaledgeriq.com'],
connectSrc: ["'self'", 'https://chat.hoaledgeriq.com', 'wss://chat.hoaledgeriq.com'],
imgSrc: ["'self'", 'data:', 'https://chat.hoaledgeriq.com'],
styleSrc: ["'self'", "'unsafe-inline'"],
frameSrc: ["'self'", 'https://chat.hoaledgeriq.com'],
fontSrc: ["'self'", 'data:'],
},
},
}),
);
// Request logging — only in development (too noisy / slow for prod)
if (!isProduction) {
app.use((req: any, _res: any, next: any) => {
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
next();
});
}
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
@@ -22,21 +76,24 @@ async function bootstrap() {
}), }),
); );
// CORS — in production nginx handles this; accept all origins behind the proxy
app.enableCors({ app.enableCors({
origin: ['http://localhost', 'http://localhost:5173'], origin: isProduction ? true : ['http://localhost', 'http://localhost:5173'],
credentials: true, credentials: true,
}); });
const config = new DocumentBuilder() // Swagger docs — disabled in production to avoid exposing API surface
.setTitle('HOA LedgerIQ API') if (!isProduction) {
.setDescription('API for the HOA LedgerIQ') const config = new DocumentBuilder()
.setVersion('0.1.0') .setTitle('HOA LedgerIQ API')
.addBearerAuth() .setDescription('API for the HOA LedgerIQ')
.build(); .setVersion('2026.3.11')
const document = SwaggerModule.createDocument(app, config); .addBearerAuth()
SwaggerModule.setup('api/docs', app, document); .build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
await app.listen(3000); await app.listen(3000);
console.log('Backend running on port 3000'); console.log(`Backend worker ${process.pid} listening on port 3000`);
} }
bootstrap();

View File

@@ -142,7 +142,21 @@ export class AccountsService {
} }
} }
return account; // Auto-set as primary if this is the first asset account for this fund_type
if (dto.accountType === 'asset') {
const existingPrimary = await this.tenant.query(
'SELECT id FROM accounts WHERE fund_type = $1 AND is_primary = true AND id != $2',
[dto.fundType, accountId],
);
if (!existingPrimary.length) {
await this.tenant.query(
'UPDATE accounts SET is_primary = true WHERE id = $1',
[accountId],
);
}
}
return this.findOne(accountId);
} }
async update(id: string, dto: UpdateAccountDto) { async update(id: string, dto: UpdateAccountDto) {

View File

@@ -1,6 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service'; import { TenantService } from '../../database/tenant.service';
const DEFAULT_DUE_MONTHS: Record<string, number[]> = {
monthly: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
quarterly: [1, 4, 7, 10],
annual: [1],
};
@Injectable() @Injectable()
export class AssessmentGroupsService { export class AssessmentGroupsService {
constructor(private tenant: TenantService) {} constructor(private tenant: TenantService) {}
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
return rows.length ? rows[0] : null; return rows.length ? rows[0] : null;
} }
private validateDueMonths(frequency: string, dueMonths: number[]) {
if (!dueMonths || !dueMonths.length) {
throw new BadRequestException('Due months are required');
}
// Validate all values are 1-12
if (dueMonths.some((m) => m < 1 || m > 12 || !Number.isInteger(m))) {
throw new BadRequestException('Due months must be integers between 1 and 12');
}
switch (frequency) {
case 'monthly':
if (dueMonths.length !== 12) {
throw new BadRequestException('Monthly frequency must include all 12 months');
}
break;
case 'quarterly':
if (dueMonths.length !== 4) {
throw new BadRequestException('Quarterly frequency must have exactly 4 due months');
}
break;
case 'annual':
if (dueMonths.length !== 1) {
throw new BadRequestException('Annual frequency must have exactly 1 due month');
}
break;
}
}
async create(dto: any) { async create(dto: any) {
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups'); const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0; const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
@@ -51,17 +84,23 @@ export class AssessmentGroupsService {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true'); await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
} }
const frequency = dto.frequency || 'monthly';
const dueMonths = dto.dueMonths || DEFAULT_DUE_MONTHS[frequency] || DEFAULT_DUE_MONTHS.monthly;
const dueDay = Math.min(Math.max(dto.dueDay || 1, 1), 28);
this.validateDueMonths(frequency, dueMonths);
const rows = await this.tenant.query( const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default) `INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, [dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault], dto.unitCount || 0, frequency, dueMonths, dueDay, shouldBeDefault],
); );
return rows[0]; return rows[0];
} }
async update(id: string, dto: any) { async update(id: string, dto: any) {
await this.findOne(id); const existing = await this.findOne(id);
if (dto.isDefault === true) { if (dto.isDefault === true) {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true'); await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
@@ -80,6 +119,24 @@ export class AssessmentGroupsService {
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); } if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); } if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
// Handle due_months: if frequency changed and no explicit dueMonths, auto-populate defaults
const effectiveFrequency = dto.frequency || existing.frequency;
if (dto.dueMonths !== undefined) {
this.validateDueMonths(effectiveFrequency, dto.dueMonths);
sets.push(`due_months = $${idx++}`);
params.push(dto.dueMonths);
} else if (dto.frequency !== undefined && dto.frequency !== existing.frequency) {
// Frequency changed, auto-populate due_months
const newDueMonths = DEFAULT_DUE_MONTHS[dto.frequency] || DEFAULT_DUE_MONTHS.monthly;
sets.push(`due_months = $${idx++}`);
params.push(newDueMonths);
}
if (dto.dueDay !== undefined) {
sets.push(`due_day = $${idx++}`);
params.push(Math.min(Math.max(dto.dueDay, 1), 28));
}
if (!sets.length) return this.findOne(id); if (!sets.length) return this.findOne(id);
sets.push('updated_at = NOW()'); sets.push('updated_at = NOW()');

View File

@@ -1,6 +1,7 @@
import { import {
Controller, Controller,
Post, Post,
Patch,
Body, Body,
UseGuards, UseGuards,
Request, Request,
@@ -8,11 +9,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { SwitchOrgDto } from './dto/switch-org.dto'; import { SwitchOrgDto } from './dto/switch-org.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -21,12 +24,14 @@ export class AuthController {
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register a new user' }) @ApiOperation({ summary: 'Register a new user' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto) { async register(@Body() dto: RegisterDto) {
return this.authService.register(dto); return this.authService.register(dto);
} }
@Post('login') @Post('login')
@ApiOperation({ summary: 'Login with email and password' }) @ApiOperation({ summary: 'Login with email and password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
@UseGuards(AuthGuard('local')) @UseGuards(AuthGuard('local'))
async login(@Request() req: any, @Body() _dto: LoginDto) { async login(@Request() req: any, @Body() _dto: LoginDto) {
const ip = req.headers['x-forwarded-for'] || req.ip; const ip = req.headers['x-forwarded-for'] || req.ip;
@@ -42,10 +47,21 @@ export class AuthController {
return this.authService.getProfile(req.user.sub); return this.authService.getProfile(req.user.sub);
} }
@Patch('intro-seen')
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async markIntroSeen(@Request() req: any) {
await this.authService.markIntroSeen(req.user.sub);
return { success: true };
}
@Post('switch-org') @Post('switch-org')
@ApiOperation({ summary: 'Switch active organization' }) @ApiOperation({ summary: 'Switch active organization' })
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@AllowViewer()
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) { async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
const ip = req.headers['x-forwarded-for'] || req.ip; const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent']; const ua = req.headers['user-agent'];

View File

@@ -118,7 +118,6 @@ export class AuthService {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
orgId: membership.organizationId, orgId: membership.organizationId,
orgSchema: membership.organization.schemaName,
role: membership.role, role: membership.role,
}; };
@@ -131,10 +130,15 @@ export class AuthService {
id: membership.organization.id, id: membership.organization.id,
name: membership.organization.name, name: membership.organization.name,
role: membership.role, role: membership.role,
settings: membership.organization.settings || {},
}, },
}; };
} }
async markIntroSeen(userId: string): Promise<void> {
await this.usersService.markIntroSeen(userId);
}
private async recordLoginHistory( private async recordLoginHistory(
userId: string, userId: string,
organizationId: string | null, organizationId: string | null,
@@ -172,7 +176,6 @@ export class AuthService {
if (defaultOrg) { if (defaultOrg) {
payload.orgId = defaultOrg.organizationId; payload.orgId = defaultOrg.organizationId;
payload.orgSchema = defaultOrg.organization?.schemaName;
payload.role = defaultOrg.role; payload.role = defaultOrg.role;
} }
@@ -185,11 +188,11 @@ export class AuthService {
lastName: user.lastName, lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false, isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false, isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
}, },
organizations: orgs.map((uo) => ({ organizations: orgs.map((uo) => ({
id: uo.organizationId, id: uo.organizationId,
name: uo.organization?.name, name: uo.organization?.name,
schemaName: uo.organization?.schemaName,
status: uo.organization?.status, status: uo.organization?.status,
role: uo.role, role: uo.role,
})), })),

View File

@@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
sub: payload.sub, sub: payload.sub,
email: payload.email, email: payload.email,
orgId: payload.orgId, orgId: payload.orgId,
orgSchema: payload.orgSchema,
role: payload.role, role: payload.role,
isSuperadmin: payload.isSuperadmin || false, isSuperadmin: payload.isSuperadmin || false,
impersonatedBy: payload.impersonatedBy || null, impersonatedBy: payload.impersonatedBy || null,

View File

@@ -0,0 +1,546 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
const round2 = (v: number) => Math.round(v * 100) / 100;
@Injectable()
export class BoardPlanningProjectionService {
constructor(private tenant: TenantService) {}
/** Return cached projection if fresh, otherwise compute. */
async getProjection(scenarioId: string) {
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
if (!rows.length) throw new NotFoundException('Scenario not found');
const scenario = rows[0];
// Return cache if it exists and is less than 1 hour old
if (scenario.projection_cache && scenario.projection_cached_at) {
const age = Date.now() - new Date(scenario.projection_cached_at).getTime();
if (age < 3600000) return scenario.projection_cache;
}
return this.computeProjection(scenarioId);
}
/** Compute full projection for a scenario. */
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];
const investments = await this.tenant.query(
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
);
const assessments = await this.tenant.query(
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY effective_date', [scenarioId],
);
const months = scenario.projection_months || 36;
const now = new Date();
const startYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
// ── 1. Baseline state (mirrors reports.service.ts getCashFlowForecast) ──
const baseline = await this.getBaselineState(startYear, months);
// ── 2. Build month-by-month projection ──
let { opCash, resCash, opInv, resInv } = baseline.openingBalances;
const datapoints: any[] = [];
let totalInterestEarned = 0;
const interestByInvestment: Record<string, number> = {};
for (let i = 0; i < months; i++) {
const year = startYear + Math.floor(i / 12);
const month = (i % 12) + 1;
const key = `${year}-${month}`;
const label = `${monthLabels[month - 1]} ${year}`;
const isHistorical = year < startYear || (year === startYear && month < currentMonth);
// Baseline income/expenses from budget
const budget = baseline.budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
const baseAssessment = this.getAssessmentIncome(baseline.assessmentGroups, month);
const existingMaturity = baseline.maturityIndex[key] || { operating: 0, reserve: 0 };
const project = baseline.projectIndex[key] || { operating: 0, reserve: 0 };
// Scenario investment deltas for this month
const invDelta = this.computeInvestmentDelta(investments, year, month);
totalInterestEarned += invDelta.interestEarned;
for (const [invId, amt] of Object.entries(invDelta.interestByInvestment)) {
interestByInvestment[invId] = (interestByInvestment[invId] || 0) + amt;
}
// Scenario assessment deltas for this month
const asmtDelta = this.computeAssessmentDelta(assessments, baseline.assessmentGroups, year, month);
if (isHistorical) {
// Historical months: use actual changes + scenario deltas
const opChange = baseline.histIndex[`${year}-${month}-operating`] || 0;
const resChange = baseline.histIndex[`${year}-${month}-reserve`] || 0;
opCash += opChange + invDelta.opCashFlow + asmtDelta.operating;
resCash += resChange + invDelta.resCashFlow + asmtDelta.reserve;
} else {
// Forecast months: budget + assessments + scenario deltas
const opIncomeMonth = (budget.opIncome > 0 ? budget.opIncome : baseAssessment.operating) + asmtDelta.operating;
const resIncomeMonth = (budget.resIncome > 0 ? budget.resIncome : baseAssessment.reserve) + asmtDelta.reserve;
opCash += opIncomeMonth - budget.opExpense - project.operating + existingMaturity.operating + invDelta.opCashFlow;
resCash += resIncomeMonth - budget.resExpense - project.reserve + existingMaturity.reserve + invDelta.resCashFlow;
// Existing maturities reduce investment balances
if (existingMaturity.operating > 0) {
opInv -= existingMaturity.operating * 0.96; // approximate principal
if (opInv < 0) opInv = 0;
}
if (existingMaturity.reserve > 0) {
resInv -= existingMaturity.reserve * 0.96;
if (resInv < 0) resInv = 0;
}
}
// Scenario investment balance changes
opInv += invDelta.opInvChange;
resInv += invDelta.resInvChange;
if (opInv < 0) opInv = 0;
if (resInv < 0) resInv = 0;
datapoints.push({
month: label,
year,
monthNum: month,
is_forecast: !isHistorical,
operating_cash: round2(opCash),
operating_investments: round2(opInv),
reserve_cash: round2(resCash),
reserve_investments: round2(resInv),
});
}
// ── 3. Summary metrics ──
const summary = this.computeSummary(datapoints, baseline, assessments, investments, totalInterestEarned, interestByInvestment);
const result = { datapoints, summary };
// ── 4. Cache ──
await this.tenant.query(
`UPDATE board_scenarios SET projection_cache = $1, projection_cached_at = NOW() WHERE id = $2`,
[JSON.stringify(result), scenarioId],
);
return result;
}
/** Compare multiple scenarios side-by-side. */
async compareScenarios(scenarioIds: string[]) {
if (!scenarioIds.length || scenarioIds.length > 4) {
throw new NotFoundException('Provide 1 to 4 scenario IDs');
}
const scenarios = await Promise.all(
scenarioIds.map(async (id) => {
const rows = await this.tenant.query('SELECT id, name, scenario_type, status FROM board_scenarios WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException(`Scenario ${id} not found`);
const projection = await this.getProjection(id);
return { ...rows[0], projection };
}),
);
return { scenarios };
}
// ── Private Helpers ──
private async getBaselineState(startYear: number, months: number) {
// Current balances from asset accounts
const opCashRows = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
GROUP BY a.id
) sub
`);
const resCashRows = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
GROUP BY a.id
) sub
`);
const opInvRows = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
`);
const resInvRows = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
// Opening balances at start of startYear
const openingOp = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
GROUP BY a.id
) sub
`, [`${startYear}-01-01`]);
const openingRes = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
GROUP BY a.id
) sub
`, [`${startYear}-01-01`]);
// Assessment groups
const assessmentGroups = await this.tenant.query(
`SELECT frequency, regular_assessment, special_assessment, unit_count FROM assessment_groups WHERE is_active = true`,
);
// Budgets (official + planned budget fallback)
const budgetsByYearMonth: Record<string, any> = {};
const endYear = startYear + Math.ceil(months / 12) + 1;
for (let yr = startYear; yr <= endYear; yr++) {
let budgetRows: any[];
try {
budgetRows = await this.tenant.query(
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
SELECT b.account_id, b.fund_type, a.account_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt,
1 as source_priority
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
UNION ALL
SELECT bpl.account_id, bpl.fund_type, a.account_type,
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt,
2 as source_priority
FROM budget_plan_lines bpl
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
JOIN accounts a ON a.id = bpl.account_id
WHERE bp.fiscal_year = $1
) combined
ORDER BY account_id, fund_type, source_priority`, [yr],
);
} catch {
// budget_plan_lines may not exist yet - fall back to official only
budgetRows = await this.tenant.query(
`SELECT b.fund_type, a.account_type, b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1`, [yr],
);
}
for (let m = 0; m < 12; m++) {
const key = `${yr}-${m + 1}`;
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
for (const row of budgetRows) {
const amt = parseFloat(row[monthNames[m]]) || 0;
if (amt === 0) continue;
const isOp = row.fund_type === 'operating';
if (row.account_type === 'income') {
if (isOp) budgetsByYearMonth[key].opIncome += amt;
else budgetsByYearMonth[key].resIncome += amt;
} else if (row.account_type === 'expense') {
if (isOp) budgetsByYearMonth[key].opExpense += amt;
else budgetsByYearMonth[key].resExpense += amt;
}
}
}
}
// Historical cash changes
const historicalCash = await this.tenant.query(`
SELECT EXTRACT(YEAR FROM je.entry_date)::int as yr, EXTRACT(MONTH FROM je.entry_date)::int as mo,
a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as net_change
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
JOIN accounts a ON a.id = jel.account_id AND a.account_type = 'asset' AND a.is_active = true
WHERE je.entry_date >= $1::date
GROUP BY yr, mo, a.fund_type ORDER BY yr, mo
`, [`${startYear}-01-01`]);
const histIndex: Record<string, number> = {};
for (const row of historicalCash) {
histIndex[`${row.yr}-${row.mo}-${row.fund_type}`] = parseFloat(row.net_change) || 0;
}
// Investment maturities
const maturities = await this.tenant.query(`
SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date
FROM investment_accounts WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
`);
const maturityIndex: Record<string, { operating: number; reserve: number }> = {};
for (const inv of maturities) {
const d = new Date(inv.maturity_date);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
const val = parseFloat(inv.current_value) || 0;
const rate = parseFloat(inv.interest_rate) || 0;
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
const matDate = new Date(inv.maturity_date);
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
const interestEarned = val * (rate / 100) * (daysHeld / 365);
const maturityTotal = val + interestEarned;
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
else maturityIndex[key].reserve += maturityTotal;
}
// Capital project expenses (from unified projects table)
const projectExpenses = await this.tenant.query(`
SELECT estimated_cost, target_year, target_month, fund_source
FROM projects WHERE is_active = true AND status IN ('planned', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
`);
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
for (const p of projectExpenses) {
const yr = parseInt(p.target_year);
const mo = parseInt(p.target_month) || 6;
const key = `${yr}-${mo}`;
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
const cost = parseFloat(p.estimated_cost) || 0;
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
else projectIndex[key].reserve += cost;
}
// Also include capital_projects table (Capital Planning page)
try {
const capitalProjectExpenses = await this.tenant.query(`
SELECT estimated_cost, target_year, target_month, fund_source
FROM capital_projects WHERE status IN ('planned', 'approved', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
`);
for (const p of capitalProjectExpenses) {
const yr = parseInt(p.target_year);
const mo = parseInt(p.target_month) || 6;
const key = `${yr}-${mo}`;
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
const cost = parseFloat(p.estimated_cost) || 0;
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
else projectIndex[key].reserve += cost;
}
} catch {
// capital_projects table may not exist in all tenants
}
return {
openingBalances: {
opCash: parseFloat(openingOp[0]?.total || '0'),
resCash: parseFloat(openingRes[0]?.total || '0'),
opInv: parseFloat(opInvRows[0]?.total || '0'),
resInv: parseFloat(resInvRows[0]?.total || '0'),
},
assessmentGroups,
budgetsByYearMonth,
histIndex,
maturityIndex,
projectIndex,
};
}
private getAssessmentIncome(assessmentGroups: any[], month: number) {
let operating = 0;
let reserve = 0;
for (const g of assessmentGroups) {
const units = parseInt(g.unit_count) || 0;
const regular = parseFloat(g.regular_assessment) || 0;
const special = parseFloat(g.special_assessment) || 0;
const freq = g.frequency || 'monthly';
let applies = false;
if (freq === 'monthly') applies = true;
else if (freq === 'quarterly') applies = [1, 4, 7, 10].includes(month);
else if (freq === 'annual') applies = month === 1;
if (applies) {
operating += regular * units;
reserve += special * units;
}
}
return { operating, reserve };
}
/** Compute investment cash flow and balance deltas for a given month from scenario investments. */
private computeInvestmentDelta(investments: any[], year: number, month: number) {
let opCashFlow = 0;
let resCashFlow = 0;
let opInvChange = 0;
let resInvChange = 0;
let interestEarned = 0;
const interestByInvestment: Record<string, number> = {};
for (const inv of investments) {
if (inv.executed_investment_id) continue; // skip already-executed investments
const principal = parseFloat(inv.principal) || 0;
const rate = parseFloat(inv.interest_rate) || 0;
const isOp = inv.fund_type === 'operating';
// Purchase: cash leaves, investment balance increases
if (inv.purchase_date) {
const pd = new Date(inv.purchase_date);
if (pd.getFullYear() === year && pd.getMonth() + 1 === month) {
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
else { resCashFlow -= principal; resInvChange += principal; }
}
}
// Maturity: investment returns to cash with interest
if (inv.maturity_date) {
const md = new Date(inv.maturity_date);
if (md.getFullYear() === year && md.getMonth() + 1 === month) {
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
const daysHeld = Math.max((md.getTime() - purchaseDate.getTime()) / 86400000, 1);
const invInterest = principal * (rate / 100) * (daysHeld / 365);
const maturityTotal = principal + invInterest;
interestEarned += invInterest;
interestByInvestment[inv.id] = (interestByInvestment[inv.id] || 0) + invInterest;
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; }
}
}
}
}
return { opCashFlow, resCashFlow, opInvChange, resInvChange, interestEarned, interestByInvestment };
}
/** Compute assessment income delta for a given month from scenario assessment changes. */
private computeAssessmentDelta(scenarioAssessments: any[], assessmentGroups: any[], year: number, month: number) {
let operating = 0;
let reserve = 0;
const monthDate = new Date(year, month - 1, 1);
// Get total units across all assessment groups
let totalUnits = 0;
for (const g of assessmentGroups) {
totalUnits += parseInt(g.unit_count) || 0;
}
for (const a of scenarioAssessments) {
const effectiveDate = new Date(a.effective_date);
const endDate = a.end_date ? new Date(a.end_date) : null;
// Only apply if within the active window
if (monthDate < effectiveDate) continue;
if (endDate && monthDate > endDate) continue;
if (a.change_type === 'dues_increase' || a.change_type === 'dues_decrease') {
const baseIncome = this.getAssessmentIncome(assessmentGroups, month);
const pctChange = parseFloat(a.percentage_change) || 0;
const flatChange = parseFloat(a.flat_amount_change) || 0;
const sign = a.change_type === 'dues_decrease' ? -1 : 1;
let delta = 0;
if (pctChange > 0) {
// Percentage change of base assessment income
const target = a.target_fund || 'operating';
if (target === 'operating' || target === 'both') {
delta = baseIncome.operating * (pctChange / 100) * sign;
operating += delta;
}
if (target === 'reserve' || target === 'both') {
delta = baseIncome.reserve * (pctChange / 100) * sign;
reserve += delta;
}
} else if (flatChange > 0) {
// Flat per-unit change times total units
const target = a.target_fund || 'operating';
if (target === 'operating' || target === 'both') {
operating += flatChange * totalUnits * sign;
}
if (target === 'reserve' || target === 'both') {
reserve += flatChange * totalUnits * sign;
}
}
} else if (a.change_type === 'special_assessment') {
// Special assessment distributed across installments
const perUnit = parseFloat(a.special_per_unit) || 0;
const installments = parseInt(a.special_installments) || 1;
const monthsFromStart = (year - effectiveDate.getFullYear()) * 12 + (month - (effectiveDate.getMonth() + 1));
if (monthsFromStart >= 0 && monthsFromStart < installments) {
const monthlyIncome = (perUnit * totalUnits) / installments;
const target = a.target_fund || 'reserve';
if (target === 'operating' || target === 'both') operating += monthlyIncome;
if (target === 'reserve' || target === 'both') reserve += monthlyIncome;
}
}
}
return { operating, reserve };
}
private computeSummary(
datapoints: any[], baseline: any, scenarioAssessments: any[],
investments?: any[], totalInterestEarned = 0, interestByInvestment: Record<string, number> = {},
) {
if (!datapoints.length) return {};
const last = datapoints[datapoints.length - 1];
const first = datapoints[0];
const allLiquidity = datapoints.map(
(d) => d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
);
const minLiquidity = Math.min(...allLiquidity);
const endLiquidity = allLiquidity[allLiquidity.length - 1];
// Reserve coverage: reserve balance / avg monthly reserve expenditure from planned capital projects
let totalReserveProjectCost = 0;
const projectionYears = Math.max(1, Math.ceil(datapoints.length / 12));
for (const key of Object.keys(baseline.projectIndex)) {
totalReserveProjectCost += baseline.projectIndex[key].reserve || 0;
}
const avgMonthlyReserveExpenditure = totalReserveProjectCost > 0
? totalReserveProjectCost / (projectionYears * 12)
: 0;
const reserveCoverageMonths = avgMonthlyReserveExpenditure > 0
? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure
: 0; // No planned projects = show 0 (N/A)
// Calculate total principal from scenario investments
let totalPrincipal = 0;
const investmentInterestDetails: Array<{ id: string; label: string; principal: number; interest: number }> = [];
if (investments) {
for (const inv of investments) {
if (inv.executed_investment_id) continue;
const principal = parseFloat(inv.principal) || 0;
totalPrincipal += principal;
const interest = interestByInvestment[inv.id] || 0;
investmentInterestDetails.push({
id: inv.id,
label: inv.label,
principal: round2(principal),
interest: round2(interest),
});
}
}
return {
end_liquidity: round2(endLiquidity),
min_liquidity: round2(minLiquidity),
reserve_coverage_months: round2(reserveCoverageMonths),
end_operating_cash: last.operating_cash,
end_reserve_cash: last.reserve_cash,
end_operating_investments: last.operating_investments,
end_reserve_investments: last.reserve_investments,
period_change: round2(endLiquidity - allLiquidity[0]),
total_interest_earned: round2(totalInterestEarned),
total_principal_invested: round2(totalPrincipal),
roi_percentage: totalPrincipal > 0 ? round2((totalInterestEarned / totalPrincipal) * 100) : 0,
investment_interest_details: investmentInterestDetails,
};
}
}

View File

@@ -0,0 +1,200 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { BoardPlanningService } from './board-planning.service';
import { BoardPlanningProjectionService } from './board-planning-projection.service';
import { BudgetPlanningService } from './budget-planning.service';
@ApiTags('board-planning')
@Controller('board-planning')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class BoardPlanningController {
constructor(
private service: BoardPlanningService,
private projection: BoardPlanningProjectionService,
private budgetPlanning: BudgetPlanningService,
) {}
// ── Scenarios ──
@Get('scenarios')
@AllowViewer()
listScenarios(@Query('type') type?: string) {
return this.service.listScenarios(type);
}
@Get('scenarios/:id')
@AllowViewer()
getScenario(@Param('id') id: string) {
return this.service.getScenario(id);
}
@Post('scenarios')
createScenario(@Body() dto: any, @Req() req: any) {
return this.service.createScenario(dto, req.user.sub);
}
@Put('scenarios/:id')
updateScenario(@Param('id') id: string, @Body() dto: any) {
return this.service.updateScenario(id, dto);
}
@Delete('scenarios/:id')
deleteScenario(@Param('id') id: string) {
return this.service.deleteScenario(id);
}
// ── Scenario Investments ──
@Get('scenarios/:scenarioId/investments')
@AllowViewer()
listInvestments(@Param('scenarioId') scenarioId: string) {
return this.service.listInvestments(scenarioId);
}
@Post('scenarios/:scenarioId/investments')
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestment(scenarioId, dto);
}
@Post('scenarios/:scenarioId/investments/from-recommendation')
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
}
@Put('investments/:id')
updateInvestment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateInvestment(id, dto);
}
@Delete('investments/:id')
removeInvestment(@Param('id') id: string) {
return this.service.removeInvestment(id);
}
// ── Scenario Assessments ──
@Get('scenarios/:scenarioId/assessments')
@AllowViewer()
listAssessments(@Param('scenarioId') scenarioId: string) {
return this.service.listAssessments(scenarioId);
}
@Post('scenarios/:scenarioId/assessments')
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addAssessment(scenarioId, dto);
}
@Put('assessments/:id')
updateAssessment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateAssessment(id, dto);
}
@Delete('assessments/:id')
removeAssessment(@Param('id') id: string) {
return this.service.removeAssessment(id);
}
// ── Projections ──
@Get('scenarios/:id/projection')
@AllowViewer()
getProjection(@Param('id') id: string) {
return this.projection.getProjection(id);
}
@Post('scenarios/:id/projection/refresh')
refreshProjection(@Param('id') id: string) {
return this.projection.computeProjection(id);
}
// ── Comparison ──
@Get('compare')
@AllowViewer()
compareScenarios(@Query('ids') ids: string) {
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
return this.projection.compareScenarios(scenarioIds);
}
// ── Execute Investment ──
@Post('investments/:id/execute')
executeInvestment(
@Param('id') id: string,
@Body() dto: { executionDate: string },
@Req() req: any,
) {
return this.service.executeInvestment(id, dto.executionDate, req.user.sub);
}
// ── Budget Planning ──
@Get('budget-plans')
@AllowViewer()
listBudgetPlans() {
return this.budgetPlanning.listPlans();
}
@Get('budget-plans/available-years')
@AllowViewer()
getAvailableYears() {
return this.budgetPlanning.getAvailableYears();
}
@Get('budget-plans/:year')
@AllowViewer()
getBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.getPlan(parseInt(year, 10));
}
@Post('budget-plans')
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
}
@Put('budget-plans/:year/lines')
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
}
@Put('budget-plans/:year/inflation')
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
}
@Put('budget-plans/:year/status')
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
}
@Post('budget-plans/:year/import')
importBudgetPlanLines(
@Param('year') year: string,
@Body() lines: any[],
@Req() req: any,
) {
return this.budgetPlanning.importLines(parseInt(year, 10), lines, req.user.sub);
}
@Get('budget-plans/:year/template')
async getBudgetPlanTemplate(
@Param('year') year: string,
@Res() res: Response,
) {
const csv = await this.budgetPlanning.getTemplate(parseInt(year, 10));
res.set({
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="budget_template_${year}.csv"`,
});
res.send(csv);
}
@Delete('budget-plans/:year')
deleteBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.deletePlan(parseInt(year, 10));
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { BoardPlanningController } from './board-planning.controller';
import { BoardPlanningService } from './board-planning.service';
import { BoardPlanningProjectionService } from './board-planning-projection.service';
import { BudgetPlanningService } from './budget-planning.service';
@Module({
controllers: [BoardPlanningController],
providers: [BoardPlanningService, BoardPlanningProjectionService, BudgetPlanningService],
exports: [BoardPlanningService, BudgetPlanningService],
})
export class BoardPlanningModule {}

View File

@@ -0,0 +1,383 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class BoardPlanningService {
constructor(private tenant: TenantService) {}
// ── Scenarios ──
async listScenarios(type?: string) {
let sql = `
SELECT bs.*,
(SELECT COUNT(*) FROM scenario_investments si WHERE si.scenario_id = bs.id) as investment_count,
(SELECT COALESCE(SUM(si.principal), 0) FROM scenario_investments si WHERE si.scenario_id = bs.id) as total_principal,
(SELECT COUNT(*) FROM scenario_assessments sa WHERE sa.scenario_id = bs.id) as assessment_count
FROM board_scenarios bs
WHERE bs.status != 'archived'
`;
const params: any[] = [];
if (type) {
params.push(type);
sql += ` AND bs.scenario_type = $${params.length}`;
}
sql += ' ORDER BY bs.updated_at DESC';
return this.tenant.query(sql, params);
}
async getScenario(id: string) {
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Scenario not found');
const scenario = rows[0];
const investments = await this.tenant.query(
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
[id],
);
const assessments = await this.tenant.query(
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
[id],
);
return { ...scenario, investments, assessments };
}
async createScenario(dto: any, userId: string) {
const rows = await this.tenant.query(
`INSERT INTO board_scenarios (name, description, scenario_type, projection_months, created_by)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[dto.name, dto.description || null, dto.scenarioType, dto.projectionMonths || 36, userId],
);
return rows[0];
}
async updateScenario(id: string, dto: any) {
await this.getScenarioRow(id);
const rows = await this.tenant.query(
`UPDATE board_scenarios SET
name = COALESCE($2, name),
description = COALESCE($3, description),
status = COALESCE($4, status),
projection_months = COALESCE($5, projection_months),
updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.name, dto.description, dto.status, dto.projectionMonths],
);
return rows[0];
}
async deleteScenario(id: string) {
await this.getScenarioRow(id);
await this.tenant.query(
`UPDATE board_scenarios SET status = 'archived', updated_at = NOW() WHERE id = $1`,
[id],
);
}
// ── Scenario Investments ──
async listInvestments(scenarioId: string) {
return this.tenant.query(
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
[scenarioId],
);
}
async addInvestment(scenarioId: string, dto: any) {
await this.getScenarioRow(scenarioId);
const rows = await this.tenant.query(
`INSERT INTO scenario_investments
(scenario_id, source_recommendation_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, $11, $12, $13, $14)
RETURNING *`,
[
scenarioId, dto.sourceRecommendationId || null, dto.label,
dto.investmentType || null, dto.fundType,
dto.principal, dto.interestRate || null, dto.termMonths || null,
dto.institution || null, dto.purchaseDate || null, dto.maturityDate || null,
dto.autoRenew || false, dto.notes || null, dto.sortOrder || 0,
],
);
await this.invalidateProjectionCache(scenarioId);
return rows[0];
}
async addInvestmentFromRecommendation(scenarioId: string, dto: any) {
await this.getScenarioRow(scenarioId);
// Helper: compute maturity date from purchase date + term months
const computeMaturityDate = (purchaseDate: string | null, termMonths: number | null): string | null => {
if (!purchaseDate || !termMonths) return null;
const d = new Date(purchaseDate);
d.setMonth(d.getMonth() + termMonths);
return d.toISOString().split('T')[0];
};
const startDate = dto.startDate || null; // ISO date string e.g. "2026-03-16"
// If the recommendation has components (e.g. CD ladder with multiple CDs), create one row per component
const components = dto.components as any[] | undefined;
if (components && Array.isArray(components) && components.length > 0) {
const results: any[] = [];
for (let i = 0; i < components.length; i++) {
const comp = components[i];
const termMonths = comp.term_months || null;
const maturityDate = computeMaturityDate(startDate, termMonths);
const rows = await this.tenant.query(
`INSERT INTO scenario_investments
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
principal, interest_rate, term_months, institution, purchase_date, maturity_date,
notes, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
scenarioId, dto.sourceRecommendationId || null,
comp.label || `${dto.title || 'AI Recommendation'} - Part ${i + 1}`,
comp.investment_type || dto.investmentType || null,
dto.fundType || 'reserve',
comp.amount || 0, comp.rate || null,
termMonths, comp.bank_name || dto.bankName || null,
startDate, maturityDate,
dto.rationale || dto.notes || null,
i,
],
);
results.push(rows[0]);
}
await this.invalidateProjectionCache(scenarioId);
return results;
}
// Single investment (no components)
const termMonths = dto.termMonths || null;
const maturityDate = computeMaturityDate(startDate, termMonths);
const rows = await this.tenant.query(
`INSERT INTO scenario_investments
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
principal, interest_rate, term_months, institution, purchase_date, maturity_date, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`,
[
scenarioId, dto.sourceRecommendationId || null,
dto.title || dto.label || 'AI Recommendation',
dto.investmentType || null, dto.fundType || 'reserve',
dto.suggestedAmount || 0, dto.suggestedRate || null,
termMonths, dto.bankName || null,
startDate, maturityDate,
dto.rationale || dto.notes || null,
],
);
await this.invalidateProjectionCache(scenarioId);
return rows[0];
}
async updateInvestment(id: string, dto: any) {
const inv = await this.getInvestmentRow(id);
const rows = await this.tenant.query(
`UPDATE scenario_investments SET
label = COALESCE($2, label),
investment_type = COALESCE($3, investment_type),
fund_type = COALESCE($4, fund_type),
principal = COALESCE($5, principal),
interest_rate = COALESCE($6, interest_rate),
term_months = COALESCE($7, term_months),
institution = COALESCE($8, institution),
purchase_date = COALESCE($9, purchase_date),
maturity_date = COALESCE($10, maturity_date),
auto_renew = COALESCE($11, auto_renew),
notes = COALESCE($12, notes),
sort_order = COALESCE($13, sort_order),
updated_at = NOW()
WHERE id = $1 RETURNING *`,
[
id, dto.label, dto.investmentType, dto.fundType,
dto.principal, dto.interestRate, dto.termMonths,
dto.institution, dto.purchaseDate, dto.maturityDate,
dto.autoRenew, dto.notes, dto.sortOrder,
],
);
await this.invalidateProjectionCache(inv.scenario_id);
return rows[0];
}
async removeInvestment(id: string) {
const inv = await this.getInvestmentRow(id);
await this.tenant.query('DELETE FROM scenario_investments WHERE id = $1', [id]);
await this.invalidateProjectionCache(inv.scenario_id);
}
// ── Scenario Assessments ──
async listAssessments(scenarioId: string) {
return this.tenant.query(
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
[scenarioId],
);
}
async addAssessment(scenarioId: string, dto: any) {
await this.getScenarioRow(scenarioId);
const rows = await this.tenant.query(
`INSERT INTO scenario_assessments
(scenario_id, change_type, label, target_fund, percentage_change,
flat_amount_change, special_total, special_per_unit, special_installments,
effective_date, end_date, applies_to_group_id, notes, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *`,
[
scenarioId, dto.changeType, dto.label, dto.targetFund || 'operating',
dto.percentageChange || null, dto.flatAmountChange || null,
dto.specialTotal || null, dto.specialPerUnit || null,
dto.specialInstallments || 1, dto.effectiveDate,
dto.endDate || null, dto.appliesToGroupId || null,
dto.notes || null, dto.sortOrder || 0,
],
);
await this.invalidateProjectionCache(scenarioId);
return rows[0];
}
async updateAssessment(id: string, dto: any) {
const asmt = await this.getAssessmentRow(id);
const rows = await this.tenant.query(
`UPDATE scenario_assessments SET
change_type = COALESCE($2, change_type),
label = COALESCE($3, label),
target_fund = COALESCE($4, target_fund),
percentage_change = COALESCE($5, percentage_change),
flat_amount_change = COALESCE($6, flat_amount_change),
special_total = COALESCE($7, special_total),
special_per_unit = COALESCE($8, special_per_unit),
special_installments = COALESCE($9, special_installments),
effective_date = COALESCE($10, effective_date),
end_date = COALESCE($11, end_date),
applies_to_group_id = COALESCE($12, applies_to_group_id),
notes = COALESCE($13, notes),
sort_order = COALESCE($14, sort_order),
updated_at = NOW()
WHERE id = $1 RETURNING *`,
[
id, dto.changeType, dto.label, dto.targetFund,
dto.percentageChange, dto.flatAmountChange,
dto.specialTotal, dto.specialPerUnit, dto.specialInstallments,
dto.effectiveDate, dto.endDate, dto.appliesToGroupId,
dto.notes, dto.sortOrder,
],
);
await this.invalidateProjectionCache(asmt.scenario_id);
return rows[0];
}
async removeAssessment(id: string) {
const asmt = await this.getAssessmentRow(id);
await this.tenant.query('DELETE FROM scenario_assessments WHERE id = $1', [id]);
await this.invalidateProjectionCache(asmt.scenario_id);
}
// ── Execute Investment (Story 1D) ──
async executeInvestment(investmentId: string, executionDate: string, userId: string) {
const inv = await this.getInvestmentRow(investmentId);
if (inv.executed_investment_id) {
throw new BadRequestException('This investment has already been executed');
}
// 1. Create real investment_accounts record
const invRows = await this.tenant.query(
`INSERT INTO investment_accounts
(name, institution, investment_type, fund_type, principal, interest_rate,
maturity_date, purchase_date, current_value, notes, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
RETURNING *`,
[
inv.label, inv.institution, inv.investment_type || 'cd',
inv.fund_type, inv.principal, inv.interest_rate || 0,
inv.maturity_date, executionDate, inv.principal,
`Executed from scenario investment. ${inv.notes || ''}`.trim(),
],
);
const realInvestment = invRows[0];
// 2. Create journal entry at the execution date
const entryDate = new Date(executionDate);
const year = entryDate.getFullYear();
const month = entryDate.getMonth() + 1;
const periods = await this.tenant.query(
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
[year, month],
);
if (periods.length) {
const primaryRows = await this.tenant.query(
`SELECT id, name FROM accounts WHERE is_primary = true AND fund_type = $1 AND is_active = true LIMIT 1`,
[inv.fund_type],
);
const equityAccountNumber = inv.fund_type === 'reserve' ? '3100' : '3000';
const equityRows = await this.tenant.query(
'SELECT id FROM accounts WHERE account_number = $1',
[equityAccountNumber],
);
if (primaryRows.length && equityRows.length) {
const memo = `Transfer to investment: ${inv.label}`;
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 *`,
[executionDate, memo, periods[0].id, userId],
);
const je = jeRows[0];
// Credit primary asset account (reduces cash)
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, primaryRows[0].id, inv.principal, memo],
);
// Debit equity offset account
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, equityRows[0].id, inv.principal, memo],
);
}
}
// 3. Link back to scenario investment
await this.tenant.query(
`UPDATE scenario_investments SET executed_investment_id = $1, updated_at = NOW() WHERE id = $2`,
[realInvestment.id, investmentId],
);
await this.invalidateProjectionCache(inv.scenario_id);
return realInvestment;
}
// ── Helpers ──
private async getScenarioRow(id: string) {
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Scenario not found');
return rows[0];
}
private async getInvestmentRow(id: string) {
const rows = await this.tenant.query('SELECT * FROM scenario_investments WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Scenario investment not found');
return rows[0];
}
private async getAssessmentRow(id: string) {
const rows = await this.tenant.query('SELECT * FROM scenario_assessments WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Scenario assessment not found');
return rows[0];
}
async invalidateProjectionCache(scenarioId: string) {
await this.tenant.query(
`UPDATE board_scenarios SET projection_cache = NULL, projection_cached_at = NULL, updated_at = NOW() WHERE id = $1`,
[scenarioId],
);
}
}

View File

@@ -0,0 +1,407 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
const monthCols = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
@Injectable()
export class BudgetPlanningService {
constructor(private tenant: TenantService) {}
// ── Plans CRUD ──
async listPlans() {
return this.tenant.query(
`SELECT bp.*,
(SELECT COUNT(*) FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = bp.id) as line_count
FROM budget_plans bp ORDER BY bp.fiscal_year`,
);
}
async getPlan(fiscalYear: number) {
const plans = await this.tenant.query(
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
);
if (!plans.length) return null;
const plan = plans[0];
const lines = await this.tenant.query(
`SELECT bpl.*, a.account_number, a.name as account_name, a.account_type, a.fund_type as account_fund_type
FROM budget_plan_lines bpl
JOIN accounts a ON a.id = bpl.account_id
WHERE bpl.budget_plan_id = $1
ORDER BY a.account_number`,
[plan.id],
);
return { ...plan, lines };
}
async getAvailableYears() {
// Find the latest year that has official budgets
const result = await this.tenant.query(
'SELECT MAX(fiscal_year) as max_year FROM budgets',
);
const rawMaxYear = result[0]?.max_year;
const latestBudgetYear = rawMaxYear || null; // null means no budgets exist at all
const baseYear = rawMaxYear || new Date().getFullYear();
// Also find years that already have plans
const existingPlans = await this.tenant.query(
'SELECT fiscal_year, status FROM budget_plans ORDER BY fiscal_year',
);
const planYears = existingPlans.map((p: any) => ({
year: p.fiscal_year,
status: p.status,
}));
// Return next 5 years (or current year + 4 if no budgets exist)
const years = [];
const startOffset = rawMaxYear ? 1 : 0; // include current year if no budgets exist
for (let i = startOffset; i <= startOffset + 4; i++) {
const yr = baseYear + i;
const existing = planYears.find((p: any) => p.year === yr);
years.push({
year: yr,
hasPlan: !!existing,
status: existing?.status || null,
});
}
return { latestBudgetYear, years, existingPlans: planYears };
}
async createPlan(fiscalYear: number, baseYear: number, inflationRate: number, userId: string) {
// Check no existing plan for this year
const existing = await this.tenant.query(
'SELECT id FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
);
if (existing.length) {
throw new BadRequestException(`A budget plan already exists for ${fiscalYear}`);
}
// Create the plan
const rows = await this.tenant.query(
`INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by)
VALUES ($1, $2, $3, $4) RETURNING *`,
[fiscalYear, baseYear, inflationRate, userId],
);
const plan = rows[0];
// Generate inflated lines from base year
await this.generateLines(plan.id, baseYear, inflationRate, fiscalYear);
return this.getPlan(fiscalYear);
}
async generateLines(planId: string, baseYear: number, inflationRate: number, fiscalYear: number) {
// Delete existing non-manually-adjusted lines (or all if fresh)
await this.tenant.query(
'DELETE FROM budget_plan_lines WHERE budget_plan_id = $1 AND is_manually_adjusted = false',
[planId],
);
// Try official budgets first, then fall back to budget_plan_lines for base year
let baseLines = await this.tenant.query(
`SELECT b.account_id, b.fund_type, ${monthCols.join(', ')}
FROM budgets b WHERE b.fiscal_year = $1`,
[baseYear],
);
if (!baseLines.length) {
// Fall back to budget_plan_lines for base year (for chained plans)
baseLines = await this.tenant.query(
`SELECT bpl.account_id, bpl.fund_type, ${monthCols.join(', ')}
FROM budget_plan_lines bpl
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
WHERE bp.fiscal_year = $1`,
[baseYear],
);
}
if (!baseLines.length) return;
// Compound inflation: (1 + rate/100)^yearsGap
const yearsGap = Math.max(1, fiscalYear - baseYear);
const multiplier = Math.pow(1 + inflationRate / 100, yearsGap);
// Get existing manually-adjusted lines to avoid duplicates
const manualLines = await this.tenant.query(
`SELECT account_id, fund_type FROM budget_plan_lines
WHERE budget_plan_id = $1 AND is_manually_adjusted = true`,
[planId],
);
const manualKeys = new Set(manualLines.map((l: any) => `${l.account_id}-${l.fund_type}`));
for (const line of baseLines) {
const key = `${line.account_id}-${line.fund_type}`;
if (manualKeys.has(key)) continue; // Don't overwrite manual edits
const inflated = monthCols.map((m) => {
const val = parseFloat(line[m]) || 0;
return Math.round(val * multiplier * 100) / 100;
});
await this.tenant.query(
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (budget_plan_id, account_id, fund_type)
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
is_manually_adjusted=false`,
[planId, line.account_id, line.fund_type, ...inflated],
);
}
}
async updateLines(planId: string, lines: any[]) {
for (const line of lines) {
const monthValues = monthCols.map((m) => {
const key = m === 'dec_amt' ? 'dec' : m;
return line[key] ?? line[m] ?? 0;
});
await this.tenant.query(
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true)
ON CONFLICT (budget_plan_id, account_id, fund_type)
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
is_manually_adjusted=true`,
[planId, line.accountId, line.fundType, ...monthValues],
);
}
return { updated: lines.length };
}
async updateInflation(fiscalYear: number, inflationRate: number) {
const plans = await this.tenant.query(
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
);
if (!plans.length) throw new NotFoundException('Budget plan not found');
const plan = plans[0];
if (plan.status === 'ratified') {
throw new BadRequestException('Cannot modify inflation on a ratified budget');
}
await this.tenant.query(
'UPDATE budget_plans SET inflation_rate = $1, updated_at = NOW() WHERE fiscal_year = $2',
[inflationRate, fiscalYear],
);
// Re-generate only non-manually-adjusted lines
await this.generateLines(plan.id, plan.base_year, inflationRate, fiscalYear);
return this.getPlan(fiscalYear);
}
async advanceStatus(fiscalYear: number, newStatus: string, userId: string) {
const plans = await this.tenant.query(
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
);
if (!plans.length) throw new NotFoundException('Budget plan not found');
const plan = plans[0];
const validTransitions: Record<string, string[]> = {
planning: ['approved'],
approved: ['planning', 'ratified'],
ratified: ['approved'],
};
if (!validTransitions[plan.status]?.includes(newStatus)) {
throw new BadRequestException(`Cannot transition from ${plan.status} to ${newStatus}`);
}
// If reverting from ratified, remove official budget
if (plan.status === 'ratified' && newStatus === 'approved') {
await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]);
}
const updates: string[] = ['status = $1', 'updated_at = NOW()'];
const params: any[] = [newStatus];
if (newStatus === 'approved') {
updates.push(`approved_by = $${params.length + 1}`, `approved_at = NOW()`);
params.push(userId);
} else if (newStatus === 'ratified') {
updates.push(`ratified_by = $${params.length + 1}`, `ratified_at = NOW()`);
params.push(userId);
}
params.push(fiscalYear);
await this.tenant.query(
`UPDATE budget_plans SET ${updates.join(', ')} WHERE fiscal_year = $${params.length}`,
params,
);
// If ratifying, copy to official budgets
if (newStatus === 'ratified') {
await this.ratifyToOfficial(plan.id, fiscalYear);
}
return this.getPlan(fiscalYear);
}
private async ratifyToOfficial(planId: string, fiscalYear: number) {
// Clear existing official budgets for this year
await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]);
// Copy plan lines to official budgets
await this.tenant.query(
`INSERT INTO budgets (fiscal_year, account_id, fund_type,
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes)
SELECT $1, bpl.account_id, bpl.fund_type,
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun,
bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt, bpl.notes
FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = $2`,
[fiscalYear, planId],
);
}
async importLines(fiscalYear: number, lines: any[], userId: string) {
// Ensure plan exists (create if needed)
let plans = await this.tenant.query(
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
);
if (!plans.length) {
await this.tenant.query(
`INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by)
VALUES ($1, $1, 0, $2) RETURNING *`,
[fiscalYear, userId],
);
plans = await this.tenant.query(
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
);
}
const plan = plans[0];
const errors: string[] = [];
const created: string[] = [];
let imported = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const accountNumber = String(line.accountNumber || line.account_number || '').trim();
const accountName = String(line.accountName || line.account_name || '').trim();
if (!accountNumber) {
errors.push(`Row ${i + 1}: missing account_number`);
continue;
}
let accounts = await this.tenant.query(
`SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`,
[accountNumber],
);
// Auto-create account if not found
if ((!accounts || accounts.length === 0) && accountName) {
const accountType = this.inferAccountType(accountNumber, accountName);
const fundType = this.inferFundType(accountNumber, accountName);
await this.tenant.query(
`INSERT INTO accounts (account_number, name, account_type, fund_type, is_system)
VALUES ($1, $2, $3, $4, false)`,
[accountNumber, accountName, accountType, fundType],
);
accounts = await this.tenant.query(
`SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`,
[accountNumber],
);
created.push(`${accountNumber} - ${accountName} (${accountType}/${fundType})`);
}
if (!accounts || accounts.length === 0) {
errors.push(`Row ${i + 1}: account "${accountNumber}" not found`);
continue;
}
const account = accounts[0];
const fundType = line.fund_type || account.fund_type || 'operating';
const monthValues = monthCols.map((m) => {
const key = m === 'dec_amt' ? 'dec' : m;
return this.parseCurrency(line[key] ?? line[m] ?? 0);
});
await this.tenant.query(
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true)
ON CONFLICT (budget_plan_id, account_id, fund_type)
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
is_manually_adjusted=true`,
[plan.id, account.id, fundType, ...monthValues],
);
imported++;
}
return { imported, errors, created, plan: await this.getPlan(fiscalYear) };
}
async getTemplate(fiscalYear: number): Promise<string> {
const rows = await this.tenant.query(
`SELECT a.account_number, a.name as account_name,
COALESCE(b.jan, 0) as jan, COALESCE(b.feb, 0) as feb,
COALESCE(b.mar, 0) as mar, COALESCE(b.apr, 0) as apr,
COALESCE(b.may, 0) as may, COALESCE(b.jun, 0) as jun,
COALESCE(b.jul, 0) as jul, COALESCE(b.aug, 0) as aug,
COALESCE(b.sep, 0) as sep, COALESCE(b.oct, 0) as oct,
COALESCE(b.nov, 0) as nov, COALESCE(b.dec_amt, 0) as dec
FROM accounts a
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
WHERE a.is_active = true
AND a.account_type IN ('income', 'expense')
ORDER BY a.account_number`,
[fiscalYear],
);
const header = 'account_number,account_name,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec';
const csvLines = rows.map((r: any) => {
const name = String(r.account_name).includes(',') ? `"${r.account_name}"` : r.account_name;
return [r.account_number, name, r.jan, r.feb, r.mar, r.apr, r.may, r.jun, r.jul, r.aug, r.sep, r.oct, r.nov, r.dec].join(',');
});
return [header, ...csvLines].join('\n');
}
private parseCurrency(val: string | number | undefined | null): number {
if (val === undefined || val === null) return 0;
if (typeof val === 'number') return val;
let s = String(val).trim();
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
const isNegative = s.includes('(') && s.includes(')');
s = s.replace(/[$,\s()]/g, '');
if (!s || s === '-') return 0;
const num = parseFloat(s);
if (isNaN(num)) return 0;
return isNegative ? -num : num;
}
private inferAccountType(accountNumber: string, accountName: string): string {
const prefix = parseInt(accountNumber.split('-')[0].trim(), 10);
if (isNaN(prefix)) return 'expense';
const nameUpper = (accountName || '').toUpperCase();
if (prefix >= 3000 && prefix < 4000) return 'income';
if (nameUpper.includes('INCOME') || nameUpper.includes('REVENUE') || nameUpper.includes('ASSESSMENT')) return 'income';
return 'expense';
}
private inferFundType(accountNumber: string, accountName: string): string {
const prefix = parseInt(accountNumber.split('-')[0].trim(), 10);
const nameUpper = (accountName || '').toUpperCase();
if (nameUpper.includes('RESERVE')) return 'reserve';
if (prefix >= 7000 && prefix < 8000) return 'reserve';
return 'operating';
}
async deletePlan(fiscalYear: number) {
const plans = await this.tenant.query(
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
);
if (!plans.length) throw new NotFoundException('Budget plan not found');
if (plans[0].status !== 'planning') {
throw new BadRequestException('Can only delete plans in planning status');
}
await this.tenant.query('DELETE FROM budget_plans WHERE fiscal_year = $1', [fiscalYear]);
return { deleted: true };
}
}

View File

@@ -0,0 +1,76 @@
import { Controller, Get, Post, UseGuards, Req, Logger } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { HealthScoresService } from './health-scores.service';
@ApiTags('health-scores')
@Controller('health-scores')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class HealthScoresController {
private readonly logger = new Logger(HealthScoresController.name);
constructor(private service: HealthScoresService) {}
@Get('latest')
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
getLatest(@Req() req: any) {
const schema = req.tenantSchema;
return this.service.getLatestScores(schema);
}
@Post('calculate')
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.tenantSchema;
// Fire-and-forget — background processing saves results to DB
Promise.all([
this.service.calculateScore(schema, 'operating'),
this.service.calculateScore(schema, 'reserve'),
]).catch((err) => {
this.logger.error(`Background health score calculation failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Health score calculations started. Results will appear when ready.',
};
}
@Post('calculate/operating')
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
@AllowViewer()
async calculateOperating(@Req() req: any) {
const schema = req.tenantSchema;
// Fire-and-forget
this.service.calculateScore(schema, 'operating').catch((err) => {
this.logger.error(`Background operating score failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Operating fund health score calculation started.',
};
}
@Post('calculate/reserve')
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
@AllowViewer()
async calculateReserve(@Req() req: any) {
const schema = req.tenantSchema;
// Fire-and-forget
this.service.calculateScore(schema, 'reserve').catch((err) => {
this.logger.error(`Background reserve score failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Reserve fund health score calculation started.',
};
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthScoresController } from './health-scores.controller';
import { HealthScoresService } from './health-scores.service';
import { HealthScoresScheduler } from './health-scores.scheduler';
@Module({
controllers: [HealthScoresController],
providers: [HealthScoresService, HealthScoresScheduler],
})
export class HealthScoresModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { HealthScoresService } from './health-scores.service';
@Injectable()
export class HealthScoresScheduler {
private readonly logger = new Logger(HealthScoresScheduler.name);
constructor(
private dataSource: DataSource,
private healthScoresService: HealthScoresService,
) {}
/**
* Run daily at 2:00 AM — calculate health scores for all active tenants.
* Uses DataSource directly to list tenants (no HTTP request context needed).
*/
@Cron('0 2 * * *')
async calculateAllTenantScores() {
this.logger.log('Starting daily health score calculation for all tenants...');
const startTime = Date.now();
try {
const orgs = await this.dataSource.query(
`SELECT id, name, schema_name FROM shared.organizations WHERE status = 'active'`,
);
this.logger.log(`Found ${orgs.length} active tenants`);
let successCount = 0;
let errorCount = 0;
for (const org of orgs) {
try {
await this.healthScoresService.calculateScore(org.schema_name, 'operating');
await this.healthScoresService.calculateScore(org.schema_name, 'reserve');
successCount++;
this.logger.log(`Health scores calculated for ${org.name} (${org.schema_name})`);
} catch (err: any) {
errorCount++;
this.logger.error(`Failed to calculate health scores for ${org.name}: ${err.message}`);
}
}
const elapsed = Date.now() - startTime;
this.logger.log(
`Daily health scores complete: ${successCount} success, ${errorCount} errors (${elapsed}ms)`,
);
} catch (err: any) {
this.logger.error(`Health score scheduler failed: ${err.message}`);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common'; import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { InvestmentPlanningService } from './investment-planning.service'; import { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning') @ApiTags('investment-planning')
@@ -17,14 +18,27 @@ export class InvestmentPlanningController {
} }
@Get('cd-rates') @Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data' }) @ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
getCdRates() { getCdRates() {
return this.service.getCdRates(); return this.service.getCdRates();
} }
@Get('market-rates')
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
getMarketRates() {
return this.service.getMarketRates();
}
@Get('saved-recommendation')
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
getSavedRecommendation() {
return this.service.getSavedRecommendation();
}
@Post('recommendations') @Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' }) @ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
getRecommendations(@Req() req: any) { @AllowViewer()
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId); triggerRecommendations(@Req() req: any) {
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
} }
} }

View File

@@ -28,15 +28,25 @@ export interface InvestmentAccount {
current_value: string; current_value: string;
} }
export interface CdRate { export interface MarketRate {
bank_name: string; bank_name: string;
apy: string; apy: string;
min_deposit: string | null; min_deposit: string | null;
term: string; term: string;
term_months: number | null; term_months: number | null;
rate_type: string;
fetched_at: string; fetched_at: string;
} }
export interface RecommendationComponent {
label: string;
amount: number;
term_months: number;
rate: number;
bank_name?: string;
investment_type?: string;
}
export interface Recommendation { export interface Recommendation {
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general'; type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
priority: 'high' | 'medium' | 'low'; priority: 'high' | 'medium' | 'low';
@@ -49,6 +59,7 @@ export interface Recommendation {
suggested_rate?: number; suggested_rate?: number;
bank_name?: string; bank_name?: string;
rationale: string; rationale: string;
components?: RecommendationComponent[];
} }
export interface AIResponse { export interface AIResponse {
@@ -57,6 +68,18 @@ export interface AIResponse {
risk_notes: string[]; risk_notes: string[];
} }
export interface SavedRecommendation {
id: string;
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
response_time_ms: number;
created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
}
@Injectable() @Injectable()
export class InvestmentPlanningService { export class InvestmentPlanningService {
private readonly logger = new Logger(InvestmentPlanningService.name); private readonly logger = new Logger(InvestmentPlanningService.name);
@@ -139,40 +162,341 @@ export class InvestmentPlanningService {
} }
/** /**
* Fetch latest CD rates from the shared schema (cross-tenant market data). * Fetch latest market rates from the shared schema (cross-tenant market data).
* Uses DataSource directly since this queries the shared schema, not tenant. * Returns rates grouped by type, each showing only the most recent fetch batch.
*/ */
async getCdRates(): Promise<CdRate[]> { async getMarketRates(): Promise<{ cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] }> {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
try { try {
await queryRunner.connect(); await queryRunner.connect();
const rates = await queryRunner.query(
`SELECT bank_name, apy, min_deposit, term, term_months, fetched_at // For each rate type, get the latest batch (same fetched_at timestamp)
FROM shared.cd_rates const fetchLatest = async (rateType: string): Promise<MarketRate[]> => {
ORDER BY apy DESC return queryRunner.query(
LIMIT 25`, `SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
); FROM shared.cd_rates
return rates; WHERE rate_type = $1
AND fetched_at = (
SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type = $1
)
ORDER BY apy DESC
LIMIT 25`,
[rateType],
);
};
const [cd, moneyMarket, highYieldSavings] = await Promise.all([
fetchLatest('cd'),
fetchLatest('money_market'),
fetchLatest('high_yield_savings'),
]);
return {
cd,
money_market: moneyMarket,
high_yield_savings: highYieldSavings,
};
} finally { } finally {
await queryRunner.release(); await queryRunner.release();
} }
} }
/**
* Backward-compatible: get only CD rates.
*/
async getCdRates(): Promise<MarketRate[]> {
const rates = await this.getMarketRates();
return rates.cd;
}
/**
* Ensure the status/error_message columns exist (for tenants created before this migration).
*/
private async ensureStatusColumn(): Promise<void> {
try {
await this.tenant.query(
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'complete'`,
);
await this.tenant.query(
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS error_message TEXT`,
);
} catch {
// Ignore — column may already exist or table may not exist
}
}
/**
* Get the latest saved AI recommendation for this tenant.
* Returns status and last_failed flag for UI state management.
*/
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
try {
await this.ensureStatusColumn();
const rows = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, status, error_message, created_at
FROM ai_recommendations
ORDER BY created_at DESC
LIMIT 1`,
);
if (!rows || rows.length === 0) return null;
const row = rows[0];
const status = row.status || 'complete';
// If still processing, return processing status
if (status === 'processing') {
return {
id: row.id,
recommendations: [],
overall_assessment: '',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'processing',
last_failed: false,
};
}
// If latest attempt failed, return the last successful result with last_failed flag
if (status === 'error') {
const lastGood = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at
FROM ai_recommendations
WHERE status = 'complete'
ORDER BY created_at DESC
LIMIT 1`,
);
if (lastGood?.length) {
const goodRow = lastGood[0];
const recData = goodRow.recommendations_json || {};
return {
id: goodRow.id,
recommendations: recData.recommendations || [],
overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '',
risk_notes: goodRow.risk_notes || recData.risk_notes || [],
response_time_ms: goodRow.response_time_ms || 0,
created_at: goodRow.created_at,
status: 'complete',
last_failed: true,
error_message: row.error_message,
};
}
// No previous good result — return error state
return {
id: row.id,
recommendations: [],
overall_assessment: row.error_message || 'AI analysis failed. Please try again.',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'error',
last_failed: true,
error_message: row.error_message,
};
}
// Complete — return the data normally
const recData = row.recommendations_json || {};
return {
id: row.id,
recommendations: recData.recommendations || [],
overall_assessment: row.overall_assessment || recData.overall_assessment || '',
risk_notes: row.risk_notes || recData.risk_notes || [],
response_time_ms: row.response_time_ms || 0,
created_at: row.created_at,
status: 'complete',
last_failed: false,
};
} catch (err: any) {
// Table might not exist yet (pre-migration tenants)
this.logger.warn(`Could not load saved recommendations: ${err.message}`);
return null;
}
}
/**
* Save a 'processing' placeholder record and return its ID.
*/
private async saveProcessingRecord(userId?: string): Promise<string> {
await this.ensureStatusColumn();
const rows = await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, status)
VALUES ('{}', '', '[]', $1, 'processing')
RETURNING id`,
[userId || null],
);
return rows[0].id;
}
/**
* Update a processing record with completed results.
*/
private async updateRecommendationComplete(
jobId: string,
aiResponse: AIResponse,
userId: string | undefined,
elapsed: number,
): Promise<void> {
try {
await this.tenant.query(
`UPDATE ai_recommendations
SET recommendations_json = $1,
overall_assessment = $2,
risk_notes = $3,
response_time_ms = $4,
status = 'complete'
WHERE id = $5`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
JSON.stringify(aiResponse.risk_notes || []),
elapsed,
jobId,
],
);
} catch (err: any) {
this.logger.warn(`Could not update recommendation ${jobId}: ${err.message}`);
}
}
/**
* Update a processing record with error status.
*/
private async updateRecommendationError(jobId: string, errorMessage: string): Promise<void> {
try {
await this.tenant.query(
`UPDATE ai_recommendations
SET status = 'error',
error_message = $1
WHERE id = $2`,
[errorMessage, jobId],
);
} catch (err: any) {
this.logger.warn(`Could not update recommendation error ${jobId}: ${err.message}`);
}
}
/**
* Trigger AI recommendations asynchronously.
* Saves a 'processing' record, starts the AI work in the background, and returns immediately.
* The TenantService instance remains alive via closure reference for the duration of the background work.
*/
async triggerAIRecommendations(userId?: string, orgId?: string): Promise<{ status: string; message: string }> {
const jobId = await this.saveProcessingRecord(userId);
this.logger.log(`AI recommendation triggered (job ${jobId}), starting background processing...`);
// Fire-and-forget — the Promise keeps this service instance (and TenantService) alive
this.runBackgroundRecommendations(jobId, userId, orgId).catch((err) => {
this.logger.error(`Background AI recommendation failed (job ${jobId}): ${err.message}`);
});
return {
status: 'processing',
message: 'AI analysis has been started. You can navigate away safely — results will appear when ready.',
};
}
/**
* Run the full AI recommendation pipeline in the background.
*/
private async runBackgroundRecommendations(jobId: string, userId?: string, orgId?: string): Promise<void> {
try {
const startTime = Date.now();
const [snapshot, allRates, monthlyForecast] = await Promise.all([
this.getFinancialSnapshot(),
this.getMarketRates(),
this.getMonthlyForecast(),
]);
this.debug('background_snapshot_summary', {
job_id: jobId,
operating_cash: snapshot.summary.operating_cash,
reserve_cash: snapshot.summary.reserve_cash,
total_all: snapshot.summary.total_all,
investment_accounts: snapshot.investment_accounts.length,
});
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime;
this.debug('background_final_response', {
job_id: jobId,
recommendation_count: aiResponse.recommendations.length,
has_assessment: !!aiResponse.overall_assessment,
elapsed_ms: elapsed,
});
// Check if the AI returned a graceful error (empty recommendations with error message)
const isGracefulError = aiResponse.recommendations.length === 0 &&
(aiResponse.overall_assessment?.includes('Unable to generate') ||
aiResponse.overall_assessment?.includes('invalid response'));
if (isGracefulError) {
await this.updateRecommendationError(jobId, aiResponse.overall_assessment);
} else {
await this.updateRecommendationComplete(jobId, aiResponse, userId, elapsed);
}
// Log AI usage (fire-and-forget)
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
this.logger.log(`Background AI recommendation completed (job ${jobId}) in ${elapsed}ms`);
} catch (err: any) {
this.logger.error(`Background AI recommendation error (job ${jobId}): ${err.message}`);
await this.updateRecommendationError(jobId, err.message);
}
}
/**
* Save AI recommendation result to tenant schema.
* @deprecated Use triggerAIRecommendations() for async flow instead
*/
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
try {
await this.ensureStatusColumn();
await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
VALUES ($1, $2, $3, $4, $5, 'complete')`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
JSON.stringify(aiResponse.risk_notes || []),
userId || null,
elapsed,
],
);
} catch (err: any) {
// Non-critical — don't let storage failure break recommendations
this.logger.warn(`Could not save recommendation: ${err.message}`);
}
}
/** /**
* Orchestrate the AI recommendation flow: * Orchestrate the AI recommendation flow:
* 1. Gather all financial data (tenant-scoped) * 1. Gather all financial data (tenant-scoped)
* 2. Fetch CD rates (shared schema) * 2. Fetch all market rates (shared schema)
* 3. Build the prompt with all context * 3. Build the prompt with all context
* 4. Call the AI API * 4. Call the AI API
* 5. Parse and return structured recommendations * 5. Parse and return structured recommendations
* 6. Save to tenant storage for future retrieval
*/ */
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> { async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
this.debug('getAIRecommendations', 'Starting AI recommendation flow'); this.debug('getAIRecommendations', 'Starting AI recommendation flow');
const startTime = Date.now(); const startTime = Date.now();
const [snapshot, cdRates, monthlyForecast] = await Promise.all([ const [snapshot, allRates, monthlyForecast] = await Promise.all([
this.getFinancialSnapshot(), this.getFinancialSnapshot(),
this.getCdRates(), this.getMarketRates(),
this.getMonthlyForecast(), this.getMonthlyForecast(),
]); ]);
@@ -183,11 +507,13 @@ export class InvestmentPlanningService {
investment_accounts: snapshot.investment_accounts.length, investment_accounts: snapshot.investment_accounts.length,
budgets: snapshot.budgets.length, budgets: snapshot.budgets.length,
projects: snapshot.projects.length, projects: snapshot.projects.length,
cd_rates: cdRates.length, cd_rates: allRates.cd.length,
money_market_rates: allRates.money_market.length,
savings_rates: allRates.high_yield_savings.length,
forecast_months: monthlyForecast.datapoints.length, forecast_months: monthlyForecast.datapoints.length,
}); });
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast); const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
const aiResponse = await this.callAI(messages); const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
@@ -197,6 +523,9 @@ export class InvestmentPlanningService {
risk_notes_count: aiResponse.risk_notes?.length || 0, risk_notes_count: aiResponse.risk_notes?.length || 0,
}); });
// Save recommendation to tenant storage (fire-and-forget)
this.saveRecommendation(aiResponse, userId, elapsed).catch(() => {});
// Log AI usage to shared.ai_recommendation_log (fire-and-forget) // Log AI usage to shared.ai_recommendation_log (fire-and-forget)
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {}); this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
@@ -345,9 +674,6 @@ export class InvestmentPlanningService {
/** /**
* Build a 12-month forward cash flow forecast for the AI. * Build a 12-month forward cash flow forecast for the AI.
* Mirrors the logic from ReportsService.getCashFlowForecast() but streamlined
* for AI context. Includes: assessment income schedule (regular + special),
* monthly budget income/expenses, investment maturities, and capital project costs.
*/ */
private async getMonthlyForecast() { private async getMonthlyForecast() {
const now = new Date(); const now = new Date();
@@ -551,7 +877,11 @@ export class InvestmentPlanningService {
// ── Private: AI Prompt Construction ── // ── Private: AI Prompt Construction ──
private buildPromptMessages(snapshot: any, cdRates: CdRate[], monthlyForecast: any) { private buildPromptMessages(
snapshot: any,
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
monthlyForecast: any,
) {
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot; const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
@@ -564,8 +894,10 @@ CRITICAL RULES:
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access. 4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
5. Operating funds should remain highly liquid (money market or high-yield savings only). 5. Operating funds should remain highly liquid (money market or high-yield savings only).
6. Respect the separation between operating funds and reserve funds. Never suggest commingling. 6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
7. Base your recommendations ONLY on the available CD rates and instruments provided. Do not reference rates or banks not in the provided data. 7. Base your recommendations ONLY on the available market rates (CDs, Money Market, High Yield Savings) provided. Do not reference rates or banks not in the provided data.
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due. 8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
RESPONSE FORMAT: RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema: Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
@@ -581,15 +913,30 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
"suggested_amount": 50000.00, "suggested_amount": 50000.00,
"suggested_term": "12 months", "suggested_term": "12 months",
"suggested_rate": 4.50, "suggested_rate": 4.50,
"bank_name": "Bank name from CD rates (if applicable)", "bank_name": "Bank name from market rates (if applicable)",
"rationale": "Financial reasoning for why this makes sense" "rationale": "Financial reasoning for why this makes sense",
"components": [
{
"label": "Component label (e.g. '6-Month CD at Marcus')",
"amount": 6600.00,
"term_months": 6,
"rate": 4.05,
"bank_name": "Marcus",
"investment_type": "cd"
}
]
} }
], ],
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities", "overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
"risk_notes": ["Array of risk items or concerns to flag for the board"] "risk_notes": ["Array of risk items or concerns to flag for the board"]
} }
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible.`; IMPORTANT ABOUT COMPONENTS:
- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts.
- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array.
- For simple single-investment recommendations, omit the "components" field entirely.
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
// Build the data context for the user prompt // Build the data context for the user prompt
const investmentsList = investment_accounts.length === 0 const investmentsList = investment_accounts.length === 0
@@ -616,11 +963,18 @@ IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority item
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`, `- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
).join('\n'); ).join('\n');
const cdRateLines = cdRates.length === 0 // Format market rates by type
? 'No CD rate data available. Rate fetcher may not have been run yet.' const formatRates = (rates: MarketRate[], typeLabel: string): string => {
: cdRates.map((r: CdRate) => if (rates.length === 0) return `No ${typeLabel} rate data available. Rate fetcher may not have been run yet.`;
`- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}% | Term: ${r.term} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`, return rates.map((r: MarketRate) => {
).join('\n'); const termStr = r.term !== 'N/A' ? ` | Term: ${r.term}` : '';
return `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${termStr} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`;
}).join('\n');
};
const cdRateLines = formatRates(allRates.cd, 'CD');
const moneyMarketLines = formatRates(allRates.money_market, 'Money Market');
const savingsRateLines = formatRates(allRates.high_yield_savings, 'High Yield Savings');
// Format assessment schedule showing regular + special // Format assessment schedule showing regular + special
const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0 const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0
@@ -679,15 +1033,25 @@ ${projectLines}
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities. This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
${forecastLines} ${forecastLines}
=== AVAILABLE CD RATES (Market Data) === === AVAILABLE MARKET RATES ===
--- CD Rates ---
${cdRateLines} ${cdRateLines}
--- Money Market Rates ---
${moneyMarketLines}
--- High Yield Savings Rates ---
${savingsRateLines}
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider: Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
1. Is there excess cash that could earn better returns in CDs? 1. Is there excess cash that could earn better returns in CDs, money market accounts, or high-yield savings?
2. Are any current investments maturing soon that need reinvestment planning? 2. Are any current investments maturing soon that need reinvestment planning?
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low. 3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
4. Would a CD ladder strategy improve the yield while maintaining access to funds? 4. Would a CD ladder strategy improve the yield while maintaining access to funds?
5. Are operating and reserve funds properly separated in the investment strategy?`; 5. Are operating and reserve funds properly separated in the investment strategy?
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
return [ return [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
@@ -754,7 +1118,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'), 'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
}, },
timeout: 180000, // 3 minute timeout timeout: 600000, // 10 minute timeout
}; };
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
@@ -768,7 +1132,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
req.on('error', (err) => reject(err)); req.on('error', (err) => reject(err));
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
reject(new Error(`Request timed out after 180s`)); reject(new Error(`Request timed out after 600s`));
}); });
req.write(bodyString); req.write(bodyString);

View File

@@ -16,6 +16,11 @@ export class InvoicesController {
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); } findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-preview')
generatePreview(@Body() dto: { month: number; year: number }) {
return this.invoicesService.generatePreview(dto);
}
@Post('generate-bulk') @Post('generate-bulk')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) { generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub); return this.invoicesService.generateBulk(dto, req.user.sub);

View File

@@ -1,33 +1,135 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service'; import { TenantService } from '../../database/tenant.service';
const MONTH_NAMES = [
'', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTH_ABBREV = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
@Injectable() @Injectable()
export class InvoicesService { export class InvoicesService {
constructor(private tenant: TenantService) {} constructor(private tenant: TenantService) {}
async findAll() { async findAll() {
return this.tenant.query(` return this.tenant.query(`
SELECT i.*, u.unit_number, SELECT i.*, u.unit_number, u.owner_name, ag.name as assessment_group_name, ag.frequency,
(i.amount - i.amount_paid) as balance_due (i.amount - i.amount_paid) as balance_due
FROM invoices i FROM invoices i
JOIN units u ON u.id = i.unit_id JOIN units u ON u.id = i.unit_id
LEFT JOIN assessment_groups ag ON ag.id = i.assessment_group_id
ORDER BY i.invoice_date DESC, i.invoice_number DESC ORDER BY i.invoice_date DESC, i.invoice_number DESC
`); `);
} }
async findOne(id: string) { async findOne(id: string) {
const rows = await this.tenant.query(` const rows = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i SELECT i.*, u.unit_number, u.owner_name FROM invoices i
JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]); JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]);
if (!rows.length) throw new NotFoundException('Invoice not found'); if (!rows.length) throw new NotFoundException('Invoice not found');
return rows[0]; return rows[0];
} }
async generateBulk(dto: { month: number; year: number }, userId: string) { /**
const units = await this.tenant.query( * Calculate billing period based on frequency and the billing month.
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 0`, */
private calculatePeriod(frequency: string, month: number, year: number): { start: string; end: string; description: string } {
switch (frequency) {
case 'quarterly': {
// Period covers 3 months starting from the billing month
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month + 2, 0); // last day of month+2
const endMonth = month + 2 > 12 ? month + 2 - 12 : month + 2;
const quarter = Math.ceil(month / 3);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Q${quarter} ${year} Assessment (${MONTH_ABBREV[month]}-${MONTH_ABBREV[endMonth]})`,
};
}
case 'annual': {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Annual Assessment ${year}`,
};
}
default: { // monthly
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0); // last day of month
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Monthly Assessment - ${MONTH_NAMES[month]} ${year}`,
};
}
}
}
/**
* Preview which groups/units will be billed for a given month/year.
*/
async generatePreview(dto: { month: number; year: number }) {
const allGroups = await this.tenant.query(
`SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as active_units
FROM assessment_groups ag WHERE ag.is_active = true ORDER BY ag.name`,
); );
if (!units.length) throw new BadRequestException('No active units with assessments found');
const groups = allGroups.map((g: any) => {
const dueMonths: number[] = g.due_months || [1,2,3,4,5,6,7,8,9,10,11,12];
const isBillingMonth = dueMonths.includes(dto.month);
const activeUnits = parseInt(g.active_units || '0');
const totalAmount = isBillingMonth
? (parseFloat(g.regular_assessment) + parseFloat(g.special_assessment || '0')) * activeUnits
: 0;
const period = this.calculatePeriod(g.frequency || 'monthly', dto.month, dto.year);
return {
id: g.id,
name: g.name,
frequency: g.frequency || 'monthly',
due_months: dueMonths,
active_units: activeUnits,
regular_assessment: g.regular_assessment,
special_assessment: g.special_assessment,
is_billing_month: isBillingMonth,
total_amount: totalAmount,
period_description: period.description,
};
});
const billableGroups = groups.filter((g: any) => g.is_billing_month && g.active_units > 0);
const totalInvoices = billableGroups.reduce((sum: number, g: any) => sum + g.active_units, 0);
const totalAmount = billableGroups.reduce((sum: number, g: any) => sum + g.total_amount, 0);
return {
month: dto.month,
year: dto.year,
month_name: MONTH_NAMES[dto.month],
groups,
summary: { total_groups_billing: billableGroups.length, total_invoices: totalInvoices, total_amount: totalAmount },
};
}
/**
* Generate invoices for all assessment groups where the given month is a billing month.
*/
async generateBulk(dto: { month: number; year: number }, userId: string) {
// Get assessment groups where this month is a billing month
const groups = await this.tenant.query(
`SELECT * FROM assessment_groups WHERE is_active = true AND $1 = ANY(due_months)`,
[dto.month],
);
if (!groups.length) {
throw new BadRequestException(`No assessment groups have billing scheduled for ${MONTH_NAMES[dto.month]}`);
}
// Get or create fiscal period // Get or create fiscal period
let fp = await this.tenant.query( let fp = await this.tenant.query(
@@ -41,50 +143,87 @@ export class InvoicesService {
} }
const fiscalPeriodId = fp[0].id; const fiscalPeriodId = fp[0].id;
const invoiceDate = new Date(dto.year, dto.month - 1, 1); // Look up GL accounts once
const dueDate = new Date(dto.year, dto.month - 1, 15); const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
let created = 0; let created = 0;
const groupResults: any[] = [];
for (const unit of units) { for (const group of groups) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`; // Get active units in this assessment group
const units = await this.tenant.query(
// Check if already generated `SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`,
const existing = await this.tenant.query( [group.id],
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
);
if (existing.length) continue;
// Create the invoice
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
unit.monthly_assessment],
); );
// Create journal entry: DR Accounts Receivable, CR Assessment Income if (!units.length) continue;
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
if (arAccount.length && incomeAccount.length) { const frequency = group.frequency || 'monthly';
const je = await this.tenant.query( const period = this.calculatePeriod(frequency, dto.month, dto.year);
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by) const dueDay = Math.min(group.due_day || 1, 28);
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`, const invoiceDate = new Date(dto.year, dto.month - 1, 1);
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId], const dueDate = new Date(dto.year, dto.month - 1, dueDay);
// Use the group's assessment amount (full period amount, not monthly equivalent)
const assessmentAmount = parseFloat(group.regular_assessment) + parseFloat(group.special_assessment || '0');
let groupCreated = 0;
for (const unit of units) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
// Check if already generated
const existing = await this.tenant.query(
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
); );
await this.tenant.query( if (existing.length) continue;
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unit.monthly_assessment, incomeAccount[0].id], // Use unit-level override if set, otherwise use group amount
); const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0
await this.tenant.query( ? (frequency === 'monthly'
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id], ? parseFloat(unit.monthly_assessment)
: frequency === 'quarterly'
? parseFloat(unit.monthly_assessment) * 3
: parseFloat(unit.monthly_assessment) * 12)
: assessmentAmount;
// Create the invoice with status 'pending' (no email sending capability)
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status, period_start, period_end, assessment_group_id)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
period.description, unitAmount, period.start, period.end, group.id],
); );
// Create journal entry: DR Accounts Receivable, CR Assessment Income
if (arAccount.length && incomeAccount.length) {
const je = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
);
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unitAmount, incomeAccount[0].id],
);
await this.tenant.query(
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
);
}
created++;
groupCreated++;
} }
created++;
groupResults.push({
group_name: group.name,
frequency,
period: period.description,
invoices_created: groupCreated,
});
} }
return { created, month: dto.month, year: dto.year }; return { created, month: dto.month, year: dto.year, groups: groupResults };
} }
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) { async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
@@ -95,7 +234,7 @@ export class InvoicesService {
const overdue = await this.tenant.query(` const overdue = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i SELECT i.*, u.unit_number FROM invoices i
JOIN units u ON u.id = i.unit_id JOIN units u ON u.id = i.unit_id
WHERE i.status IN ('sent', 'partial') AND i.due_date < $1 WHERE i.status IN ('pending', 'partial') AND i.due_date < $1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id
AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%' AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%'
@@ -109,7 +248,7 @@ export class InvoicesService {
const lfNum = `LF-${inv.invoice_number}`; const lfNum = `LF-${inv.invoice_number}`;
await this.tenant.query( await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status) `INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'sent')`, VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'pending')`,
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount], [lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
); );
applied++; applied++;

View File

@@ -13,6 +13,16 @@ export class JournalEntriesService {
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) { async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
let sql = ` let sql = `
SELECT je.*, SELECT je.*,
CASE
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
ELSE COALESCE(SUM(jel.debit), 0)
END as total_debit,
CASE
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
ELSE COALESCE(SUM(jel.credit), 0)
END as total_credit,
json_agg(json_build_object( json_agg(json_build_object(
'id', jel.id, 'account_id', jel.account_id, 'id', jel.id, 'account_id', jel.account_id,
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo, 'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,

View File

@@ -1,4 +1,4 @@
import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common'; import { Controller, Post, Get, Put, Patch, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service'; import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto';
@@ -23,6 +23,13 @@ export class OrganizationsController {
return this.orgService.findByUser(req.user.sub); return this.orgService.findByUser(req.user.sub);
} }
@Patch('settings')
@ApiOperation({ summary: 'Update settings for the current organization' })
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
this.requireTenantAdmin(req);
return this.orgService.updateSettings(req.user.orgId, body);
}
// ── Org Member Management ── // ── Org Member Management ──
private requireTenantAdmin(req: any) { private requireTenantAdmin(req: any) {

View File

@@ -78,6 +78,13 @@ export class OrganizationsService {
return this.orgRepository.save(org); return this.orgRepository.save(org);
} }
async updateSettings(id: string, settings: Record<string, any>) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new NotFoundException('Organization not found');
org.settings = { ...(org.settings || {}), ...settings };
return this.orgRepository.save(org);
}
async findByUser(userId: string) { async findByUser(userId: string) {
const memberships = await this.userOrgRepository.find({ const memberships = await this.userOrgRepository.find({
where: { userId, isActive: true }, where: { userId, isActive: true },
@@ -146,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role; existing.role = data.role;
return this.userOrgRepository.save(existing); return this.userOrgRepository.save(existing);
} }
// Update password for existing user being added to a new org
if (data.password) {
const passwordHash = await bcrypt.hash(data.password, 12);
await dataSource.query(
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
[passwordHash, userId],
);
}
} else { } else {
// Create new user // Create new user
const passwordHash = await bcrypt.hash(data.password, 12); const passwordHash = await bcrypt.hash(data.password, 12);

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common'; import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PaymentsService } from './payments.service'; import { PaymentsService } from './payments.service';
@@ -18,4 +18,12 @@ export class PaymentsController {
@Post() @Post()
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); } create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
return this.paymentsService.update(id, dto, req.user.sub);
}
@Delete(':id')
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
} }

View File

@@ -74,17 +74,95 @@ export class PaymentsService {
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]); await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
} }
// Update invoice if linked // Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error
if (invoice) { if (invoice) {
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount); const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
const invoiceAmt = parseFloat(invoice.amount); const invoiceAmt = parseFloat(invoice.amount);
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial'; const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
await this.tenant.query( await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $3`, `UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $4`,
[newPaid, newStatus, invoice.id], [newPaid, newStatus, newStatus, invoice.id],
); );
} }
return payment[0]; return payment[0];
} }
async update(id: string, dto: any, userId: string) {
const existing = await this.findOne(id);
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.payment_date !== undefined) { sets.push(`payment_date = $${idx++}`); params.push(dto.payment_date); }
if (dto.amount !== undefined) { sets.push(`amount = $${idx++}`); params.push(dto.amount); }
if (dto.payment_method !== undefined) { sets.push(`payment_method = $${idx++}`); params.push(dto.payment_method); }
if (dto.reference_number !== undefined) { sets.push(`reference_number = $${idx++}`); params.push(dto.reference_number); }
if (dto.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(dto.notes); }
if (!sets.length) return this.findOne(id);
params.push(id);
await this.tenant.query(
`UPDATE payments SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
// If amount changed and payment is linked to an invoice, recalculate invoice totals
if (dto.amount !== undefined && existing.invoice_id) {
await this.recalculateInvoice(existing.invoice_id);
}
return this.findOne(id);
}
async delete(id: string) {
const payment = await this.findOne(id);
const invoiceId = payment.invoice_id;
// Delete associated journal entry lines and journal entry
if (payment.journal_entry_id) {
await this.tenant.query('DELETE FROM journal_entry_lines WHERE journal_entry_id = $1', [payment.journal_entry_id]);
await this.tenant.query('DELETE FROM journal_entries WHERE id = $1', [payment.journal_entry_id]);
}
// Delete the payment
await this.tenant.query('DELETE FROM payments WHERE id = $1', [id]);
// Recalculate invoice totals if payment was linked
if (invoiceId) {
await this.recalculateInvoice(invoiceId);
}
return { success: true };
}
private async recalculateInvoice(invoiceId: string) {
// Sum all remaining payments for this invoice
const result = await this.tenant.query(
'SELECT COALESCE(SUM(amount), 0) as total_paid FROM payments WHERE invoice_id = $1',
[invoiceId],
);
const totalPaid = parseFloat(result[0].total_paid);
// Get the invoice amount
const inv = await this.tenant.query('SELECT amount FROM invoices WHERE id = $1', [invoiceId]);
if (!inv.length) return;
const invoiceAmt = parseFloat(inv[0].amount);
let newStatus: string;
if (totalPaid >= invoiceAmt) {
newStatus = 'paid';
} else if (totalPaid > 0) {
newStatus = 'partial';
} else {
newStatus = 'pending';
}
await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = $4`,
[totalPaid, newStatus, newStatus, invoiceId],
);
}
} }

View File

@@ -7,7 +7,7 @@ export class ProjectsService {
async findAll() { async findAll() {
const projects = await this.tenant.query( const projects = await this.tenant.query(
'SELECT * FROM projects WHERE is_active = true ORDER BY name', 'SELECT * FROM projects WHERE is_active = true ORDER BY planned_date NULLS LAST, target_year NULLS LAST, target_month NULLS LAST, name',
); );
return this.computeFunding(projects); return this.computeFunding(projects);
} }
@@ -20,7 +20,7 @@ export class ProjectsService {
async findForPlanning() { async findForPlanning() {
const projects = await this.tenant.query( const projects = await this.tenant.query(
'SELECT * FROM projects WHERE is_active = true AND target_year IS NOT NULL ORDER BY target_year, target_month NULLS LAST, priority', 'SELECT * FROM projects WHERE is_active = true ORDER BY target_year NULLS LAST, target_month NULLS LAST, priority',
); );
return this.computeFunding(projects); return this.computeFunding(projects);
} }
@@ -157,6 +157,9 @@ export class ProjectsService {
const params: any[] = []; const params: any[] = [];
let idx = 1; let idx = 1;
// Date columns must be null (not empty string) for PostgreSQL DATE type
const dateFields = new Set(['last_replacement_date', 'next_replacement_date', 'planned_date']);
// Build dynamic SET clause // Build dynamic SET clause
const fields: [string, string][] = [ const fields: [string, string][] = [
['name', 'name'], ['description', 'description'], ['category', 'category'], ['name', 'name'], ['description', 'description'], ['category', 'category'],
@@ -175,7 +178,8 @@ export class ProjectsService {
for (const [dtoKey, dbCol] of fields) { for (const [dtoKey, dbCol] of fields) {
if (dto[dtoKey] !== undefined) { if (dto[dtoKey] !== undefined) {
sets.push(`${dbCol} = $${idx++}`); sets.push(`${dbCol} = $${idx++}`);
params.push(dto[dtoKey]); const val = dateFields.has(dtoKey) && dto[dtoKey] === '' ? null : dto[dtoKey];
params.push(val);
} }
} }
@@ -276,7 +280,7 @@ export class ProjectsService {
await this.findOne(id); await this.findOne(id);
const rows = await this.tenant.query( const rows = await this.tenant.query(
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *', 'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
[id, planned_date], [id, planned_date || null],
); );
return rows[0]; return rows[0];
} }

View File

@@ -24,8 +24,16 @@ export class ReportsController {
} }
@Get('cash-flow-sankey') @Get('cash-flow-sankey')
getCashFlowSankey(@Query('year') year?: string) { getCashFlowSankey(
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear()); @Query('year') year?: string,
@Query('source') source?: string,
@Query('fundType') fundType?: string,
) {
return this.reportsService.getCashFlowSankey(
parseInt(year || '') || new Date().getFullYear(),
source || 'actuals',
fundType || 'all',
);
} }
@Get('cash-flow') @Get('cash-flow')
@@ -66,4 +74,20 @@ export class ReportsController {
const mo = Math.min(parseInt(months || '') || 24, 48); const mo = Math.min(parseInt(months || '') || 24, 48);
return this.reportsService.getCashFlowForecast(yr, mo); return this.reportsService.getCashFlowForecast(yr, mo);
} }
@Get('quarterly')
getQuarterlyFinancial(
@Query('year') year?: string,
@Query('quarter') quarter?: string,
) {
const now = new Date();
const defaultYear = now.getFullYear();
// Default to last complete quarter
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4;
const defaultQYear = currentQuarter > 1 ? defaultYear : defaultYear - 1;
const yr = parseInt(year || '') || defaultQYear;
const q = Math.min(Math.max(parseInt(quarter || '') || defaultQuarter, 1), 4);
return this.reportsService.getQuarterlyFinancial(yr, q);
}
} }

View File

@@ -14,10 +14,12 @@ export class ReportsService {
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance END as balance
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN (
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id journal_entry_lines jel
AND je.is_posted = true AND je.is_void = false INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.entry_date <= $1 AND je.is_posted = true AND je.is_void = false
AND je.entry_date <= $1
) ON jel.account_id = a.id
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity') WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
HAVING CASE HAVING CASE
@@ -32,6 +34,71 @@ export class ReportsService {
const liabilities = rows.filter((r: any) => r.account_type === 'liability'); const liabilities = rows.filter((r: any) => r.account_type === 'liability');
const equity = rows.filter((r: any) => r.account_type === 'equity'); const equity = rows.filter((r: any) => r.account_type === 'equity');
// Compute current year net income (income - expenses) for the fiscal year through as_of date
// This balances the accounting equation: Assets = Liabilities + Equity + Net Income
const fiscalYearStart = `${asOf.substring(0, 4)}-01-01`;
const netIncomeSql = `
SELECT
COALESCE(SUM(CASE WHEN a.account_type = 'income'
THEN jel.credit - jel.debit ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
THEN jel.debit - jel.credit ELSE 0 END), 0) as net_income
FROM journal_entry_lines jel
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
INNER JOIN accounts a ON a.id = jel.account_id
AND a.account_type IN ('income', 'expense') AND a.is_active = true
`;
const netIncomeResult = await this.tenant.query(netIncomeSql, [fiscalYearStart, asOf]);
const netIncome = parseFloat(netIncomeResult[0]?.net_income || '0');
// Add current year net income as a synthetic equity line
if (netIncome !== 0) {
equity.push({
id: null,
account_number: '',
name: 'Current Year Net Income',
account_type: 'equity',
fund_type: 'operating',
balance: netIncome.toFixed(2),
});
}
// Add investment account balances to assets and corresponding equity
const investmentsSql = `
SELECT id, name, institution, current_value as balance, fund_type
FROM investment_accounts
WHERE is_active = true AND current_value > 0
`;
const investments = await this.tenant.query(investmentsSql);
const investmentsByFund: Record<string, number> = {};
for (const inv of investments) {
assets.push({
id: inv.id,
account_number: '',
name: `${inv.name} (${inv.institution})`,
account_type: 'asset',
fund_type: inv.fund_type,
balance: parseFloat(inv.balance).toFixed(2),
});
investmentsByFund[inv.fund_type] = (investmentsByFund[inv.fund_type] || 0) + parseFloat(inv.balance);
}
// Add investment balances as synthetic equity lines to maintain A = L + E
for (const [fundType, total] of Object.entries(investmentsByFund)) {
if (total > 0) {
const label = fundType === 'reserve' ? 'Reserve' : 'Operating';
equity.push({
id: null,
account_number: '',
name: `${label} Investment Holdings`,
account_type: 'equity',
fund_type: fundType,
balance: total.toFixed(2),
});
}
}
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0); const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0); const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0); const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
@@ -54,10 +121,12 @@ export class ReportsService {
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount END as amount
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN (
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id journal_entry_lines jel
AND je.is_posted = true AND je.is_void = false INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.entry_date BETWEEN $1 AND $2 AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
) ON jel.account_id = a.id
WHERE a.is_active = true AND a.account_type IN ('income', 'expense') WHERE a.is_active = true AND a.account_type IN ('income', 'expense')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
HAVING CASE HAVING CASE
@@ -83,33 +152,151 @@ export class ReportsService {
}; };
} }
async getCashFlowSankey(year: number) { async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
// Get income accounts with amounts let income: any[];
const income = await this.tenant.query(` let expenses: any[];
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, [year]);
const expenses = await this.tenant.query(` const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : '';
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount const fundParams = fundType !== 'all' ? [year, fundType] : [year];
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id const monthSum = `COALESCE(b.jan,0)+COALESCE(b.feb,0)+COALESCE(b.mar,0)+COALESCE(b.apr,0)+COALESCE(b.may,0)+COALESCE(b.jun,0)+COALESCE(b.jul,0)+COALESCE(b.aug,0)+COALESCE(b.sep,0)+COALESCE(b.oct,0)+COALESCE(b.nov,0)+COALESCE(b.dec_amt,0)`;
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false if (source === 'budget') {
AND EXTRACT(YEAR FROM je.entry_date) = $1 income = await this.tenant.query(`
WHERE a.account_type = 'expense' AND a.is_active = true SELECT a.name, SUM(${monthSum}) as amount
GROUP BY a.id, a.name, a.fund_type FROM budgets b
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0 JOIN accounts a ON a.id = b.account_id
ORDER BY amount DESC WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
`, [year]); GROUP BY a.id, a.name
HAVING SUM(${monthSum}) > 0
ORDER BY SUM(${monthSum}) DESC
`, fundParams);
expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, SUM(${monthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
HAVING SUM(${monthSum}) > 0
ORDER BY SUM(${monthSum}) DESC
`, fundParams);
} else if (source === 'forecast') {
// Combine actuals (Jan to current date) + budget (remaining months)
const now = new Date();
const currentMonth = now.getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
const remainingMonths = monthNames.slice(currentMonth + 1);
const actualsFundCond = fundType !== 'all' ? ' AND a.fund_type = $2' : '';
const actualsParams: any[] = fundType !== 'all' ? [`${year}-01-01`, fundType] : [`${year}-01-01`];
const actualsIncome = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
WHERE a.account_type = 'income' AND a.is_active = true${actualsFundCond}
GROUP BY a.id, a.name
`, actualsParams);
const actualsExpenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
WHERE a.account_type = 'expense' AND a.is_active = true${actualsFundCond}
GROUP BY a.id, a.name, a.fund_type
`, actualsParams);
// Budget for remaining months
let budgetIncome: any[] = [];
let budgetExpenses: any[] = [];
if (remainingMonths.length > 0) {
const budgetMonthSum = remainingMonths.map(m => `COALESCE(b.${m},0)`).join('+');
budgetIncome = await this.tenant.query(`
SELECT a.name, SUM(${budgetMonthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
`, fundParams);
budgetExpenses = await this.tenant.query(`
SELECT a.name, a.fund_type, SUM(${budgetMonthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
`, fundParams);
}
// Merge actuals + budget by account name
const incomeMap = new Map<string, number>();
for (const a of actualsIncome) {
const amt = parseFloat(a.amount) || 0;
if (amt > 0) incomeMap.set(a.name, (incomeMap.get(a.name) || 0) + amt);
}
for (const b of budgetIncome) {
const amt = parseFloat(b.amount) || 0;
if (amt > 0) incomeMap.set(b.name, (incomeMap.get(b.name) || 0) + amt);
}
income = Array.from(incomeMap.entries())
.map(([name, amount]) => ({ name, amount: String(amount) }))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
const expenseMap = new Map<string, { amount: number; fund_type: string }>();
for (const a of actualsExpenses) {
const amt = parseFloat(a.amount) || 0;
if (amt > 0) {
const existing = expenseMap.get(a.name);
expenseMap.set(a.name, { amount: (existing?.amount || 0) + amt, fund_type: a.fund_type });
}
}
for (const b of budgetExpenses) {
const amt = parseFloat(b.amount) || 0;
if (amt > 0) {
const existing = expenseMap.get(b.name);
expenseMap.set(b.name, { amount: (existing?.amount || 0) + amt, fund_type: b.fund_type });
}
}
expenses = Array.from(expenseMap.entries())
.map(([name, { amount, fund_type }]) => ({ name, amount: String(amount), fund_type }))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
} else {
// Actuals: query journal entries for the year
income = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, fundParams);
expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
ORDER BY amount DESC
`, fundParams);
}
if (!income.length && !expenses.length) { if (!income.length && !expenses.length) {
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 }; return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
@@ -222,20 +409,20 @@ export class ReportsService {
ORDER BY a.name ORDER BY a.name
`, [from, to]); `, [from, to]);
// Asset filter: cash-only vs cash + investment accounts // Asset filter: all asset accounts (bank/checking/savings are the cash accounts)
const assetFilter = includeInvestments const assetFilter = `a.account_type = 'asset'`;
? `a.account_type = 'asset'`
: `a.account_type = 'asset' AND a.name LIKE '%Cash%'`;
// Cash beginning and ending balances // Cash beginning and ending balances
const beginCash = await this.tenant.query(` const beginCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM ( SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN (
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id journal_entry_lines jel
AND je.is_posted = true AND je.is_void = false INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.entry_date < $1 AND je.is_posted = true AND je.is_void = false
AND je.entry_date < $1
) ON jel.account_id = a.id
WHERE ${assetFilter} AND a.is_active = true WHERE ${assetFilter} AND a.is_active = true
GROUP BY a.id GROUP BY a.id
) sub ) sub
@@ -245,10 +432,12 @@ export class ReportsService {
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM ( SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN (
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id journal_entry_lines jel
AND je.is_posted = true AND je.is_void = false INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.entry_date <= $1 AND je.is_posted = true AND je.is_void = false
AND je.entry_date <= $1
) ON jel.account_id = a.id
WHERE ${assetFilter} AND a.is_active = true WHERE ${assetFilter} AND a.is_active = true
GROUP BY a.id GROUP BY a.id
) sub ) sub
@@ -273,7 +462,8 @@ export class ReportsService {
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0); const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0); const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0); const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance; // Only include investment balances in ending balance when includeInvestments is toggled on
const endingBalance = parseFloat(endCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
return { return {
from, to, from, to,
@@ -360,19 +550,22 @@ export class ReportsService {
const incomeStmt = await this.getIncomeStatement(from, to); const incomeStmt = await this.getIncomeStatement(from, to);
const balanceSheet = await this.getBalanceSheet(to); const balanceSheet = await this.getBalanceSheet(to);
// 1099 vendor data // 1099 vendor data — uses journal entries via vendor's default_account_id
const vendors1099 = await this.tenant.query(` const vendors1099 = await this.tenant.query(`
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code, SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
COALESCE(SUM(p.amount), 0) as total_paid COALESCE(SUM(p_amounts.amount), 0) as total_paid
FROM vendors v FROM vendors v
JOIN ( LEFT JOIN (
SELECT vendor_id, amount FROM invoices SELECT jel.account_id, jel.debit as amount
WHERE EXTRACT(YEAR FROM invoice_date) = $1 FROM journal_entry_lines jel
AND status IN ('paid', 'partial') JOIN journal_entries je ON je.id = jel.journal_entry_id
) p ON p.vendor_id = v.id WHERE je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
AND jel.debit > 0
) p_amounts ON p_amounts.account_id = v.default_account_id
WHERE v.is_1099_eligible = true WHERE v.is_1099_eligible = true
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
HAVING COALESCE(SUM(p.amount), 0) >= 600 HAVING COALESCE(SUM(p_amounts.amount), 0) >= 600
ORDER BY v.name ORDER BY v.name
`, [year]); `, [year]);
@@ -444,24 +637,43 @@ export class ReportsService {
} }
async getDashboardKPIs() { async getDashboardKPIs() {
// Total cash: ALL asset accounts (not just those named "Cash") // Operating cash (asset accounts, fund_type=operating)
// Uses proper double-entry balance: debit - credit for assets const opCash = await this.tenant.query(`
const cash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.is_active = true WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
GROUP BY a.id GROUP BY a.id
) sub ) sub
`); `);
// Also include investment account current_value in total cash // Reserve cash (asset accounts, fund_type=reserve)
const investmentCash = await this.tenant.query(` const resCash = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
FROM investment_accounts WHERE is_active = true SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
GROUP BY a.id
) sub
`); `);
const totalCash = parseFloat(cash[0]?.total || '0') + parseFloat(investmentCash[0]?.total || '0'); // Investment accounts split by fund type
const opInv = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
`);
const resInv = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
const operatingCash = parseFloat(opCash[0]?.total || '0');
const reserveCash = parseFloat(resCash[0]?.total || '0');
const operatingInvestments = parseFloat(opInv[0]?.total || '0');
const reserveInvestments = parseFloat(resInv[0]?.total || '0');
const totalCash = operatingCash + reserveCash + operatingInvestments + reserveInvestments;
// Receivables: sum of unpaid invoices // Receivables: sum of unpaid invoices
const ar = await this.tenant.query(` const ar = await this.tenant.query(`
@@ -469,9 +681,7 @@ export class ReportsService {
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off') FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
`); `);
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100) // Reserve fund balance via equity accounts + reserve investments
// The equity accounts track the total reserve fund position via double-entry bookkeeping
// This is the standard HOA approach — every reserve contribution/expenditure flows through equity
const reserves = await this.tenant.query(` const reserves = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
@@ -482,17 +692,67 @@ export class ReportsService {
GROUP BY a.id GROUP BY a.id
) sub ) sub
`); `);
// Add reserve investment account values to the reserve fund total
const reserveInvestments = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
// Delinquent count (overdue invoices) // Delinquent count (overdue invoices)
const delinquent = await this.tenant.query(` const delinquent = await this.tenant.query(`
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue' SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
`); `);
// Monthly interest estimate from accounts + investments with rates
const acctInterest = await this.tenant.query(`
SELECT COALESCE(SUM(sub.monthly_interest), 0) as total FROM (
SELECT (COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) * (a.interest_rate / 100) / 12 as monthly_interest
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.is_active = true AND a.interest_rate > 0
GROUP BY a.id, a.interest_rate
) sub
`);
const acctInterestTotal = parseFloat(acctInterest[0]?.total || '0');
const invInterest = await this.tenant.query(`
SELECT COALESCE(SUM(current_value * interest_rate / 100 / 12), 0) as total
FROM investment_accounts WHERE is_active = true AND interest_rate > 0
`);
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
// Interest earned YTD: actual interest income from journal entries for current year
const currentYear = new Date().getFullYear();
const interestEarned = await this.tenant.query(`
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
AND LOWER(a.name) LIKE '%interest%'
`, [currentYear]);
// Interest earned last year (for YoY comparison)
const interestLastYear = await this.tenant.query(`
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
AND LOWER(a.name) LIKE '%interest%'
`, [currentYear - 1]);
// Projected interest for current year: YTD actual + remaining months using
// the rate-based est_monthly_interest (same source as the dashboard KPI)
const currentMonth = new Date().getMonth() + 1;
const ytdInterest = parseFloat(interestEarned[0]?.total || '0');
const projectedInterest = ytdInterest + (estMonthlyInterest * (12 - currentMonth));
// Planned capital spend for current year
const capitalSpend = await this.tenant.query(`
SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
`, [currentYear]);
// Recent transactions // Recent transactions
const recentTx = await this.tenant.query(` const recentTx = await this.tenant.query(`
SELECT je.id, je.entry_date, je.description, je.entry_type, SELECT je.id, je.entry_date, je.description, je.entry_type,
@@ -504,9 +764,19 @@ export class ReportsService {
return { return {
total_cash: totalCash.toFixed(2), total_cash: totalCash.toFixed(2),
total_receivables: ar[0]?.total || '0.00', total_receivables: ar[0]?.total || '0.00',
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2), reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + reserveInvestments).toFixed(2),
delinquent_units: parseInt(delinquent[0]?.count || '0'), delinquent_units: parseInt(delinquent[0]?.count || '0'),
recent_transactions: recentTx, recent_transactions: recentTx,
// Enhanced split data
operating_cash: operatingCash.toFixed(2),
reserve_cash: reserveCash.toFixed(2),
operating_investments: operatingInvestments.toFixed(2),
reserve_investments: reserveInvestments.toFixed(2),
est_monthly_interest: estMonthlyInterest.toFixed(2),
interest_earned_ytd: ytdInterest.toFixed(2),
interest_last_year: parseFloat(interestLastYear[0]?.total || '0').toFixed(2),
interest_projected: projectedInterest.toFixed(2),
planned_capital_spend: capitalSpend[0]?.total || '0.00',
}; };
} }
@@ -594,15 +864,37 @@ export class ReportsService {
// We need budgets for startYear and startYear+1 to cover 24 months // We need budgets for startYear and startYear+1 to cover 24 months
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {}; const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
for (const yr of [startYear, startYear + 1, startYear + 2]) { const endYear = startYear + Math.ceil(months / 12) + 1;
const budgetRows = await this.tenant.query( for (let yr = startYear; yr <= endYear; yr++) {
`SELECT b.fund_type, a.account_type, let budgetRows: any[];
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, try {
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt budgetRows = await this.tenant.query(
FROM budgets b `SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
JOIN accounts a ON a.id = b.account_id SELECT b.account_id, b.fund_type, a.account_type,
WHERE b.fiscal_year = $1`, [yr], b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt,
); 1 as source_priority
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
UNION ALL
SELECT bpl.account_id, bpl.fund_type, a.account_type,
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt,
2 as source_priority
FROM budget_plan_lines bpl
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
JOIN accounts a ON a.id = bpl.account_id
WHERE bp.fiscal_year = $1
) combined
ORDER BY account_id, fund_type, source_priority`, [yr],
);
} catch {
budgetRows = await this.tenant.query(
`SELECT b.fund_type, a.account_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1`, [yr],
);
}
for (let m = 0; m < 12; m++) { for (let m = 0; m < 12; m++) {
const key = `${yr}-${m + 1}`; const key = `${yr}-${m + 1}`;
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 }; if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
@@ -795,4 +1087,168 @@ export class ReportsService {
datapoints, datapoints,
}; };
} }
/**
* Quarterly Financial Report: quarter income statement, YTD income statement,
* budget vs actuals for the quarter and YTD, and over-budget items.
*/
async getQuarterlyFinancial(year: number, quarter: number) {
// Quarter date ranges
const qStartMonths = [1, 4, 7, 10];
const qEndMonths = [3, 6, 9, 12];
const qStart = `${year}-${String(qStartMonths[quarter - 1]).padStart(2, '0')}-01`;
const qEndMonth = qEndMonths[quarter - 1];
const qEndDay = [31, 30, 30, 31][quarter - 1]; // Mar=31, Jun=30, Sep=30, Dec=31
const qEnd = `${year}-${String(qEndMonth).padStart(2, '0')}-${qEndDay}`;
const ytdStart = `${year}-01-01`;
// Quarter and YTD income statements (reuse existing method)
const quarterIS = await this.getIncomeStatement(qStart, qEnd);
const ytdIS = await this.getIncomeStatement(ytdStart, qEnd);
// Budget data for the quarter months
const budgetMonthCols = {
1: ['jan', 'feb', 'mar'],
2: ['apr', 'may', 'jun'],
3: ['jul', 'aug', 'sep'],
4: ['oct', 'nov', 'dec_amt'],
} as Record<number, string[]>;
const ytdMonthCols = {
1: ['jan', 'feb', 'mar'],
2: ['jan', 'feb', 'mar', 'apr', 'may', 'jun'],
3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep'],
4: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'],
} as Record<number, string[]>;
const qCols = budgetMonthCols[quarter];
const ytdCols = ytdMonthCols[quarter];
const budgetRows = await this.tenant.query(
`SELECT b.account_id, a.account_number, a.name, a.account_type, a.fund_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1`, [year],
);
// Actual amounts per account for the quarter and YTD
const quarterActuals = await this.tenant.query(`
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
`, [qStart, qEnd]);
const ytdActuals = await this.tenant.query(`
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
`, [ytdStart, qEnd]);
// Build budget vs actual comparison
const actualsByIdQ = new Map<string, number>();
for (const a of quarterActuals) {
actualsByIdQ.set(a.account_id, parseFloat(a.amount) || 0);
}
const actualsByIdYTD = new Map<string, number>();
for (const a of ytdActuals) {
actualsByIdYTD.set(a.account_id, parseFloat(a.amount) || 0);
}
const budgetVsActual: any[] = [];
const overBudgetItems: any[] = [];
for (const b of budgetRows) {
const qBudget = qCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
const ytdBudget = ytdCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
const qActual = actualsByIdQ.get(b.account_id) || 0;
const ytdActual = actualsByIdYTD.get(b.account_id) || 0;
if (qBudget === 0 && ytdBudget === 0 && qActual === 0 && ytdActual === 0) continue;
const qVariance = qActual - qBudget;
const ytdVariance = ytdActual - ytdBudget;
const isExpense = b.account_type === 'expense';
const item = {
account_id: b.account_id,
account_number: b.account_number,
name: b.name,
account_type: b.account_type,
fund_type: b.fund_type,
quarter_budget: qBudget,
quarter_actual: qActual,
quarter_variance: qVariance,
ytd_budget: ytdBudget,
ytd_actual: ytdActual,
ytd_variance: ytdVariance,
};
budgetVsActual.push(item);
// Flag expenses over budget by more than 10%
if (isExpense && qBudget > 0 && qActual > qBudget * 1.1) {
overBudgetItems.push({
...item,
variance_pct: ((qActual / qBudget - 1) * 100).toFixed(1),
});
}
}
// Also include accounts with actuals but no budget
for (const a of quarterActuals) {
if (!budgetRows.find((b: any) => b.account_id === a.account_id)) {
const ytdActual = actualsByIdYTD.get(a.account_id) || 0;
budgetVsActual.push({
account_id: a.account_id,
account_number: a.account_number,
name: a.name,
account_type: a.account_type,
fund_type: a.fund_type,
quarter_budget: 0,
quarter_actual: parseFloat(a.amount) || 0,
quarter_variance: parseFloat(a.amount) || 0,
ytd_budget: 0,
ytd_actual: ytdActual,
ytd_variance: ytdActual,
});
}
}
// Sort: income first, then expenses, both by account number
budgetVsActual.sort((a: any, b: any) => {
if (a.account_type !== b.account_type) return a.account_type === 'income' ? -1 : 1;
return (a.account_number || '').localeCompare(b.account_number || '');
});
return {
year,
quarter,
quarter_label: `Q${quarter} ${year}`,
date_range: { from: qStart, to: qEnd },
quarter_income_statement: quarterIS,
ytd_income_statement: ytdIS,
budget_vs_actual: budgetVsActual,
over_budget_items: overBudgetItems,
};
}
} }

View File

@@ -49,6 +49,9 @@ export class User {
@Column({ name: 'is_platform_owner', default: false }) @Column({ name: 'is_platform_owner', default: false })
isPlatformOwner: boolean; isPlatformOwner: boolean;
@Column({ name: 'has_seen_intro', default: false })
hasSeenIntro: boolean;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt: Date; lastLoginAt: Date;

View File

@@ -57,6 +57,10 @@ export class UsersService {
`); `);
} }
async markIntroSeen(id: string): Promise<void> {
await this.usersRepository.update(id, { hasSeenIntro: true });
}
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> { async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
// Protect platform owner from having superadmin removed // Protect platform owner from having superadmin removed
const user = await this.usersRepository.findOne({ where: { id: userId } }); const user = await this.usersRepository.findOne({ where: { id: userId } });

View File

@@ -17,10 +17,10 @@ export class VendorsService {
async create(dto: any) { async create(dto: any) {
const rows = await this.tenant.query( const rows = await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id) `INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id, last_negotiated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code, [dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code,
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null], dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null, dto.last_negotiated || null],
); );
return rows[0]; return rows[0];
} }
@@ -32,24 +32,25 @@ export class VendorsService {
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1), email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code), city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code),
tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible), tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible),
default_account_id = COALESCE($12, default_account_id), updated_at = NOW() default_account_id = COALESCE($12, default_account_id), last_negotiated = $13, updated_at = NOW()
WHERE id = $1 RETURNING *`, WHERE id = $1 RETURNING *`,
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, [id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id], dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id, dto.last_negotiated || null],
); );
return rows[0]; return rows[0];
} }
async exportCSV(): Promise<string> { async exportCSV(): Promise<string> {
const rows = await this.tenant.query( const rows = await this.tenant.query(
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible `SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated
FROM vendors WHERE is_active = true ORDER BY name`, FROM vendors WHERE is_active = true ORDER BY name`,
); );
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible']; const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible', 'last_negotiated'];
const lines = [headers.join(',')]; const lines = [headers.join(',')];
for (const r of rows) { for (const r of rows) {
lines.push(headers.map((h) => { lines.push(headers.map((h) => {
const v = r[h] ?? ''; let v = r[h] ?? '';
if (v instanceof Date) v = v.toISOString().split('T')[0];
const s = String(v); const s = String(v);
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s; return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
}).join(',')); }).join(','));
@@ -80,20 +81,22 @@ export class VendorsService {
zip_code = COALESCE(NULLIF($8, ''), zip_code), zip_code = COALESCE(NULLIF($8, ''), zip_code),
tax_id = COALESCE(NULLIF($9, ''), tax_id), tax_id = COALESCE(NULLIF($9, ''), tax_id),
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible), is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
updated_at = NOW() updated_at = NOW()
WHERE id = $1`, WHERE id = $1`,
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1, [existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible], row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible, row.last_negotiated],
); );
updated++; updated++;
} else { } else {
await this.tenant.query( await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible) `INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[name, row.contact_name || null, row.email || null, row.phone || null, [name, row.contact_name || null, row.email || null, row.phone || null,
row.address_line1 || null, row.city || null, row.state || null, row.address_line1 || null, row.city || null, row.state || null,
row.zip_code || null, row.tax_id || null, row.zip_code || null, row.tax_id || null,
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false], row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false,
row.last_negotiated || null],
); );
created++; created++;
} }

View File

@@ -77,7 +77,8 @@ CREATE TABLE shared.invitations (
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
-- CD Rates (cross-tenant market data for investment recommendations) -- Market Rates (cross-tenant market data for investment recommendations)
-- Supports CD, Money Market, and High Yield Savings rate types
CREATE TABLE shared.cd_rates ( CREATE TABLE shared.cd_rates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bank_name VARCHAR(255) NOT NULL, bank_name VARCHAR(255) NOT NULL,
@@ -85,6 +86,7 @@ CREATE TABLE shared.cd_rates (
min_deposit DECIMAL(15,2), min_deposit DECIMAL(15,2),
term VARCHAR(100) NOT NULL, term VARCHAR(100) NOT NULL,
term_months INTEGER, term_months INTEGER,
rate_type VARCHAR(50) NOT NULL DEFAULT 'cd',
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url VARCHAR(500), source_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
@@ -121,6 +123,8 @@ CREATE INDEX idx_invitations_token ON shared.invitations(token);
CREATE INDEX idx_invitations_email ON shared.invitations(email); CREATE INDEX idx_invitations_email ON shared.invitations(email);
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC); CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC); CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);
CREATE INDEX idx_cd_rates_type ON shared.cd_rates(rate_type);
CREATE INDEX idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC); CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC);
CREATE INDEX idx_login_history_user ON shared.login_history(user_id); CREATE INDEX idx_login_history_user ON shared.login_history(user_id);
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC); CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);

View File

@@ -0,0 +1,36 @@
-- Migration: Expand cd_rates for multiple market rate types + tenant AI recommendation storage
-- Phase 6: AI Features Part 2
-- 1) Add rate_type column to shared.cd_rates to support CD, Money Market, and High Yield Savings
ALTER TABLE shared.cd_rates
ADD COLUMN IF NOT EXISTS rate_type VARCHAR(50) DEFAULT 'cd' NOT NULL;
-- Index for filtering by rate type
CREATE INDEX IF NOT EXISTS idx_cd_rates_type ON shared.cd_rates(rate_type);
-- Composite index for getting latest rates by type efficiently
CREATE INDEX IF NOT EXISTS idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
-- 2) Create ai_recommendations table in each existing tenant schema
-- This stores saved AI investment recommendations per tenant
-- For new tenants, this is handled by tenant-schema.service.ts
DO $$
DECLARE
tenant_schema TEXT;
BEGIN
FOR tenant_schema IN
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I.ai_recommendations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recommendations_json JSONB NOT NULL,
overall_assessment TEXT,
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
)', tenant_schema
);
END LOOP;
END $$;

View File

@@ -0,0 +1,16 @@
-- Migration: Add last_negotiated date to vendors table
-- Bug & Tweak Sprint
DO $$
DECLARE
tenant_schema TEXT;
BEGIN
FOR tenant_schema IN
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
LOOP
EXECUTE format(
'ALTER TABLE %I.vendors ADD COLUMN IF NOT EXISTS last_negotiated DATE',
tenant_schema
);
END LOOP;
END $$;

View File

@@ -0,0 +1,9 @@
-- Migration: Add onboarding tracking flag to users table
-- Phase 7: Onboarding Features
BEGIN;
ALTER TABLE shared.users
ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
COMMIT;

View File

@@ -0,0 +1,34 @@
-- Migration: Add health_scores table to all tenant schemas
-- This table stores AI-derived operating and reserve fund health scores
DO $$
DECLARE
tenant RECORD;
BEGIN
FOR tenant IN
SELECT schema_name FROM shared.organizations WHERE status = 'active'
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I.health_scores (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
score_type VARCHAR(20) NOT NULL CHECK (score_type IN (''operating'', ''reserve'')),
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
previous_score INTEGER,
trajectory VARCHAR(20) CHECK (trajectory IN (''improving'', ''stable'', ''declining'')),
label VARCHAR(30),
summary TEXT,
factors JSONB,
recommendations JSONB,
missing_data JSONB,
status VARCHAR(20) NOT NULL DEFAULT ''complete'' CHECK (status IN (''complete'', ''pending'', ''error'')),
response_time_ms INTEGER,
calculated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
)', tenant.schema_name
);
EXECUTE format(
'CREATE INDEX IF NOT EXISTS idx_%s_hs_type_calc ON %I.health_scores(score_type, calculated_at DESC)',
replace(tenant.schema_name, '.', '_'), tenant.schema_name
);
END LOOP;
END $$;

View File

@@ -0,0 +1,57 @@
-- Migration 011: Add billing frequency support to invoices
-- Adds due_months and due_day to assessment_groups
-- Adds period_start, period_end, assessment_group_id to invoices
DO $$
DECLARE
v_schema TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Add due_months and due_day to assessment_groups
EXECUTE format('
ALTER TABLE %I.assessment_groups
ADD COLUMN IF NOT EXISTS due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
ADD COLUMN IF NOT EXISTS due_day INTEGER DEFAULT 1
', v_schema);
-- Add period tracking and assessment group link to invoices
EXECUTE format('
ALTER TABLE %I.invoices
ADD COLUMN IF NOT EXISTS period_start DATE,
ADD COLUMN IF NOT EXISTS period_end DATE,
ADD COLUMN IF NOT EXISTS assessment_group_id UUID
', v_schema);
-- Backfill due_months based on existing frequency values
EXECUTE format('
UPDATE %I.assessment_groups
SET due_months = CASE frequency
WHEN ''quarterly'' THEN ''{1,4,7,10}''::INTEGER[]
WHEN ''annual'' THEN ''{1}''::INTEGER[]
ELSE ''{1,2,3,4,5,6,7,8,9,10,11,12}''::INTEGER[]
END
WHERE due_months IS NULL OR due_months = ''{1,2,3,4,5,6,7,8,9,10,11,12}''
AND frequency != ''monthly''
', v_schema);
-- Backfill period_start/period_end for existing invoices (all monthly)
EXECUTE format('
UPDATE %I.invoices
SET period_start = invoice_date,
period_end = (invoice_date + INTERVAL ''1 month'' - INTERVAL ''1 day'')::DATE
WHERE period_start IS NULL AND invoice_type = ''regular_assessment''
', v_schema);
-- Backfill assessment_group_id on existing invoices from units
EXECUTE format('
UPDATE %I.invoices i
SET assessment_group_id = u.assessment_group_id
FROM %I.units u
WHERE i.unit_id = u.id AND i.assessment_group_id IS NULL
', v_schema, v_schema);
END LOOP;
END $$;

View File

@@ -0,0 +1,33 @@
-- Migration 012: Replace 'sent' status with 'pending' for invoices
-- 'sent' implied email delivery which doesn't exist; 'pending' is more accurate
DO $$
DECLARE
v_schema TEXT;
v_constraint TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Find and drop the existing status check constraint
SELECT constraint_name INTO v_constraint
FROM information_schema.table_constraints
WHERE table_schema = v_schema
AND table_name = 'invoices'
AND constraint_type = 'CHECK'
AND constraint_name LIKE '%status%';
IF v_constraint IS NOT NULL THEN
EXECUTE format('ALTER TABLE %I.invoices DROP CONSTRAINT %I', v_schema, v_constraint);
END IF;
-- Add new constraint that includes 'pending'
EXECUTE format('ALTER TABLE %I.invoices ADD CONSTRAINT invoices_status_check CHECK (status IN (
''draft'', ''pending'', ''sent'', ''paid'', ''partial'', ''overdue'', ''void'', ''written_off''
))', v_schema);
-- Convert existing 'sent' invoices to 'pending'
EXECUTE format('UPDATE %I.invoices SET status = ''pending'' WHERE status = ''sent''', v_schema);
END LOOP;
END $$;

View File

@@ -0,0 +1,83 @@
-- Migration 013: Board Planning tables (scenarios, investments, assessments)
-- Applies to all existing tenant schemas
DO $$
DECLARE
tenant_schema TEXT;
BEGIN
FOR tenant_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Board Scenarios
EXECUTE format('
CREATE TABLE IF NOT EXISTS %I.board_scenarios (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN (''investment'', ''assessment'')),
status VARCHAR(20) DEFAULT ''draft'' CHECK (status IN (''draft'', ''active'', ''approved'', ''archived'')),
projection_months INTEGER DEFAULT 36,
projection_cache JSONB,
projection_cached_at TIMESTAMPTZ,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)', tenant_schema);
-- Scenario Investments
EXECUTE format('
CREATE TABLE IF NOT EXISTS %I.scenario_investments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
scenario_id UUID NOT NULL REFERENCES %I.board_scenarios(id) ON DELETE CASCADE,
source_recommendation_id UUID,
label VARCHAR(255) NOT NULL,
investment_type VARCHAR(50) CHECK (investment_type IN (''cd'', ''money_market'', ''treasury'', ''savings'', ''other'')),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN (''operating'', ''reserve'')),
principal DECIMAL(15,2) NOT NULL,
interest_rate DECIMAL(6,4),
term_months INTEGER,
institution VARCHAR(255),
purchase_date DATE,
maturity_date DATE,
auto_renew BOOLEAN DEFAULT FALSE,
executed_investment_id UUID,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)', tenant_schema, tenant_schema);
-- Scenario Assessments
EXECUTE format('
CREATE TABLE IF NOT EXISTS %I.scenario_assessments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
scenario_id UUID NOT NULL REFERENCES %I.board_scenarios(id) ON DELETE CASCADE,
change_type VARCHAR(30) NOT NULL CHECK (change_type IN (''dues_increase'', ''special_assessment'', ''dues_decrease'')),
label VARCHAR(255) NOT NULL,
target_fund VARCHAR(20) CHECK (target_fund IN (''operating'', ''reserve'', ''both'')),
percentage_change DECIMAL(6,3),
flat_amount_change DECIMAL(10,2),
special_total DECIMAL(15,2),
special_per_unit DECIMAL(10,2),
special_installments INTEGER DEFAULT 1,
effective_date DATE NOT NULL,
end_date DATE,
applies_to_group_id UUID,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)', tenant_schema, tenant_schema);
-- Indexes
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bs_type_status ON %I.board_scenarios(scenario_type, status)',
replace(tenant_schema, '.', '_'), tenant_schema);
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_si_scenario ON %I.scenario_investments(scenario_id)',
replace(tenant_schema, '.', '_'), tenant_schema);
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_sa_scenario ON %I.scenario_assessments(scenario_id)',
replace(tenant_schema, '.', '_'), tenant_schema);
RAISE NOTICE 'Board planning tables created for schema: %', tenant_schema;
END LOOP;
END $$;

View File

@@ -0,0 +1,54 @@
-- Migration: Add budget_plans and budget_plan_lines tables to all tenant schemas
DO $migration$
DECLARE
s TEXT;
BEGIN
FOR s IN
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'
LOOP
-- budget_plans
EXECUTE format('
CREATE TABLE IF NOT EXISTS %I.budget_plans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fiscal_year INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT ''planning'' CHECK (status IN (''planning'', ''approved'', ''ratified'')),
base_year INTEGER NOT NULL,
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
notes TEXT,
created_by UUID,
approved_by UUID,
approved_at TIMESTAMPTZ,
ratified_by UUID,
ratified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(fiscal_year)
)', s);
-- budget_plan_lines
EXECUTE format('
CREATE TABLE IF NOT EXISTS %I.budget_plan_lines (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
budget_plan_id UUID NOT NULL REFERENCES %I.budget_plans(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES %I.accounts(id),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN (''operating'', ''reserve'')),
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
is_manually_adjusted BOOLEAN DEFAULT FALSE,
notes TEXT,
UNIQUE(budget_plan_id, account_id, fund_type)
)', s, s, s);
-- Indexes
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bp_year ON %I.budget_plans(fiscal_year)', replace(s, 'tenant_', ''), s);
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bp_status ON %I.budget_plans(status)', replace(s, 'tenant_', ''), s);
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bpl_plan ON %I.budget_plan_lines(budget_plan_id)', replace(s, 'tenant_', ''), s);
RAISE NOTICE 'Migrated schema: %', s;
END LOOP;
END;
$migration$;

View File

@@ -204,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
special_assessment DECIMAL(10,2) DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0, unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT ''monthly'', frequency VARCHAR(20) DEFAULT ''monthly'',
due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
due_day INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
)', v_schema); )', v_schema);
@@ -244,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices (
amount DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00, amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT ''draft'', status VARCHAR(20) DEFAULT ''draft'',
period_start DATE,
period_end DATE,
assessment_group_id UUID,
journal_entry_id UUID, journal_entry_id UUID,
sent_at TIMESTAMPTZ, sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ, paid_at TIMESTAMPTZ,
@@ -443,10 +449,10 @@ END LOOP;
-- ============================================================ -- ============================================================
-- 4b. Seed Assessment Groups -- 4b. Seed Assessment Groups
-- ============================================================ -- ============================================================
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day) VALUES
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20), (''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 15),
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15), (''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 1275.00, 0.00, 15, ''quarterly'', ''{1,4,7,10}'', 1),
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15) (''Estate Lots'', ''Large estate lots (Units 36-50)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1)
', v_schema); ', v_schema);
-- ============================================================ -- ============================================================

95
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,95 @@
# Production override — use with:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# What this changes from the base (dev) config:
# - Disables the Docker nginx container (host nginx handles routing + SSL)
# - Backend: production Dockerfile (compiled JS, no watch, no devDeps)
# - Frontend: production Dockerfile (static build served by nginx on port 3001)
# - Backend + Frontend bound to 127.0.0.1 only (host nginx proxies to them)
# - No source-code volume mounts (uses baked-in built code)
# - Memory limits and health checks on backend
# - Tuned PostgreSQL for production workloads
# - Restart policies for reliability
#
# SSL/TLS and request routing are handled by the host-level nginx.
# See nginx/host-production.conf for a ready-to-use reference config.
services:
nginx:
# Disabled in production — host nginx handles routing + SSL directly.
# The dev-only Docker nginx is still used by the base docker-compose.yml.
deploy:
replicas: 0
backend:
build:
context: ./backend
dockerfile: Dockerfile # production Dockerfile (compiled JS)
ports:
- "127.0.0.1:3000:3000" # loopback only — host nginx proxies here
volumes: [] # override: no source mounts in prod
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- JWT_SECRET=${JWT_SECRET}
- NODE_ENV=production
- AI_API_URL=${AI_API_URL}
- AI_API_KEY=${AI_API_KEY}
- AI_MODEL=${AI_MODEL}
- AI_DEBUG=${AI_DEBUG:-false}
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
deploy:
resources:
limits:
memory: 1024M
reservations:
memory: 256M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile # production Dockerfile (static nginx)
ports:
- "127.0.0.1:3001:3001" # loopback only — host nginx proxies here
volumes: [] # override: no source mounts in prod
environment:
- NODE_ENV=production
restart: unless-stopped
postgres:
# Tune PostgreSQL for production workloads
command: >
postgres
-c max_connections=200
-c shared_buffers=256MB
-c effective_cache_size=512MB
-c work_mem=4MB
-c maintenance_work_mem=64MB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c random_page_cost=1.1
# No host port mapping — backend reaches postgres via the Docker network.
# Removes 2 docker-proxy processes and closes 0.0.0.0:5432 to the internet.
ports: []
deploy:
resources:
limits:
memory: 1024M
reservations:
memory: 512M
restart: unless-stopped
redis:
# No host port mapping — backend reaches redis via the Docker network.
# Removes 2 docker-proxy processes and closes 0.0.0.0:6379 to the internet.
ports: []
restart: unless-stopped

28
docker-compose.ssl.yml Normal file
View File

@@ -0,0 +1,28 @@
# SSL override — use with: docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d
#
# This adds port 443, certbot volumes, and a certbot renewal service
# to the base docker-compose.yml configuration.
services:
nginx:
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/ssl.conf:/etc/nginx/conf.d/default.conf:ro
- certbot_www:/var/www/certbot:ro
- certbot_conf:/etc/letsencrypt:ro
certbot:
image: certbot/certbot:latest
volumes:
- certbot_www:/var/www/certbot
- certbot_conf:/etc/letsencrypt
networks:
- hoanet
# Auto-renew: check twice daily, only renews if < 30 days remain
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done'"
volumes:
certbot_www:
certbot_conf:

View File

@@ -15,8 +15,8 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
ports: # No host port mapping — dev traffic goes through the Docker nginx container.
- "3000:3000" # Production overlay maps 127.0.0.1:3000 for the host reverse proxy.
environment: environment:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
@@ -26,6 +26,9 @@ services:
- AI_API_KEY=${AI_API_KEY} - AI_API_KEY=${AI_API_KEY}
- AI_MODEL=${AI_MODEL} - AI_MODEL=${AI_MODEL}
- AI_DEBUG=${AI_DEBUG:-false} - AI_DEBUG=${AI_DEBUG:-false}
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
volumes: volumes:
- ./backend/src:/app/src - ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json - ./backend/nest-cli.json:/app/nest-cli.json
@@ -43,8 +46,8 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
ports: # No host port mapping — dev traffic goes through the Docker nginx container.
- "5173:5173" # Production overlay maps 127.0.0.1:3001 for the host reverse proxy.
environment: environment:
- NODE_ENV=${NODE_ENV} - NODE_ENV=${NODE_ENV}
volumes: volumes:

545
docs/AI_FEATURE_AUDIT.md Normal file
View File

@@ -0,0 +1,545 @@
# AI Feature Audit Report
**Audit Date:** 2026-03-05
**Tenant Under Test:** Pine Creek HOA (`tenant_pine_creek_hoa_q33i`)
**AI Model:** Qwen 3.5-397B-A17B via NVIDIA NIM (Temperature: 0.3)
**Auditor:** Claude Opus 4.6 (automated)
**Data Snapshot Date:** 2026-03-04
---
## Executive Summary
Three AI-powered features were audited against ground-truth database records: **Operating Fund Health**, **Reserve Fund Health**, and **Investment Recommendations**. Overall, the AI demonstrates strong financial reasoning and produces actionable, fiduciary-appropriate recommendations. However, score consistency across runs is a concern (16-point spread on operating, 20-point spread on reserve), and several specific data interpretation issues were identified.
| Feature | Latest Score/Grade | Concurrence | Verdict |
|---|---|---|---|
| Operating Fund Health | 88 / Good | **72%** | Score ~10-15 pts high; cash runway below its own "Good" threshold |
| Reserve Fund Health | 45 / Needs Attention | **85%** | Well-calibrated; minor data misquote on annual contributions |
| Investment Recommendations | 6 recommendations | **88%** | Excellent specificity; all market rates verified accurate |
---
## Data Foundation (Ground Truth)
### Financial Position
| Metric | Value | Source |
|---|---|---|
| Operating Cash (Checking) | $27,418.81 | GL balance |
| Reserve Cash (Savings) | $10,688.45 | GL balance |
| Reserve CD #1a (FCB) | $10,000 @ 3.67%, matures 6/19/26 | `investment_accounts` |
| Reserve CD #2a (FCB) | $8,000 @ 3.60%, matures 4/14/26 | `investment_accounts` |
| Reserve CD #3a (FCB) | $10,000 @ 3.67%, matures 8/18/26 | `investment_accounts` |
| Total Reserve Fund | $38,688.45 | Cash + Investments |
| Total Assets | $66,107.26 | Operating + Reserve |
### Budget (FY2026)
| Category | Annual Total |
|---|---|
| Operating Income | $184,207.40 |
| Operating Expense | $139,979.95 |
| **Net Operating Surplus** | **$44,227.45** |
| Monthly Expense Run Rate | $11,665.00 |
| Reserve Interest Income | $1,449.96 |
| Reserve Disbursements | $22,000.00 (Mar $13K, Apr $9K) |
### Assessment Structure
- **67 units** at $2,328.14/year regular + $300.00/year special (annual frequency)
- Total annual regular assessments: ~$155,985
- Total annual special assessments: ~$20,100
- Budget timing: assessments front-loaded in Mar-Jun
### Actuals (YTD through March 4, 2026)
| Metric | Value |
|---|---|
| YTD Income | $88.16 (ARC fees $100 - $50 adj + $38.16 interest) |
| YTD Expenses | $1,850.42 (January only) |
| Delinquent Invoices | 0 ($0.00) |
| Journal Entries Posted | 4 (Jan actuals + Feb adjusting + Feb opening balances) |
### Capital Projects (from `projects` table, 26 total)
| Project | Cost | Target | Funded % |
|---|---|---|---|
| Pond Spillway | $7,000 | Mar 2026 | 0% |
| Tuscany Drain Box | $5,500 | May 2026 | 0% |
| Front Entrance Power Washing | $1,500 | Mar 2027 | 0% |
| Irrigation Pump Replacement | $1,500 | Jun 2027 | 0% |
| **Road Sealing - All Roads** | **$80,000** | **Jun 2029** | **0%** |
| Asphalt Repair - Creek Stone Dr | $43,000 | TBD | 0% |
| Pavilion & Vineyard Structures | $7,000 | Jun 2035 | 0% |
| 16 placeholder items | $1.00 each | TBD | 0% |
| **Total Planned** | **$152,016** | | **0%** |
### Reserve Components
- **0 components tracked** (empty `reserve_components` table)
### Market Rates (fetched 2026-03-04)
| Type | Top Rate | Bank | Term |
|---|---|---|---|
| CD | 4.10% | E*TRADE / Synchrony | 12-14 mo |
| High-Yield Savings | 4.09% | Openbank | Liquid |
| Money Market | 4.03% | Vio Bank | Liquid |
---
## 1. Operating Fund Health Score
**Latest Score:** 88 (Good) — Generated 2026-03-04T19:24:36Z
**Score History:** 48 → 72 → 78 → 72 → 78 → **88** (6 runs, March 2-4)
**Overall Concurrence: 72%**
### Factor-by-Factor Analysis
#### Factor 1: "Projected Cash Flow" — Impact: Positive
> "12-month forecast shows consistent positive liquidity, with cash balances never dipping below the starting $27,419 and peaking at $142,788 in June."
| Check | Result |
|---|---|
| Budget surplus ($184K income vs $140K expense) | **Verified** ✅ |
| Assessments front-loaded Mar-Jun | **Verified** ✅ (budget shows $48K Mar, $64K Apr, $32K May, $16K Jun) |
| Peak of ~$142K in June | **Plausible** ✅ ($27K + cumulative income through June) |
| Cash never below starting $27K | **Plausible** ✅ (expenses < income by month) |
**Concurrence: 95%** Forecast logic is sound. The only risk is the assumption that assessments are collected on the exact budget schedule.
---
#### Factor 2: "Delinquency Rate" — Impact: Positive
> "$0.00 in overdue invoices and a 0.0% delinquency rate."
**Concurrence: 100%** Database confirms zero delinquent invoices.
---
#### Factor 3: "Budget Performance (Timing)" — Impact: Neutral
> "YTD income is 99.8% below budget ($55k variance) primarily due to the timing of the large Special Assessment ($20,700) and regular assessments appearing in future projected months."
| Check | Result |
|---|---|
| YTD income $88.16 | **Verified** |
| Budget includes March ($55K) in YTD calc | **Accurate** AI uses month 3 of 12, includes full March budget |
| Timing explanation | **Reasonable** we're only 4 days into March |
| Rating as "neutral" vs "negative" | **Appropriate** correctly avoids penalizing for calendar timing |
**Concurrence: 80%** The variance is accurately computed but presenting a $55K "variance" when we're 4 days into March could alarm a board member. The YTD window through month 3 includes all of March's budget despite only 4 days having elapsed. Consider computing YTD budget pro-rata or through the prior complete month.
**🔧 Tuning Suggestion:** Add a note to the prompt about pro-rating the current month's budget, or instruct the AI to note "X days into the current month" when the variance is driven by incomplete-month timing.
---
#### Factor 4: "Cash Reserves" — Impact: Positive
> "Current operating cash of $27,419 provides 2.4 months of runway based on the annual expense run rate."
| Check | Result |
|---|---|
| $27,419 / ($139,980 / 12) = 2.35 months | **Math verified** |
| Rated as "positive" | **Questionable** |
**Concurrence: 60%** The math is correct, but rating 2.4 months as "positive" contradicts the scoring guidelines which state 2-3 months = "Fair" (60-74) and 3-6 months = "Good" (75-89). This factor should be "neutral" at best, and the overall score should reflect that the HOA is *below* the "Good" threshold for cash reserves.
**🔧 Tuning Suggestion:** Add explicit guidance in the prompt: "If cash runway is below 3 months, this factor MUST be neutral or negative, regardless of projected future inflows."
---
#### Factor 5: "Expense Management" — Impact: Positive
> "YTD expenses are $36,313 under budget (4.8% of annual budget spent vs 25% of year elapsed)."
| Check | Result |
|---|---|
| YTD expenses $1,850.42 | **Verified** |
| Budget YTD (3 months): ~$38,164 | **Correct** |
| $1,850 / $38,164 = 4.85% | **Math verified** |
| "25% of year elapsed" | **Correct** (month 3 of 12) |
| Phrasing "of annual budget" | **Misleading** it's actually 4.8% of YTD budget, not annual |
**Concurrence: 70%** The percentage is correctly calculated against YTD budget, but the phrasing "of annual budget" is incorrect. Also, the low spend is not necessarily positive only January actuals exist; February hasn't been posted yet, which the AI partially acknowledges with "or delayed billing cycles."
---
### Recommendation Assessment
| # | Recommendation | Priority | Concurrence |
|---|---|---|---|
| 1 | "Verify the posting schedule for the $20,700 Special Assessment" | Low | **90%** Valid; assessments are annual, collection timing matters |
| 2 | "Investigate the low YTD expense recognition ($1,850 vs $38,164)" | Medium | **95%** Excellent catch; Feb expenses not posted yet |
| 3 | "Consider moving excess cash over $100K in Q2 to interest-bearing account" | Low | **85%** Sound advice; aligns with HY Savings at 4.09% |
**Recommendation Concurrence: 90%** All three recommendations are actionable and data-backed.
---
### Score Assessment
**Is 88 (Good) the right score?**
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|---|---|---|---|
| Cash reserves | 3-6 months for "Good" | 2.4 months | Below threshold |
| Income vs expenses | "Roughly matching" for Good | $184K vs $140K (surplus) | Exceeds |
| Delinquency | "Manageable" for Good | 0% | Excellent |
| Budget performance | No major overruns for Good | Under budget (timing) | Positive |
| Projected cash flow | Not explicitly in guidelines | Strong positive trajectory | Positive |
The cash runway of 2.4 months is below the stated "Good" (75-89) threshold of 3-6 months and technically falls in the "Fair" (60-74) range of 2-3 months. Earlier AI runs scored this 72-78, which better aligns with the guidelines. The 88 appears to overweight the projected future cash flow (which is speculative) vs the current actual position.
**Suggested correct score: 74-80** (high end of Fair to low end of Good)
---
### Score Consistency Concern
| Run Date | Score | Label |
|---|---|---|
| Mar 2 15:07 | 48 | Needs Attention |
| Mar 2 15:12 | 78 | Good |
| Mar 2 15:36 | 72 | Fair |
| Mar 2 17:09 | 78 | Good |
| Mar 3 02:03 | 72 | Fair |
| Mar 4 19:24 | 88 | Good |
A **40-point spread** (48-88) across 6 runs with essentially the same data is concerning. Even excluding the outlier first run (which noted a data config issue with "1 units"), the remaining 5 runs span 72-88 (16 points). At temperature 0.3, this suggests the model is not deterministic enough for financial scoring.
**🔧 Tuning Suggestion:** Consider lowering temperature to 0.1 for health score calculations to improve consistency. Alternatively, implement a moving average of the last 3 scores to smooth volatility.
---
## 2. Reserve Fund Health Score
**Latest Score:** 45 (Needs Attention) Generated 2026-03-04T19:24:50Z
**Score History:** 25 48 42 25 45 35 **45** (7 runs, March 2-4)
**Overall Concurrence: 85%**
### Factor-by-Factor Analysis
#### Factor 1: "Funded Ratio" — Impact: Negative
> "Calculated at 0% because no reserve components have been inventoried or assigned replacement costs, making it impossible to measure true funding health against the $152,016 in planned projects."
| Check | Result |
|---|---|
| 0 reserve components in DB | **Verified** |
| $152,016 in planned projects | **Verified** (sum of all `projects` rows) |
| 0% funded ratio | **Technically accurate** (no denominator from components) |
| Distinction between components and projects | **Well articulated** |
**Concurrence: 95%** The AI correctly identifies that the 0% is an artifact of missing reserve study data, not a literal lack of funds. It appropriately flags this as a governance failure.
---
#### Factor 2: "Projected Cash Flow" — Impact: Positive
> "Strong immediate liquidity; cash balance is projected to rise from $10,688 to over $49,000 by May 2026 due to special assessment income covering the $12,500 in urgent 2026 project costs."
| Check | Result |
|---|---|
| Starting reserve cash $10,688 | **Verified** |
| 2026 project costs: $7K (Mar) + $5.5K (May) = $12,500 | **Verified** |
| Special assessment: $300 × 67 = $20,100/year | **Verified** |
| CD maturities: $8K (Apr), $10K (Jun), $10K (Aug) | **Verified** |
| Projected rise to $49K by May | **Plausible** (income + maturities - project costs) |
**Concurrence: 85%** Math is directionally correct. However, the assessment is annual frequency so the full $20,100 may arrive in a single payment, not spread monthly. The timing assumption is critical.
---
#### Factor 3: "Component Tracking" — Impact: Negative
> "Critical failure in governance: 'No reserve components tracked' means the association is flying blind on the condition and remaining useful life of major assets like roads and irrigation."
**Concurrence: 100%** Database confirms 0 rows in `reserve_components`. This is objectively a critical gap.
---
#### Factor 4: "Annual Contributions" — Impact: Negative
> "Recurring annual reserve income is only $300 (plus minimal interest), which is grossly insufficient to fund the $80,000 road sealing project due in 2029."
| Check | Result |
|---|---|
| Reserve budget income: $1,449.96/yr (interest only) | **Verified** |
| Special assessment: $300/unit × 67 = $20,100/yr | **Verified** |
| "$300" cited as annual reserve income | **Incorrect** |
| Road Sealing $80K in June 2029 | **Verified** |
**Concurrence: 65%** The concern about insufficient contributions is valid, but the "$300" figure appears to confuse the per-unit special assessment amount ($300/unit) with the total annual reserve income. Actual annual reserve income = $1,450 (interest) + $20,100 (special assessments) = **$21,550/yr**. Even at $21,550/yr, the 3 years until Road Sealing would accumulate ~$64,650, still short of $80K. So the directional concern is correct, but the magnitude is significantly misstated.
**🔧 Tuning Suggestion:** The prompt should explicitly label the special assessment income total (not per-unit) in the data context. Currently the data says "$300.00/unit × 67 units (annual)" the AI should compute $20,100 but sometimes fixates on the $300 per-unit figure. Consider pre-computing and passing the total.
---
### Recommendation Assessment
| # | Recommendation | Priority | Concurrence |
|---|---|---|---|
| 1 | "Commission a professional Reserve Study to inventory assets and establish funded ratio" | High | **100%** Critical and universally correct |
| 2 | "Develop a long-term funding plan for the $80,000 Road Sealing project (2029)" | High | **90%** Verified project exists; $80K with 0% funded |
| 3 | "Formalize collection of special assessments into the reserve fund vs operating" | Medium | **95%** Budget shows special assessments in operating income section |
**Recommendation Concurrence: 95%** All recommendations are actionable, appropriately prioritized, and backed by database evidence.
---
### Score Assessment
**Is 45 (Needs Attention) the right score?**
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|---|---|---|---|
| Percent funded | 20-30% for "Needs Attention" | 0% (no components) | Worse than threshold |
| Contributions | "Inadequate" for Needs Attention | $21,550/yr for $152K in projects | Borderline |
| Component tracking | "Multiple urgent unfunded" | 0 tracked, 2 due in 2026 | Critical gap |
| Investments | Not scored negatively | 3 CDs earning 3.6-3.67% | Positive |
| Capital readiness | | $12.5K due soon, only $10.7K cash | Tight |
A score of 45 is reasonable. The 0% funded ratio technically suggests "At Risk" (20-39), but the presence of real assets ($38.7K), active investments, and manageable near-term liquidity justifies bumping it into the "Needs Attention" band. The AI's balancing of the artificial 0% metric against actual fund health shows good judgment.
**Suggested correct score: 40-50** the AI's 45 is well-calibrated.
---
### Score Consistency Concern
| Run Date | Score | Label |
|---|---|---|
| Mar 2 15:06 | 25 | At Risk |
| Mar 2 15:13 | 25 | At Risk |
| Mar 2 15:37 | 48 | Needs Attention |
| Mar 2 17:10 | 42 | Needs Attention |
| Mar 3 02:04 | 45 | Needs Attention |
| Mar 4 18:49 | 35 | At Risk |
| Mar 4 19:24 | 45 | Needs Attention |
A **23-point spread** (25-48) across 7 runs. The scores oscillate between "At Risk" and "Needs Attention" the model cannot consistently decide which band this falls into. The most recent 3 runs (35, 45, 45) are more stable.
**🔧 Tuning Suggestion:** Add boundary guidance to the prompt: "When the score falls within ±5 points of a threshold (40, 60, 75, 90), explicitly justify which side of the boundary the HOA falls on."
---
## 3. AI Investment Recommendations
**Latest Run:** 2026-03-04T19:28:22Z (3 runs saved)
**Overall Concurrence: 88%**
### Overall Assessment
> "The HOA has a healthy long-term cash flow outlook with significant surpluses projected by mid-2026, but faces an immediate liquidity pinch in the Reserve Fund for March/April capital projects. The current investment strategy relies on older, lower-yielding CDs (3.60-3.67%) that are maturing soon."
**Concurrence: 92%** Every claim verified:
- CDs are at 3.60-3.67% vs market 4.10% (verified)
- March project ($7K) vs reserve cash ($10.7K) is tight (verified)
- Long-term surplus projected from assessment income (verified from budget)
---
### Recommendation-by-Recommendation Analysis
#### Rec 1: "Critical Reserve Shortfall for March Project" — HIGH / Liquidity Warning
| Claim | Database Value | Match |
|---|---|---|
| Reserve cash = $10,688 | $10,688.45 | Exact |
| $7,000 Pond Spillway project due March | Projects table: $7,000, Mar 2026 | Exact |
| Shortfall risk | $10,688 - $7,000 = $3,688 remaining tight but feasible | |
| Suggested action: expedite special assessment or transfer from operating | Sound advice | |
**Concurrence: 90%** The liquidity concern is real. After paying the $7K project, only $3.7K would remain in reserve cash before the $5.5K May project. The AI correctly flags the timing risk even though the fund is technically solvent.
---
#### Rec 2: "Reinvest Maturing CD #2a at Higher Rate" — HIGH / Maturity Action
| Claim | Database Value | Match |
|---|---|---|
| CD #2a = $8,000 | $8,000.00 | Exact |
| Current rate = 3.60% | 3.60% | Exact |
| Maturity = April 14, 2026 | 2026-04-14 | Exact |
| Market rate = 4.10% (E*TRADE) | CD rates: E*TRADE 4.10%, 1 year, $0 min | Exact |
| Additional yield: ~$40/year per $8K | $8K × 0.50% = $40 | Math correct |
**Concurrence: 95%** Textbook-correct recommendation. Every data point verified. The 50 bps improvement is risk-free income.
---
#### Rec 3: "Establish 12-Month CD Ladder for Reserves" — MEDIUM / CD Ladder
| Claim | Database Value | Match |
|---|---|---|
| ~$38K total reserve portfolio | $38,688.45 | Exact |
| Suggest 4-rung ladder (3/6/9/12 mo) | Standard strategy | |
| Rates up to 4.10% | Market data confirmed | |
| $9K matures every quarter | $38K / 4 = $9.5K per rung | Approximate |
**Concurrence: 75%** Strategy is sound in principle, but the recommendation overlooks two constraints:
1. **Immediate project costs ($12.5K in 2026)** must be reserved first, leaving ~$26K for laddering
2. **Investing the entire $38K** is aggressive some cash buffer should remain liquid
**🔧 Tuning Suggestion:** Add a constraint to the prompt: "When recommending CD ladders, always subtract upcoming project costs (next 12 months) and a minimum emergency reserve (1 month of budgeted reserve expenses) before calculating the investable amount."
---
#### Rec 4: "Deploy Excess Operating Cash to High-Yield Savings" — MEDIUM / New Investment
| Claim | Database Value | Match |
|---|---|---|
| Operating cash = $27,418 | $27,418.81 | Exact |
| 3-month buffer = ~$35,000 | $11,665 × 3 = $34,995 | Math correct |
| Current cash below buffer | $27.4K < $35K | Correctly identified |
| Openbank 4.09% APY | Market data: Openbank 4.09%, $0.01 min | Exact |
| Trigger: "As soon as balance exceeds $35K" | Sound deferred recommendation | |
**Concurrence: 90%** The AI correctly identifies the current shortfall and provides a forward-looking trigger. Well-structured advice that respects the liquidity constraint.
---
#### Rec 5: "Optimize Reserve Cash Yield Post-Project" — LOW / Reallocation
| Claim | Database Value | Match |
|---|---|---|
| Vio Bank Money Market at 4.03% | Market data: Vio Bank 4.03%, $0 min | Exact |
| Post-project reserve cash deployment | Appropriate timing | |
| T+1 liquidity for emergencies | Correct MM account characteristic | |
**Concurrence: 85%** Reasonable low-priority optimization. Correctly uses market data.
---
#### Rec 6: "Formalize Special Assessment Collection for Reserves" — LOW / General
| Claim | Database Value | Match |
|---|---|---|
| $300/unit special assessment | Assessment groups: $300.00 special | Exact |
| Risk of commingling with operating | Budget shows special assessments in operating income | Identified |
**Concurrence: 90%** Important governance recommendation. The budget structure does show special assessments as operating income, which could lead to improper fund commingling.
---
### Risk Notes Assessment
| Risk Note | Verified | Concurrence |
|---|---|---|
| "Reserve cash ($10.6K) barely sufficient for $7K + $5.5K projects" | $10,688 vs $12,500 in projects | **95%** |
| "Concentration risk: CDs maturing in 4-month window (Apr-Aug)" | All 3 CDs mature Apr-Aug 2026 | **100%** |
| "Operating cash ballooning to $140K+ without investment plan" | Budget shows large Q2 surplus | **85%** |
| "Road Sealing $80K in 2029 needs dedicated savings plan" | Project exists, 0% funded | **95%** |
**Risk Notes Concurrence: 94%** All risk items are data-backed and appropriately flagged.
---
### Cross-Run Consistency (Investment Recommendations)
Three runs were compared. Key observations:
- **Core recommendations are highly consistent** across runs: CD reinvestment, HY savings for operating, CD ladder for reserves
- **Dollar amounts match exactly** across all runs (same data inputs)
- **Bank name recommendations vary slightly** (E*TRADE vs "Top CD Rate") cosmetic, not substantive
- **Priority levels are stable** (HIGH for liquidity warnings, MEDIUM for optimization)
**Consistency Grade: A-** Investment recommendations show much better consistency than health scores, likely because the structured data (specific CDs, specific rates) constrains the output more than the subjective health scoring.
---
## Cross-Cutting Issues
### Issue 1: Score Volatility (MEDIUM Priority)
Health scores vary significantly across runs despite identical input data:
- Operating: 40-point spread (48-88)
- Reserve: 23-point spread (25-48)
**Root Cause:** Temperature 0.3 allows too much variance for numerical scoring. The model interprets guidelines subjectively.
**Recommended Fix:**
1. Reduce temperature to **0.1** for health score calculations
2. Implement a **3-run moving average** to smooth individual run variance
3. Add explicit **boundary justification** requirements to prompts
### Issue 2: YTD Budget Calculation Includes Incomplete Month (LOW Priority)
The operating health score computes YTD budget through the current month (March), but actual data may only cover a few days. This creates alarming income variances (e.g., "$55K variance") that are pure timing artifacts.
**Recommended Fix:**
- Compute YTD budget through the **prior completed month** (February)
- OR pro-rate the current month's budget by days elapsed
- Add a note to the prompt: "If the variance is driven by the current incomplete month, flag it as 'timing' and weight it minimally."
### Issue 3: Per-Unit vs Total Confusion on Special Assessments (LOW Priority)
The AI sometimes quotes "$300" as the annual reserve income instead of $300 × 67 = $20,100. The data passed says "$300.00/unit × 67 units (annual)" but the model occasionally fixates on the per-unit figure.
**Recommended Fix:**
- Pre-compute and include the total in the data: "Total Annual Special Assessment Income: $20,100.00"
- Keep the per-unit breakdown for context but lead with the total
### Issue 4: Cash Runway Classification Inconsistency (MEDIUM Priority)
The operating health score rates 2.4 months of cash runway as "positive" despite the scoring guidelines defining 2-3 months as "Fair" territory. This inflates the overall score.
**Recommended Fix:**
- Add explicit prompt guidance: "Cash runway categorization: <2 months = negative, 2-3 months = neutral, 3-6 months = positive, 6+ months = strongly positive. Do NOT rate below-threshold runway as positive based on projected future inflows."
### Issue 5: Dual Project Tables (INFORMATIONAL)
The schema contains both `capital_projects` (empty) and `projects` (26 rows). The health score service correctly queries `projects`, but auditors initially checked `capital_projects` and found no data. This dual-table pattern could confuse future developers.
**Recommended Fix:**
- Consolidate into a single table, OR
- Add a comment/documentation clarifying the canonical source
---
## Concurrence Summary by Recommendation
### Operating Fund Health — Recommendations
| Recommendation | Concurrence |
|---|---|
| Verify posting schedule for $20,700 Special Assessment | 90% |
| Investigate low YTD expense recognition | 95% |
| Move excess cash to interest-bearing account | 85% |
| **Average** | **90%** |
### Reserve Fund Health — Recommendations
| Recommendation | Concurrence |
|---|---|
| Commission professional Reserve Study | 100% |
| Develop funding plan for $80K Road Sealing | 90% |
| Formalize special assessment collection for reserves | 95% |
| **Average** | **95%** |
### Investment Planning — Recommendations
| Recommendation | Concurrence |
|---|---|
| Critical Reserve Shortfall for March Project | 90% |
| Reinvest Maturing CD #2a at Higher Rate | 95% |
| Establish 12-Month CD Ladder | 75% |
| Deploy Operating Cash to HY Savings | 90% |
| Optimize Reserve Cash Post-Project | 85% |
| Formalize Special Assessment Collection | 90% |
| **Average** | **88%** |
---
## Final Grades
| Feature | Score Accuracy | Recommendation Quality | Data Fidelity | Consistency | **Overall** |
|---|---|---|---|---|---|
| Operating Fund Health | C+ (score ~15 pts high) | A (90%) | B+ (minor math phrasing) | C (16-pt spread) | **72% — B-** |
| Reserve Fund Health | A- (well-calibrated) | A (95%) | B (per-unit confusion) | B- (23-pt spread) | **85% — B+** |
| Investment Recommendations | N/A (no single score) | A (88%) | A (exact data matches) | A- (stable across runs) | **88% — A-** |
---
## Priority Action Items for Tuning
1. **[HIGH]** Reduce AI temperature from 0.3 0.1 for health score calculations to reduce score volatility
2. **[MEDIUM]** Add explicit cash-runway-to-impact mapping in operating prompt to prevent misclassification
3. **[MEDIUM]** Pre-compute total special assessment income in data context (not just per-unit)
4. **[LOW]** Adjust YTD budget calculation to use prior completed month or pro-rate current month
5. **[LOW]** Add boundary justification requirement to scoring prompts
6. **[LOW]** Consider implementing 3-run moving average for displayed health scores
---
*Generated by Claude Opus 4.6 — Automated AI Feature Audit*

22
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# ---- Production Dockerfile for React frontend ----
# Multi-stage build: compile to static assets, serve with nginx
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
# Copy the built static files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy a small nginx config for SPA routing
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3001
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -9,5 +9,34 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script>
(function(d,t) {
var BASE_URL="https://chat.hoaledgeriq.com";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async=true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken:'K6VXvTtKXvaCMvre4yK85SPb',
baseUrl:BASE_URL
})
}
})(document,"script");
window.addEventListener('chatwoot:ready', function() {
try {
var raw = localStorage.getItem('ledgeriq-auth');
if (!raw) return;
var auth = JSON.parse(raw);
var user = auth && auth.state && auth.state.user;
if (user && window.$chatwoot) {
window.$chatwoot.setUser(user.id, {
name: (user.firstName || '') + ' ' + (user.lastName || ''),
email: user.email
});
}
} catch (e) {}
});
</script>
</body> </body>
</html> </html>

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
# Minimal nginx config for serving the React SPA inside the frontend container.
# The outer nginx reverse proxy forwards non-API requests here.
server {
listen 3001;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Serve static assets with long cache (Vite hashes filenames)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — any non-file route returns index.html
location / {
try_files $uri $uri/ /index.html;
}
}

3324
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "0.2.0", "version": "2026.03.16",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,31 +11,32 @@
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^7.15.3", "@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/form": "^7.15.3",
"@mantine/dates": "^7.15.3", "@mantine/dates": "^7.15.3",
"@mantine/notifications": "^7.15.3", "@mantine/form": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3", "@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@tabler/icons-react": "^3.28.1", "@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",
"d3-sankey": "^0.12.3",
"dayjs": "^1.11.13",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-joyride": "^2.9.3",
"react-router-dom": "^6.28.2", "react-router-dom": "^6.28.2",
"recharts": "^2.15.0", "recharts": "^2.15.0",
"d3-sankey": "^0.12.3", "zustand": "^4.5.5"
"zustand": "^4.5.5",
"axios": "^1.7.9",
"@tanstack/react-query": "^5.64.2",
"dayjs": "^1.11.13"
}, },
"devDependencies": { "devDependencies": {
"@types/d3-sankey": "^0.12.4",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/d3-sankey": "^0.12.4",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^5.4.14",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1" "postcss-simple-vars": "^7.0.1",
"typescript": "^5.7.3",
"vite": "^5.4.14"
} }
} }

View File

@@ -22,6 +22,7 @@ import { SankeyPage } from './pages/reports/SankeyPage';
import { CashFlowPage } from './pages/reports/CashFlowPage'; import { CashFlowPage } from './pages/reports/CashFlowPage';
import { AgingReportPage } from './pages/reports/AgingReportPage'; import { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage'; import { YearEndPage } from './pages/reports/YearEndPage';
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
import { SettingsPage } from './pages/settings/SettingsPage'; import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
@@ -30,6 +31,12 @@ import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroups
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage'; import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage'; import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
import { InvestmentScenariosPage } from './pages/board-planning/InvestmentScenariosPage';
import { InvestmentScenarioDetailPage } from './pages/board-planning/InvestmentScenarioDetailPage';
import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenariosPage';
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token); const token = useAuthStore((s) => s.token);
@@ -135,6 +142,13 @@ export function App() {
<Route path="reports/aging" element={<AgingReportPage />} /> <Route path="reports/aging" element={<AgingReportPage />} />
<Route path="reports/sankey" element={<SankeyPage />} /> <Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} /> <Route path="reports/year-end" element={<YearEndPage />} />
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
<Route path="board-planning/assessments" element={<AssessmentScenariosPage />} />
<Route path="board-planning/assessments/:id" element={<AssessmentScenarioDetailPage />} />
<Route path="board-planning/compare" element={<ScenarioComparisonPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="preferences" element={<UserPreferencesPage />} /> <Route path="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} /> <Route path="org-members" element={<OrgMembersPage />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -1,4 +1,5 @@
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core'; import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
IconLogout, IconLogout,
@@ -8,18 +9,58 @@ import {
IconUserCog, IconUserCog,
IconUsersGroup, IconUsersGroup,
IconEyeOff, IconEyeOff,
IconSun,
IconMoon,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import logoSrc from '../../assets/logo.svg'; import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import logoSrc from '../../assets/logo.png';
export function AppLayout() { export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure(); const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore(); const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const isImpersonating = !!impersonationOriginal; const isImpersonating = !!impersonationOriginal;
// ── Onboarding State ──
const [showTour, setShowTour] = useState(false);
const [showWizard, setShowWizard] = useState(false);
useEffect(() => {
// Only run for non-impersonating users with an org selected, on dashboard
if (isImpersonating || !currentOrg || !user) return;
if (!location.pathname.startsWith('/dashboard')) return;
// Read-only users (viewers) skip onboarding entirely
if (currentOrg.role === 'viewer') return;
if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) {
// Delay to ensure DOM elements are rendered for tour targeting
const timer = setTimeout(() => setShowTour(true), 800);
return () => clearTimeout(timer);
} else if (currentOrg.settings?.onboardingComplete !== true) {
setShowWizard(true);
}
}, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.role, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]);
const handleTourComplete = () => {
setShowTour(false);
// After tour, check if onboarding wizard should run
if (currentOrg && currentOrg.settings?.onboardingComplete !== true) {
// Small delay before showing wizard
setTimeout(() => setShowWizard(true), 500);
}
};
const handleWizardComplete = () => {
setShowWizard(false);
};
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
navigate('/login'); navigate('/login');
@@ -65,12 +106,31 @@ export function AppLayout() {
<Group h={60} px="md" justify="space-between"> <Group h={60} px="md" justify="space-between">
<Group> <Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" /> <Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} /> <img
src={logoSrc}
alt="HOA LedgerIQ"
style={{
height: 40,
...(colorScheme === 'dark' ? {
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
} : {}),
}}
/>
</Group> </Group>
<Group> <Group>
{currentOrg && ( {currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text> <Text size="sm" c="dimmed">{currentOrg.name}</Text>
)} )}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
onClick={toggleColorScheme}
aria-label="Toggle color scheme"
>
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={220}> <Menu shadow="md" width={220}>
<Menu.Target> <Menu.Target>
<UnstyledButton> <UnstyledButton>
@@ -145,6 +205,10 @@ export function AppLayout() {
<AppShell.Main> <AppShell.Main>
<Outlet /> <Outlet />
</AppShell.Main> </AppShell.Main>
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
</AppShell> </AppShell>
); );
} }

View File

@@ -18,6 +18,9 @@ import {
IconClipboardCheck, IconClipboardCheck,
IconSparkles, IconSparkles,
IconHeartRateMonitor, IconHeartRateMonitor,
IconCalculator,
IconGitCompare,
IconScale,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -30,33 +33,52 @@ const navSections = [
{ {
label: 'Financials', label: 'Financials',
items: [ items: [
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' }, { label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' }, { label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' }, { label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' }, { label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
], ],
}, },
{ {
label: 'Assessments', label: 'Assessments',
items: [ items: [
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' }, { label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' }, { label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
], ],
}, },
{ {
label: 'Transactions', label: 'Transactions',
items: [ items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' }, { label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' }, { label: 'Payments', icon: IconCash, path: '/payments' },
], ],
}, },
{ {
label: 'Planning', label: 'Board Planning',
items: [
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
{
label: 'Projects', icon: IconShieldCheck, path: '/projects',
children: [
{ label: 'Capital Planning', path: '/capital-projects' },
],
},
{
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
},
{
label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning',
children: [
{ label: 'Investment Scenarios', path: '/board-planning/investments' },
],
},
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
],
},
{
label: 'Board Reference',
items: [ items: [
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' }, { label: 'Vendors', icon: IconUsers, path: '/vendors' },
], ],
}, },
@@ -66,6 +88,7 @@ const navSections = [
{ {
label: 'Reports', label: 'Reports',
icon: IconChartSankey, icon: IconChartSankey,
tourId: 'nav-reports',
children: [ children: [
{ label: 'Balance Sheet', path: '/reports/balance-sheet' }, { label: 'Balance Sheet', path: '/reports/balance-sheet' },
{ label: 'Income Statement', path: '/reports/income-statement' }, { label: 'Income Statement', path: '/reports/income-statement' },
@@ -74,6 +97,7 @@ const navSections = [
{ label: 'Aging Report', path: '/reports/aging' }, { label: 'Aging Report', path: '/reports/aging' },
{ label: 'Sankey Diagram', path: '/reports/sankey' }, { label: 'Sankey Diagram', path: '/reports/sankey' },
{ label: 'Year-End', path: '/reports/year-end' }, { label: 'Year-End', path: '/reports/year-end' },
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
], ],
}, },
], ],
@@ -127,7 +151,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
} }
return ( return (
<ScrollArea p="sm"> <ScrollArea p="sm" data-tour="sidebar-nav">
{navSections.map((section, sIdx) => ( {navSections.map((section, sIdx) => (
<div key={sIdx}> <div key={sIdx}>
{section.label && ( {section.label && (
@@ -139,7 +163,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
</> </>
)} )}
{section.items.map((item: any) => {section.items.map((item: any) =>
item.children ? ( item.children && !item.path ? (
// Collapsible group without a parent route (e.g. Reports)
<NavLink <NavLink
key={item.label} key={item.label}
label={item.label} label={item.label}
@@ -147,6 +172,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
defaultOpened={item.children.some((c: any) => defaultOpened={item.children.some((c: any) =>
location.pathname.startsWith(c.path), location.pathname.startsWith(c.path),
)} )}
data-tour={item.tourId || undefined}
> >
{item.children.map((child: any) => ( {item.children.map((child: any) => (
<NavLink <NavLink
@@ -157,6 +183,29 @@ export function Sidebar({ onNavigate }: SidebarProps) {
/> />
))} ))}
</NavLink> </NavLink>
) : item.children && item.path ? (
// Parent with its own route + nested children (e.g. Projects > Capital Planning)
<NavLink
key={item.path}
label={item.label}
leftSection={<item.icon size={18} />}
defaultOpened={
location.pathname === item.path ||
item.children.some((c: any) => location.pathname.startsWith(c.path))
}
data-tour={item.tourId || undefined}
active={location.pathname === item.path}
onClick={() => go(item.path!)}
>
{item.children.map((child: any) => (
<NavLink
key={child.path}
label={child.label}
active={location.pathname === child.path}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); go(child.path); }}
/>
))}
</NavLink>
) : ( ) : (
<NavLink <NavLink
key={item.path} key={item.path}
@@ -164,6 +213,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
leftSection={<item.icon size={18} />} leftSection={<item.icon size={18} />}
active={location.pathname === item.path} active={location.pathname === item.path}
onClick={() => go(item.path!)} onClick={() => go(item.path!)}
data-tour={item.tourId || undefined}
/> />
), ),
)} )}

View File

@@ -0,0 +1,93 @@
import { useState, useCallback } from 'react';
import Joyride, { type CallBackProps, STATUS, ACTIONS, EVENTS } from 'react-joyride';
import { TOUR_STEPS } from '../../config/tourSteps';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
interface AppTourProps {
run: boolean;
onComplete: () => void;
}
export function AppTour({ run, onComplete }: AppTourProps) {
const [stepIndex, setStepIndex] = useState(0);
const setUserIntroSeen = useAuthStore((s) => s.setUserIntroSeen);
const handleCallback = useCallback(
async (data: CallBackProps) => {
const { status, action, type } = data;
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
if (finishedStatuses.includes(status)) {
// Mark intro as seen on backend (fire-and-forget)
api.patch('/auth/intro-seen').catch(() => {});
setUserIntroSeen();
onComplete();
return;
}
// Handle step navigation
if (type === EVENTS.STEP_AFTER) {
setStepIndex((prev) =>
action === ACTIONS.PREV ? prev - 1 : prev + 1,
);
}
},
[onComplete, setUserIntroSeen],
);
if (!run) return null;
return (
<Joyride
steps={TOUR_STEPS}
run={run}
stepIndex={stepIndex}
continuous
showProgress
showSkipButton
scrollToFirstStep
disableOverlayClose
callback={handleCallback}
styles={{
options: {
primaryColor: '#228be6',
zIndex: 10000,
arrowColor: '#fff',
backgroundColor: '#fff',
textColor: '#333',
overlayColor: 'rgba(0, 0, 0, 0.5)',
},
tooltip: {
borderRadius: 8,
fontSize: 14,
padding: 20,
},
tooltipTitle: {
fontSize: 16,
fontWeight: 600,
},
buttonNext: {
borderRadius: 6,
fontSize: 14,
padding: '8px 16px',
},
buttonBack: {
borderRadius: 6,
fontSize: 14,
marginRight: 8,
},
buttonSkip: {
fontSize: 13,
},
}}
locale={{
back: 'Previous',
close: 'Close',
last: 'Finish Tour',
next: 'Next',
skip: 'Skip Tour',
}}
/>
);
}

View File

@@ -0,0 +1,646 @@
import { useState } from 'react';
import {
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconBuildingBank, IconUsers, IconFileSpreadsheet,
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
IconAlertCircle,
} from '@tabler/icons-react';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
interface OnboardingWizardProps {
opened: boolean;
onComplete: () => void;
}
interface UnitRow {
unitNumber: string;
ownerName: string;
ownerEmail: string;
}
// ── CSV Parsing (reused from BudgetsPage pattern) ──
function parseCSV(text: string): Record<string, string>[] {
const lines = text.split('\n').filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
return lines.slice(1).map((line) => {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes; }
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += char; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, i) => { row[h] = values[i] || ''; });
return row;
});
}
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
// ── Step 1: Account State ──
const [accountCreated, setAccountCreated] = useState(false);
const [accountName, setAccountName] = useState('Operating Checking');
const [accountNumber, setAccountNumber] = useState('1000');
const [accountDescription, setAccountDescription] = useState('');
const [initialBalance, setInitialBalance] = useState<number | string>(0);
// ── Step 2: Assessment Group State ──
const [groupCreated, setGroupCreated] = useState(false);
const [groupName, setGroupName] = useState('Standard Assessment');
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
const [frequency, setFrequency] = useState('monthly');
const [units, setUnits] = useState<UnitRow[]>([]);
const [unitsCreated, setUnitsCreated] = useState(false);
// ── Step 3: Budget State ──
const [budgetFile, setBudgetFile] = useState<File | null>(null);
const [budgetUploaded, setBudgetUploaded] = useState(false);
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
const currentYear = new Date().getFullYear();
// ── Step 1: Create Account ──
const handleCreateAccount = async () => {
if (!accountName.trim()) {
setError('Account name is required');
return;
}
if (!accountNumber.trim()) {
setError('Account number is required');
return;
}
const balance = typeof initialBalance === 'string' ? parseFloat(initialBalance) : initialBalance;
if (isNaN(balance)) {
setError('Initial balance must be a valid number');
return;
}
setLoading(true);
setError(null);
try {
await api.post('/accounts', {
accountNumber: accountNumber.trim(),
name: accountName.trim(),
description: accountDescription.trim(),
accountType: 'asset',
fundType: 'operating',
initialBalance: balance,
});
setAccountCreated(true);
notifications.show({
title: 'Account Created',
message: `${accountName} has been created with an initial balance of $${balance.toLocaleString()}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create account';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Step 2: Create Assessment Group ──
const handleCreateGroup = async () => {
if (!groupName.trim()) {
setError('Group name is required');
return;
}
const assessment = typeof regularAssessment === 'string' ? parseFloat(regularAssessment) : regularAssessment;
if (isNaN(assessment) || assessment <= 0) {
setError('Assessment amount must be greater than zero');
return;
}
setLoading(true);
setError(null);
try {
const { data: group } = await api.post('/assessment-groups', {
name: groupName.trim(),
regularAssessment: assessment,
frequency,
isDefault: true,
});
setGroupCreated(true);
// Create units if any were added
if (units.length > 0) {
let created = 0;
for (const unit of units) {
if (!unit.unitNumber.trim()) continue;
try {
await api.post('/units', {
unitNumber: unit.unitNumber.trim(),
ownerName: unit.ownerName.trim() || null,
ownerEmail: unit.ownerEmail.trim() || null,
assessmentGroupId: group.id,
});
created++;
} catch {
// Continue even if a unit fails
}
}
setUnitsCreated(true);
notifications.show({
title: 'Assessment Group Created',
message: `${groupName} created with ${created} unit(s)`,
color: 'green',
});
} else {
notifications.show({
title: 'Assessment Group Created',
message: `${groupName} created successfully`,
color: 'green',
});
}
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create assessment group';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Step 3: Budget Import ──
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${currentYear}/template`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `budget_template_${currentYear}.csv`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch {
notifications.show({
title: 'Error',
message: 'Failed to download template',
color: 'red',
});
}
};
const handleUploadBudget = async () => {
if (!budgetFile) {
setError('Please select a CSV file');
return;
}
setLoading(true);
setError(null);
try {
const text = await budgetFile.text();
const rows = parseCSV(text);
if (rows.length === 0) {
setError('CSV file appears to be empty or invalid');
setLoading(false);
return;
}
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
setBudgetUploaded(true);
setBudgetImportResult(data);
notifications.show({
title: 'Budget Imported',
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to import budget';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Finish Wizard ──
const handleFinish = async () => {
setLoading(true);
try {
await api.patch('/organizations/settings', { onboardingComplete: true });
setOrgSettings({ onboardingComplete: true });
onComplete();
} catch {
// Even if API fails, close the wizard — onboarding data is already created
onComplete();
} finally {
setLoading(false);
}
};
// ── Unit Rows ──
const addUnit = () => {
setUnits([...units, { unitNumber: '', ownerName: '', ownerEmail: '' }]);
};
const updateUnit = (index: number, field: keyof UnitRow, value: string) => {
const updated = [...units];
updated[index] = { ...updated[index], [field]: value };
setUnits(updated);
};
const removeUnit = (index: number) => {
setUnits(units.filter((_, i) => i !== index));
};
// ── Navigation ──
const canGoNext = () => {
if (active === 0) return accountCreated;
if (active === 1) return groupCreated;
if (active === 2) return true; // Budget is optional
return false;
};
const nextStep = () => {
setError(null);
if (active < 3) setActive(active + 1);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing without completing
withCloseButton={false}
size="xl"
centered
overlayProps={{ opacity: 0.6, blur: 3 }}
styles={{
body: { padding: 0 },
}}
>
{/* Header */}
<Box px="xl" pt="xl" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
<Group>
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
<IconRocket size={24} />
</ThemeIcon>
<div>
<Title order={3}>Set Up Your Organization</Title>
<Text c="dimmed" size="sm">
Let&apos;s get the essentials configured so you can start managing your HOA finances.
</Text>
</div>
</Group>
</Box>
<Box px="xl" py="lg">
<Stepper active={active} size="sm" mb="xl">
<Stepper.Step
label="Operating Account"
description="Set up your primary bank account"
icon={<IconBuildingBank size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Assessment Group"
description="Define homeowner assessments"
icon={<IconUsers size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Budget"
description="Import your annual budget"
icon={<IconFileSpreadsheet size={18} />}
completedIcon={<IconCheck size={18} />}
/>
</Stepper>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md" withCloseButton onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* ── Step 1: Create Operating Account ── */}
{active === 0 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create Your Primary Operating Account</Text>
<Text size="sm" c="dimmed" mb="md">
This is your HOA&apos;s main bank account for day-to-day operations. You can add more accounts later.
</Text>
{accountCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{accountName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={2} mb="md">
<TextInput
label="Account Name"
placeholder="e.g. Operating Checking"
value={accountName}
onChange={(e) => setAccountName(e.currentTarget.value)}
required
/>
<TextInput
label="Account Number"
placeholder="e.g. 1000"
value={accountNumber}
onChange={(e) => setAccountNumber(e.currentTarget.value)}
required
/>
</SimpleGrid>
<Textarea
label="Description"
placeholder="Optional description"
value={accountDescription}
onChange={(e) => setAccountDescription(e.currentTarget.value)}
mb="md"
autosize
minRows={2}
/>
<NumberInput
label="Current Balance"
description="Enter the current balance of this bank account"
placeholder="0.00"
value={initialBalance}
onChange={setInitialBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
mb="md"
/>
<Button
onClick={handleCreateAccount}
loading={loading}
leftSection={<IconBuildingBank size={16} />}
>
Create Account
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 2: Assessment Group + Units ── */}
{active === 1 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create an Assessment Group</Text>
<Text size="sm" c="dimmed" mb="md">
Assessment groups define how much each homeowner pays and how often. You can create additional groups later for different unit types.
</Text>
{groupCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{groupName} created successfully!</Text>
<Text size="sm" c="dimmed">
${(typeof regularAssessment === 'number' ? regularAssessment : parseFloat(regularAssessment as string) || 0).toLocaleString()} {frequency}
{unitsCreated && ` with ${units.length} unit(s)`}
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={3} mb="md">
<TextInput
label="Group Name"
placeholder="e.g. Standard Assessment"
value={groupName}
onChange={(e) => setGroupName(e.currentTarget.value)}
required
/>
<NumberInput
label="Assessment Amount"
placeholder="0.00"
value={regularAssessment}
onChange={setRegularAssessment}
thousandSeparator=","
prefix="$"
decimalScale={2}
required
/>
<Select
label="Frequency"
value={frequency}
onChange={(v) => setFrequency(v || 'monthly')}
data={[
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' },
]}
/>
</SimpleGrid>
<Divider my="md" label="Add Homeowner Units (Optional)" labelPosition="center" />
{units.length > 0 && (
<Table mb="md" striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Unit Number</Table.Th>
<Table.Th>Owner Name</Table.Th>
<Table.Th>Owner Email</Table.Th>
<Table.Th w={40}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((unit, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<TextInput
size="xs"
placeholder="e.g. 101"
value={unit.unitNumber}
onChange={(e) => updateUnit(idx, 'unitNumber', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="John Smith"
value={unit.ownerName}
onChange={(e) => updateUnit(idx, 'ownerName', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="john@example.com"
value={unit.ownerEmail}
onChange={(e) => updateUnit(idx, 'ownerEmail', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<ActionIcon color="red" variant="subtle" size="sm" onClick={() => removeUnit(idx)}>
<IconTrash size={14} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group mb="md">
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={addUnit}
>
Add Unit
</Button>
<Text size="xs" c="dimmed">You can also import units in bulk later from the Units page.</Text>
</Group>
<Button
onClick={handleCreateGroup}
loading={loading}
leftSection={<IconUsers size={16} />}
>
Create Assessment Group
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 3: Budget Upload ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
<Text size="sm" c="dimmed" mb="md">
Upload a CSV file with your annual budget. If you don&apos;t have one ready, you can download a template
or skip this step and set it up later from the Budgets page.
</Text>
{budgetUploaded ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>Budget imported successfully!</Text>
{budgetImportResult && (
<Text size="sm" c="dimmed">
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
</Text>
)}
</Alert>
) : (
<>
<Group mb="md">
<Button
variant="light"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download CSV Template
</Button>
</Group>
<FileInput
label="Upload Budget CSV"
placeholder="Click to select a .csv file"
accept=".csv"
value={budgetFile}
onChange={setBudgetFile}
mb="md"
leftSection={<IconFileSpreadsheet size={16} />}
/>
<Button
onClick={handleUploadBudget}
loading={loading}
leftSection={<IconFileSpreadsheet size={16} />}
disabled={!budgetFile}
>
Import Budget
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Completion Screen ── */}
{active === 3 && (
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
<IconCheck size={32} />
</ThemeIcon>
<Title order={3} mb="xs">You&apos;re All Set!</Title>
<Text c="dimmed" mb="lg" maw={400} mx="auto">
Your organization is configured and ready to go. You can always update your accounts,
assessment groups, and budgets from the sidebar navigation.
</Text>
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconBuildingBank size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Account</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconUsers size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Assessments</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} />
</ThemeIcon>
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
{budgetUploaded ? 'Done' : 'Skipped'}
</Badge>
<Text size="xs" mt={4}>Budget</Text>
</Card>
</SimpleGrid>
<Button
size="lg"
onClick={handleFinish}
loading={loading}
leftSection={<IconRocket size={18} />}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Start Using LedgerIQ
</Button>
</Card>
)}
{/* ── Navigation Buttons ── */}
{active < 3 && (
<Group justify="flex-end" mt="xl">
{active === 2 && !budgetUploaded && (
<Button variant="subtle" onClick={nextStep}>
Skip for now
</Button>
)}
<Button
onClick={nextStep}
disabled={!canGoNext()}
>
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
</Button>
</Group>
)}
</Box>
</Modal>
);
}

View File

@@ -0,0 +1,68 @@
/**
* How-To Intro Tour Steps
*
* Centralized configuration for the react-joyride walkthrough.
* Edit the title and content fields below to change tour text.
* Steps are ordered to mirror the natural workflow of the platform.
*/
import type { Step } from 'react-joyride';
export const TOUR_STEPS: Step[] = [
{
target: '[data-tour="dashboard-content"]',
title: 'Your Financial Dashboard',
content:
'Welcome to LedgerIQ! This dashboard gives you an at-a-glance view of your HOA\'s financial health — operating funds, reserve funds, receivables, delinquencies, and recent transactions. It updates automatically as you record activity.',
placement: 'center',
disableBeacon: true,
},
{
target: '[data-tour="sidebar-nav"]',
title: 'Navigation',
content:
'The sidebar organizes all your tools into five sections: Financials, Assessments, Transactions, Planning, and Reports. Click any item to navigate directly to that module.',
placement: 'right',
},
{
target: '[data-tour="nav-accounts"]',
title: 'Chart of Accounts',
content:
'Manage your Chart of Accounts here. Set up operating and reserve fund bank accounts, track balances, record opening balances, and manage your investment accounts — all separated by fund type.',
placement: 'right',
},
{
target: '[data-tour="nav-assessment-groups"]',
title: 'Assessments & Homeowners',
content:
'Create assessment groups to define your monthly, quarterly, or annual HOA dues. Add homeowner units, assign them to groups, and generate invoices automatically based on your assessment schedule.',
placement: 'right',
},
{
target: '[data-tour="nav-transactions"]',
title: 'Transactions & Journal Entries',
content:
'Record all financial activity here through double-entry journal entries. The system also automatically creates entries when you record payments, generate invoices, or set opening balances.',
placement: 'right',
},
{
target: '[data-tour="nav-budgets"]',
title: 'Budget Management',
content:
'Create and manage annual budgets for every income and expense account. You can enter amounts manually by month or import your budget from a CSV file for quick setup.',
placement: 'right',
},
{
target: '[data-tour="nav-reports"]',
title: 'Financial Reports',
content:
'Generate comprehensive reports including Balance Sheet, Income Statement, Cash Flow Statement, Budget vs Actual, Aging Report, and more. All reports are generated in real-time from your journal data.',
placement: 'right',
},
{
target: '[data-tour="nav-investment-planning"]',
title: 'AI Investment Planning',
content:
'Use AI-powered recommendations to optimize your reserve fund investments. The system analyzes current market rates for CDs, money market accounts, and high-yield savings to suggest the best allocation strategy.',
placement: 'right',
},
];

View File

@@ -10,6 +10,7 @@ import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import { App } from './App'; import { App } from './App';
import { theme } from './theme/theme'; import { theme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -21,9 +22,11 @@ const queryClient = new QueryClient({
}, },
}); });
ReactDOM.createRoot(document.getElementById('root')!).render( function Root() {
<React.StrictMode> const colorScheme = usePreferencesStore((s) => s.colorScheme);
<MantineProvider theme={theme}>
return (
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
<Notifications position="top-right" /> <Notifications position="top-right" />
<ModalsProvider> <ModalsProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -33,5 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</QueryClientProvider> </QueryClientProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -40,6 +40,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage']; const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
@@ -126,6 +127,7 @@ export function AccountsPage() {
const [filterType, setFilterType] = useState<string | null>(null); const [filterType, setFilterType] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ── Accounts query ── // ── Accounts query ──
const { data: accounts = [], isLoading } = useQuery<Account[]>({ const { data: accounts = [], isLoading } = useQuery<Account[]>({
@@ -434,14 +436,44 @@ export function AccountsPage() {
// Net position = assets + investments - liabilities // Net position = assets + investments - liabilities
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0); const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
// ── Estimated monthly interest across all accounts with rates ── // ── Estimated monthly interest across all accounts + investments with rates ──
const estMonthlyInterest = accounts const acctMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0) .filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
.reduce((sum, a) => { .reduce((sum, a) => {
const bal = parseFloat(a.balance || '0'); const bal = parseFloat(a.balance || '0');
const rate = parseFloat(a.interest_rate || '0'); const rate = parseFloat(a.interest_rate || '0');
return sum + (bal * (rate / 100) / 12); return sum + (bal * (rate / 100) / 12);
}, 0); }, 0);
const invMonthlyInterest = investments
.filter((i) => i.is_active && parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => {
const val = parseFloat(i.current_value || i.principal || '0');
const rate = parseFloat(i.interest_rate || '0');
return sum + (val * (rate / 100) / 12);
}, 0);
const estMonthlyInterest = acctMonthlyInterest + invMonthlyInterest;
// ── Per-fund cash and interest breakdowns ──
const operatingCash = accounts
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'operating')
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
const reserveCash = accounts
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'reserve')
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
const opInvTotal = operatingInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const resInvTotal = reserveInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const opMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'operating' && parseFloat(a.interest_rate || '0') > 0)
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
+ operatingInvestments
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
const resMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'reserve' && parseFloat(a.interest_rate || '0') > 0)
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
+ reserveInvestments
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
// ── Adjust modal: current balance from trial balance ── // ── Adjust modal: current balance from trial balance ──
const adjustCurrentBalance = adjustingAccount const adjustCurrentBalance = adjustingAccount
@@ -472,37 +504,35 @@ export function AccountsPage() {
onChange={(e) => setShowArchived(e.currentTarget.checked)} onChange={(e) => setShowArchived(e.currentTarget.checked)}
size="sm" size="sm"
/> />
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}> {!isReadOnly && (
Add Account <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
</Button> Add Account
</Button>
)}
</Group> </Group>
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 4 }}> <SimpleGrid cols={{ base: 2, sm: 4 }}>
<Card withBorder p="xs"> <Card withBorder p="xs">
<Text size="xs" c="dimmed">Cash on Hand</Text> <Text size="xs" c="dimmed">Operating Fund</Text>
<Text fw={700} size="sm" c="green">{fmt(totalsByType['asset'] || 0)}</Text> <Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
</Card> </Card>
{investmentTotal > 0 && (
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Investments</Text>
<Text fw={700} size="sm" c="teal">{fmt(investmentTotal)}</Text>
</Card>
)}
{(totalsByType['liability'] || 0) > 0 && (
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Liabilities</Text>
<Text fw={700} size="sm" c="red">{fmt(totalsByType['liability'] || 0)}</Text>
</Card>
)}
<Card withBorder p="xs"> <Card withBorder p="xs">
<Text size="xs" c="dimmed">Net Position</Text> <Text size="xs" c="dimmed">Reserve Fund</Text>
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
</Card>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Total All Funds</Text>
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text> <Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
<Text size="xs" c="dimmed">Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)}</Text>
</Card> </Card>
{estMonthlyInterest > 0 && ( {estMonthlyInterest > 0 && (
<Card withBorder p="xs"> <Card withBorder p="xs">
<Text size="xs" c="dimmed">Est. Monthly Interest</Text> <Text size="xs" c="dimmed">Est. Monthly Interest</Text>
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text> <Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
</Card> </Card>
)} )}
</SimpleGrid> </SimpleGrid>
@@ -552,7 +582,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{investments.filter(i => i.is_active).length > 0 && ( {investments.filter(i => i.is_active).length > 0 && (
<> <>
@@ -570,7 +600,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{operatingInvestments.length > 0 && ( {operatingInvestments.length > 0 && (
<> <>
@@ -588,7 +618,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{reserveInvestments.length > 0 && ( {reserveInvestments.length > 0 && (
<> <>
@@ -606,7 +636,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
isArchivedView isArchivedView
/> />
</Tabs.Panel> </Tabs.Panel>
@@ -908,6 +938,7 @@ function AccountTable({
onArchive, onArchive,
onSetPrimary, onSetPrimary,
onAdjustBalance, onAdjustBalance,
isReadOnly = false,
isArchivedView = false, isArchivedView = false,
}: { }: {
accounts: Account[]; accounts: Account[];
@@ -915,6 +946,7 @@ function AccountTable({
onArchive: (a: Account) => void; onArchive: (a: Account) => void;
onSetPrimary: (id: string) => void; onSetPrimary: (id: string) => void;
onAdjustBalance: (a: Account) => void; onAdjustBalance: (a: Account) => void;
isReadOnly?: boolean;
isArchivedView?: boolean; isArchivedView?: boolean;
}) { }) {
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0); const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
@@ -1003,42 +1035,44 @@ function AccountTable({
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''} {a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4}> {!isReadOnly && (
{!a.is_system && ( <Group gap={4}>
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}> {!a.is_system && (
<ActionIcon <Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
variant="subtle" <ActionIcon
color="yellow" variant="subtle"
onClick={() => onSetPrimary(a.id)} color="yellow"
> onClick={() => onSetPrimary(a.id)}
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />} >
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
)}
{!a.is_system && (
<Tooltip label="Adjust Balance">
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Edit account">
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} {!a.is_system && (
{!a.is_system && ( <Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<Tooltip label="Adjust Balance"> <ActionIcon
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}> variant="subtle"
<IconAdjustments size={16} /> color={a.is_active ? 'gray' : 'green'}
</ActionIcon> onClick={() => onArchive(a)}
</Tooltip> >
)} {a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
<Tooltip label="Edit account"> </ActionIcon>
<ActionIcon variant="subtle" onClick={() => onEdit(a)}> </Tooltip>
<IconEdit size={16} /> )}
</ActionIcon> </Group>
</Tooltip> )}
{!a.is_system && (
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<ActionIcon
variant="subtle"
color={a.is_active ? 'gray' : 'green'}
onClick={() => onArchive(a)}
>
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
</ActionIcon>
</Tooltip>
)}
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); );
@@ -1090,6 +1124,7 @@ function InvestmentMiniTable({
<Table.Th>Name</Table.Th> <Table.Th>Name</Table.Th>
<Table.Th>Institution</Table.Th> <Table.Th>Institution</Table.Th>
<Table.Th>Type</Table.Th> <Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Principal</Table.Th> <Table.Th ta="right">Principal</Table.Th>
<Table.Th ta="right">Current Value</Table.Th> <Table.Th ta="right">Current Value</Table.Th>
<Table.Th ta="right">Rate</Table.Th> <Table.Th ta="right">Rate</Table.Th>
@@ -1103,7 +1138,7 @@ function InvestmentMiniTable({
<Table.Tbody> <Table.Tbody>
{investments.length === 0 && ( {investments.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={11}> <Table.Td colSpan={12}>
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text> <Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -1117,6 +1152,11 @@ function InvestmentMiniTable({
{inv.investment_type} {inv.investment_type}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td>
<Badge color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td> <Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td> <Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td>
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td> <Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip, ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
MultiSelect,
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
@@ -11,6 +12,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface AssessmentGroup { interface AssessmentGroup {
id: string; id: string;
@@ -20,6 +22,8 @@ interface AssessmentGroup {
special_assessment: string; special_assessment: string;
unit_count: number; unit_count: number;
frequency: string; frequency: string;
due_months: number[];
due_day: number;
actual_unit_count: string; actual_unit_count: string;
monthly_operating_income: string; monthly_operating_income: string;
monthly_reserve_income: string; monthly_reserve_income: string;
@@ -48,10 +52,34 @@ const frequencyColors: Record<string, string> = {
annual: 'violet', annual: 'violet',
}; };
const MONTH_OPTIONS = [
{ value: '1', label: 'January' },
{ value: '2', label: 'February' },
{ value: '3', label: 'March' },
{ value: '4', label: 'April' },
{ value: '5', label: 'May' },
{ value: '6', label: 'June' },
{ value: '7', label: 'July' },
{ value: '8', label: 'August' },
{ value: '9', label: 'September' },
{ value: '10', label: 'October' },
{ value: '11', label: 'November' },
{ value: '12', label: 'December' },
];
const MONTH_ABBREV = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DEFAULT_DUE_MONTHS: Record<string, string[]> = {
monthly: ['1','2','3','4','5','6','7','8','9','10','11','12'],
quarterly: ['1','4','7','10'],
annual: ['1'],
};
export function AssessmentGroupsPage() { export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null); const [editing, setEditing] = useState<AssessmentGroup | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({ const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'], queryKey: ['assessment-groups'],
@@ -71,18 +99,31 @@ export function AssessmentGroupsPage() {
specialAssessment: 0, specialAssessment: 0,
unitCount: 0, unitCount: 0,
frequency: 'monthly', frequency: 'monthly',
dueMonths: DEFAULT_DUE_MONTHS.monthly,
dueDay: 1,
}, },
validate: { validate: {
name: (v) => (v.length > 0 ? null : 'Required'), name: (v) => (v.length > 0 ? null : 'Required'),
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'), regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
dueMonths: (v, values) => {
if (values.frequency === 'quarterly' && v.length !== 4) return 'Quarterly requires exactly 4 months';
if (values.frequency === 'annual' && v.length !== 1) return 'Annual requires exactly 1 month';
return null;
},
dueDay: (v) => (v >= 1 && v <= 28 ? null : 'Must be 1-28'),
}, },
}); });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (values: any) => mutationFn: (values: any) => {
editing const payload = {
? api.put(`/assessment-groups/${editing.id}`, values) ...values,
: api.post('/assessment-groups', values), dueMonths: values.dueMonths.map(Number),
};
return editing
? api.put(`/assessment-groups/${editing.id}`, payload)
: api.post('/assessment-groups', payload);
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] }); queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] }); queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
@@ -119,6 +160,9 @@ export function AssessmentGroupsPage() {
const handleEdit = (group: AssessmentGroup) => { const handleEdit = (group: AssessmentGroup) => {
setEditing(group); setEditing(group);
const dueMonths = group.due_months
? group.due_months.map(String)
: DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly;
form.setValues({ form.setValues({
name: group.name, name: group.name,
description: group.description || '', description: group.description || '',
@@ -126,6 +170,8 @@ export function AssessmentGroupsPage() {
specialAssessment: parseFloat(group.special_assessment || '0'), specialAssessment: parseFloat(group.special_assessment || '0'),
unitCount: group.unit_count || 0, unitCount: group.unit_count || 0,
frequency: group.frequency || 'monthly', frequency: group.frequency || 'monthly',
dueMonths,
dueDay: group.due_day || 1,
}); });
open(); open();
}; };
@@ -136,6 +182,12 @@ export function AssessmentGroupsPage() {
open(); open();
}; };
const handleFrequencyChange = (value: string | null) => {
if (!value) return;
form.setFieldValue('frequency', value);
form.setFieldValue('dueMonths', DEFAULT_DUE_MONTHS[value] || DEFAULT_DUE_MONTHS.monthly);
};
const fmt = (v: string | number) => const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@@ -147,6 +199,11 @@ export function AssessmentGroupsPage() {
} }
}; };
const formatDueMonths = (months: number[], frequency: string) => {
if (!months || frequency === 'monthly') return 'Every month';
return months.map((m) => MONTH_ABBREV[m]).join(', ');
};
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
return ( return (
@@ -156,9 +213,11 @@ export function AssessmentGroupsPage() {
<Title order={2}>Assessment Groups</Title> <Title order={2}>Assessment Groups</Title>
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text> <Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
</div> </div>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}> {!isReadOnly && (
Add Group <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
</Button> Add Group
</Button>
)}
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}> <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
@@ -215,6 +274,7 @@ export function AssessmentGroupsPage() {
<Table.Th>Group Name</Table.Th> <Table.Th>Group Name</Table.Th>
<Table.Th ta="center">Units</Table.Th> <Table.Th ta="center">Units</Table.Th>
<Table.Th>Frequency</Table.Th> <Table.Th>Frequency</Table.Th>
<Table.Th>Due Months</Table.Th>
<Table.Th ta="right">Regular Assessment</Table.Th> <Table.Th ta="right">Regular Assessment</Table.Th>
<Table.Th ta="right">Special Assessment</Table.Th> <Table.Th ta="right">Special Assessment</Table.Th>
<Table.Th ta="right">Monthly Equiv.</Table.Th> <Table.Th ta="right">Monthly Equiv.</Table.Th>
@@ -225,7 +285,7 @@ export function AssessmentGroupsPage() {
<Table.Tbody> <Table.Tbody>
{groups.length === 0 && ( {groups.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={8}> <Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg"> <Text ta="center" c="dimmed" py="lg">
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc. No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
</Text> </Text>
@@ -259,6 +319,9 @@ export function AssessmentGroupsPage() {
{frequencyLabels[g.frequency] || 'Monthly'} {frequencyLabels[g.frequency] || 'Monthly'}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDueMonths(g.due_months, g.frequency)}</Text>
</Table.Td>
<Table.Td ta="right" ff="monospace"> <Table.Td ta="right" ff="monospace">
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)} {fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
</Table.Td> </Table.Td>
@@ -274,28 +337,30 @@ export function AssessmentGroupsPage() {
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4}> {!isReadOnly && (
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}> <Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
<ActionIcon
variant="subtle"
color={g.is_default ? 'yellow' : 'gray'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
disabled={g.is_default}
>
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={g.is_default ? 'yellow' : 'gray'} color={g.is_active ? 'gray' : 'green'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)} onClick={() => archiveMutation.mutate(g)}
disabled={g.is_default}
> >
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />} <IconArchive size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Group>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}> )}
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={g.is_active ? 'gray' : 'green'}
onClick={() => archiveMutation.mutate(g)}
>
<IconArchive size={16} />
</ActionIcon>
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
@@ -316,8 +381,22 @@ export function AssessmentGroupsPage() {
{ value: 'quarterly', label: 'Quarterly' }, { value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' }, { value: 'annual', label: 'Annual' },
]} ]}
{...form.getInputProps('frequency')} value={form.values.frequency}
onChange={handleFrequencyChange}
/> />
{form.values.frequency !== 'monthly' && (
<MultiSelect
label={form.values.frequency === 'quarterly' ? 'Billing Quarters (select 4 months)' : 'Due Month'}
description={form.values.frequency === 'quarterly'
? 'Select the first month of each quarter when assessments are due'
: 'Select the month when the annual assessment is due'}
data={MONTH_OPTIONS}
value={form.values.dueMonths}
onChange={(v) => form.setFieldValue('dueMonths', v)}
error={form.errors.dueMonths}
maxValues={form.values.frequency === 'annual' ? 1 : 4}
/>
)}
<Group grow> <Group grow>
<NumberInput <NumberInput
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`} label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
@@ -334,7 +413,16 @@ export function AssessmentGroupsPage() {
{...form.getInputProps('specialAssessment')} {...form.getInputProps('specialAssessment')}
/> />
</Group> </Group>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} /> <Group grow>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
<NumberInput
label="Due Day of Month"
description="Day invoices are due (1-28)"
min={1}
max={28}
{...form.getInputProps('dueDay')}
/>
</Group>
<Button type="submit" loading={saveMutation.isPending}> <Button type="submit" loading={saveMutation.isPending}>
{editing ? 'Update' : 'Create'} {editing ? 'Update' : 'Create'}
</Button> </Button>

View File

@@ -16,13 +16,15 @@ import { IconAlertCircle } from '@tabler/icons-react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import logoSrc from '../../assets/logo.svg'; import { usePreferencesStore } from '../../stores/preferencesStore';
import logoSrc from '../../assets/logo.png';
export function LoginPage() { export function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth); const setAuth = useAuthStore((s) => s.setAuth);
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const form = useForm({ const form = useForm({
initialValues: { email: '', password: '' }, initialValues: { email: '', password: '' },
@@ -57,7 +59,16 @@ export function LoginPage() {
return ( return (
<Container size={420} my={80}> <Container size={420} my={80}>
<Center> <Center>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 60 }} /> <img
src={logoSrc}
alt="HOA LedgerIQ"
style={{
height: 60,
...(isDark ? {
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
} : {}),
}}
/>
</Center> </Center>
<Text c="dimmed" size="sm" ta="center" mt={5}> <Text c="dimmed" size="sm" ta="center" mt={5}>
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}

View File

@@ -120,11 +120,6 @@ export function SelectOrgPage() {
<Text fw={500}>{org.name}</Text> <Text fw={500}>{org.name}</Text>
<Group gap={4}> <Group gap={4}>
<Badge size="sm" variant="light">{org.role}</Badge> <Badge size="sm" variant="light">{org.role}</Badge>
{org.schemaName && (
<Badge size="xs" variant="dot" color="gray">
{org.schemaName}
</Badge>
)}
</Group> </Group>
</div> </div>
</Group> </Group>

View File

@@ -0,0 +1,264 @@
import { useState } from 'react';
import {
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
Loader, Center, Select, SimpleGrid, Tooltip,
} from '@mantine/core';
import {
IconPlus, IconArrowLeft, IconTrash, IconEdit,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
import { AssessmentChangeForm } from './components/AssessmentChangeForm';
import { ProjectionChart } from './components/ProjectionChart';
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const statusColors: Record<string, string> = {
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
};
const changeTypeLabels: Record<string, string> = {
dues_increase: 'Dues Increase',
dues_decrease: 'Dues Decrease',
special_assessment: 'Special Assessment',
};
const changeTypeColors: Record<string, string> = {
dues_increase: 'green',
dues_decrease: 'orange',
special_assessment: 'violet',
};
export function AssessmentScenarioDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [editAsmt, setEditAsmt] = useState<any>(null);
const { data: scenario, isLoading } = useQuery({
queryKey: ['board-planning-scenario', id],
queryFn: async () => {
const { data } = await api.get(`/board-planning/scenarios/${id}`);
return data;
},
});
const { data: projection, isLoading: projLoading } = useQuery({
queryKey: ['board-planning-projection', id],
queryFn: async () => {
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
return data;
},
enabled: !!id,
});
const addMutation = useMutation({
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/assessments`, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
setAddOpen(false);
notifications.show({ message: 'Assessment change added', color: 'green' });
},
});
const updateMutation = useMutation({
mutationFn: ({ asmtId, ...dto }: any) => api.put(`/board-planning/assessments/${asmtId}`, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
setEditAsmt(null);
notifications.show({ message: 'Assessment change updated', color: 'green' });
},
});
const removeMutation = useMutation({
mutationFn: (asmtId: string) => api.delete(`/board-planning/assessments/${asmtId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
notifications.show({ message: 'Assessment change removed', color: 'orange' });
},
});
const statusMutation = useMutation({
mutationFn: (status: string) => api.put(`/board-planning/scenarios/${id}`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
},
});
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
const assessments = scenario.assessments || [];
return (
<Stack>
{/* Header */}
<Group justify="space-between" align="flex-start">
<Group>
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/assessments')}>
<IconArrowLeft size={20} />
</ActionIcon>
<div>
<Group gap="xs">
<Title order={2}>{scenario.name}</Title>
<Badge color={statusColors[scenario.status]}>{scenario.status}</Badge>
</Group>
{scenario.description && <Text c="dimmed" size="sm">{scenario.description}</Text>}
</div>
</Group>
<Group>
<Select
size="xs"
value={scenario.status}
onChange={(v) => v && statusMutation.mutate(v)}
data={[
{ value: 'draft', label: 'Draft' },
{ value: 'active', label: 'Active' },
{ value: 'approved', label: 'Approved' },
]}
/>
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
Add Change
</Button>
</Group>
</Group>
{/* Summary Cards */}
{projection?.summary && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
<Text fw={700} size="xl" ff="monospace">{fmt(projection.summary.end_liquidity || 0)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
<Text fw={700} size="xl" ff="monospace" c={projection.summary.period_change >= 0 ? 'green' : 'red'}>
{projection.summary.period_change >= 0 ? '+' : ''}{fmt(projection.summary.period_change || 0)}
</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Min Liquidity</Text>
<Text fw={700} size="xl" ff="monospace" c={projection.summary.min_liquidity < 0 ? 'red' : undefined}>
{fmt(projection.summary.min_liquidity || 0)}
</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Coverage</Text>
<Text fw={700} size="xl" ff="monospace">
{projection.summary.reserve_coverage_months > 0
? `${projection.summary.reserve_coverage_months.toFixed(1)} mo`
: 'N/A'}
</Text>
{projection.summary.reserve_coverage_months <= 0 && (
<Text size="xs" c="dimmed">No planned capital projects</Text>
)}
</Card>
</SimpleGrid>
)}
{/* Assessment Changes Table */}
<Card withBorder p="lg">
<Title order={4} mb="md">Assessment Changes ({assessments.length})</Title>
{assessments.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Target Fund</Table.Th>
<Table.Th ta="right">Change</Table.Th>
<Table.Th>Effective</Table.Th>
<Table.Th>End</Table.Th>
<Table.Th w={80}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{assessments.map((a: any) => (
<Table.Tr key={a.id}>
<Table.Td fw={500}>{a.label}</Table.Td>
<Table.Td>
<Badge size="sm" color={changeTypeColors[a.change_type] || 'gray'}>
{changeTypeLabels[a.change_type] || a.change_type}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{a.target_fund}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{a.change_type === 'special_assessment'
? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit${(() => {
const inst = parseInt(a.special_installments) || 1;
if (inst === 1) return ', one-time';
if (inst === 3) return ', quarterly';
if (inst === 12) return ', annual';
return `, ${inst} mo`;
})()}`
: a.percentage_change
? `${parseFloat(a.percentage_change).toFixed(1)}%`
: a.flat_amount_change
? `${fmt(parseFloat(a.flat_amount_change))}/unit/mo`
: '-'}
</Table.Td>
<Table.Td>{a.effective_date ? new Date(a.effective_date).toLocaleDateString() : '-'}</Table.Td>
<Table.Td>{a.end_date ? new Date(a.end_date).toLocaleDateString() : 'Ongoing'}</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditAsmt(a)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove">
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(a.id)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text ta="center" c="dimmed" py="lg">
No assessment changes added yet. Click "Add Change" to model a dues increase or special assessment.
</Text>
)}
</Card>
{/* Projection Chart */}
{projection && (
<ProjectionChart
datapoints={projection.datapoints || []}
title="Assessment Impact Projection"
summary={projection.summary}
/>
)}
{projLoading && <Center py="xl"><Loader /></Center>}
{/* Add/Edit Modal */}
<AssessmentChangeForm
opened={addOpen || !!editAsmt}
onClose={() => { setAddOpen(false); setEditAsmt(null); }}
onSubmit={(data) => {
if (editAsmt) {
updateMutation.mutate({ asmtId: editAsmt.id, ...data });
} else {
addMutation.mutate(data);
}
}}
initialData={editAsmt}
loading={addMutation.isPending || updateMutation.isPending}
/>
</Stack>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { Title, Text, Stack, Group, Button, SimpleGrid, Modal, TextInput, Textarea, Loader, Center } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
import { ScenarioCard } from './components/ScenarioCard';
export function AssessmentScenariosPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editScenario, setEditScenario] = useState<any>(null);
const [form, setForm] = useState({ name: '', description: '' });
const { data: scenarios, isLoading } = useQuery<any[]>({
queryKey: ['board-planning-scenarios', 'assessment'],
queryFn: async () => {
const { data } = await api.get('/board-planning/scenarios?type=assessment');
return data;
},
});
const createMutation = useMutation({
mutationFn: (dto: any) => api.post('/board-planning/scenarios', dto),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
setCreateOpen(false);
setForm({ name: '', description: '' });
notifications.show({ message: 'Scenario created', color: 'green' });
navigate(`/board-planning/assessments/${res.data.id}`);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, ...dto }: any) => api.put(`/board-planning/scenarios/${id}`, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
setEditScenario(null);
notifications.show({ message: 'Scenario updated', color: 'green' });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/board-planning/scenarios/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
notifications.show({ message: 'Scenario archived', color: 'orange' });
},
});
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
return (
<Stack>
<Group justify="space-between" align="flex-start">
<div>
<Title order={2}>Assessment Scenarios</Title>
<Text c="dimmed" size="sm">
Model dues increases, special assessments, and their impact on cash flow and reserves
</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
New Scenario
</Button>
</Group>
{scenarios && scenarios.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{scenarios.map((s) => (
<ScenarioCard
key={s.id}
scenario={s}
onClick={() => navigate(`/board-planning/assessments/${s.id}`)}
onEdit={() => { setEditScenario(s); setForm({ name: s.name, description: s.description || '' }); }}
onDelete={() => deleteMutation.mutate(s.id)}
/>
))}
</SimpleGrid>
) : (
<Center py="xl">
<Stack align="center" gap="sm">
<Text c="dimmed">No assessment scenarios yet</Text>
<Text size="sm" c="dimmed" maw={400} ta="center">
Create a scenario to model dues increases, special assessments, and multi-year assessment planning.
</Text>
</Stack>
</Center>
)}
{/* Create Modal */}
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title="New Assessment Scenario">
<Stack>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. 5% Annual Increase" />
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe this assessment strategy..." />
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
onClick={() => createMutation.mutate({ name: form.name, description: form.description, scenarioType: 'assessment' })}
loading={createMutation.isPending}
disabled={!form.name}
>
Create Scenario
</Button>
</Group>
</Stack>
</Modal>
{/* Edit Modal */}
<Modal opened={!!editScenario} onClose={() => setEditScenario(null)} title="Edit Scenario">
<Stack>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<Group justify="flex-end">
<Button variant="default" onClick={() => setEditScenario(null)}>Cancel</Button>
<Button
onClick={() => updateMutation.mutate({ id: editScenario.id, name: form.name, description: form.description })}
loading={updateMutation.isPending}
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,776 @@
import { useState, useEffect, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card, Alert, Modal,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
IconCheck, IconArrowBack, IconTrash, IconRefresh,
IconUpload, IconDownload,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface PlanLine {
id: string;
account_id: string;
account_number: string;
account_name: string;
account_type: string;
fund_type: string;
is_manually_adjusted: boolean;
jan: number; feb: number; mar: number; apr: number;
may: number; jun: number; jul: number; aug: number;
sep: number; oct: number; nov: number; dec_amt: number;
annual_total: number;
}
const monthKeys = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
function hydrateLine(row: any): PlanLine {
const line: any = { ...row };
for (const m of monthKeys) {
line[m] = Number(line[m]) || 0;
}
line.annual_total = monthKeys.reduce((sum, m) => sum + (line[m] || 0), 0);
return line as PlanLine;
}
function parseCurrencyValue(val: string): number {
if (!val) return 0;
let s = val.trim();
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
const isNegative = s.includes('(') && s.includes(')');
s = s.replace(/[$,\s()]/g, '');
if (!s || s === '-') return 0;
const num = parseFloat(s);
if (isNaN(num)) return 0;
return isNegative ? -num : num;
}
function parseCSV(text: string): Record<string, string>[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values: string[] = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < line.length; j++) {
const ch = line[j];
if (ch === '"') { inQuotes = !inQuotes; }
else if (ch === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += ch; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, idx) => { row[h] = values[idx] || ''; });
rows.push(row);
}
return rows;
}
const statusColors: Record<string, string> = {
planning: 'blue',
approved: 'yellow',
ratified: 'green',
};
export function BudgetPlanningPage() {
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const [selectedYear, setSelectedYear] = useState<string | null>(null);
const [lineData, setLineData] = useState<PlanLine[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [inflationInput, setInflationInput] = useState<number>(2.5);
const [confirmModal, setConfirmModal] = useState<{ action: string; title: string; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Available years
const { data: availableYears } = useQuery<any>({
queryKey: ['budget-plan-available-years'],
queryFn: async () => {
const { data } = await api.get('/board-planning/budget-plans/available-years');
return data;
},
});
// Set default year when available
useEffect(() => {
if (availableYears?.years?.length && !selectedYear) {
setSelectedYear(String(availableYears.years[0].year));
}
}, [availableYears, selectedYear]);
// Plan data for selected year
const { data: plan, isLoading } = useQuery<any>({
queryKey: ['budget-plan', selectedYear],
queryFn: async () => {
const { data } = await api.get(`/board-planning/budget-plans/${selectedYear}`);
return data;
},
enabled: !!selectedYear,
});
// Hydrate lines when plan changes
useEffect(() => {
if (plan?.lines) {
setLineData(plan.lines.map(hydrateLine));
setInflationInput(parseFloat(plan.inflation_rate) || 2.5);
setIsEditing(false);
} else {
setLineData([]);
}
}, [plan]);
const hasBaseBudget = !!availableYears?.latestBudgetYear;
const yearOptions = (availableYears?.years || []).map((y: any) => ({
value: String(y.year),
label: `${y.year}${y.hasPlan ? ` (${y.status})` : ''}`,
}));
// If no base budget at all, also offer the current year as an option
const currentYear = new Date().getFullYear();
const allYearOptions = !hasBaseBudget && !yearOptions.find((y: any) => y.value === String(currentYear))
? [{ value: String(currentYear), label: String(currentYear) }, ...yearOptions]
: yearOptions;
// Mutations
const createMutation = useMutation({
mutationFn: async () => {
const fiscalYear = parseInt(selectedYear!, 10);
const baseYear = availableYears?.latestBudgetYear || new Date().getFullYear();
const { data } = await api.post('/board-planning/budget-plans', {
fiscalYear,
baseYear,
inflationRate: inflationInput,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan'] });
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
notifications.show({ message: 'Budget plan created', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Create failed', color: 'red' });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
const payload = lineData.map((l) => ({
accountId: l.account_id,
fundType: l.fund_type,
jan: l.jan, feb: l.feb, mar: l.mar, apr: l.apr,
may: l.may, jun: l.jun, jul: l.jul, aug: l.aug,
sep: l.sep, oct: l.oct, nov: l.nov, dec: l.dec_amt,
}));
return api.put(`/board-planning/budget-plans/${selectedYear}/lines`, {
planId: plan.id,
lines: payload,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
setIsEditing(false);
notifications.show({ message: 'Budget plan saved', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Save failed', color: 'red' });
},
});
const importMutation = useMutation({
mutationFn: async (lines: Record<string, string>[]) => {
const parsed = lines.map((row) => ({
account_number: row.account_number || row.accountnumber || '',
account_name: row.account_name || row.accountname || '',
jan: parseCurrencyValue(row.jan),
feb: parseCurrencyValue(row.feb),
mar: parseCurrencyValue(row.mar),
apr: parseCurrencyValue(row.apr),
may: parseCurrencyValue(row.may),
jun: parseCurrencyValue(row.jun),
jul: parseCurrencyValue(row.jul),
aug: parseCurrencyValue(row.aug),
sep: parseCurrencyValue(row.sep),
oct: parseCurrencyValue(row.oct),
nov: parseCurrencyValue(row.nov),
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
}));
const { data } = await api.post(`/board-planning/budget-plans/${selectedYear}/import`, parsed);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
let msg = `Imported ${data.imported} budget line(s)`;
if (data.created?.length) msg += `. Created ${data.created.length} new account(s)`;
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
notifications.show({
message: msg,
color: data.errors?.length ? 'yellow' : 'green',
autoClose: 10000,
});
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
},
});
const inflationMutation = useMutation({
mutationFn: () => api.put(`/board-planning/budget-plans/${selectedYear}/inflation`, {
inflationRate: inflationInput,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
notifications.show({ message: 'Inflation rate applied', color: 'green' });
},
});
const statusMutation = useMutation({
mutationFn: (status: string) => api.put(`/board-planning/budget-plans/${selectedYear}/status`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
queryClient.invalidateQueries({ queryKey: ['budgets'] });
setConfirmModal(null);
notifications.show({ message: 'Status updated', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Status update failed', color: 'red' });
setConfirmModal(null);
},
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/board-planning/budget-plans/${selectedYear}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
setConfirmModal(null);
notifications.show({ message: 'Budget plan deleted', color: 'orange' });
},
});
const updateCell = (idx: number, month: string, value: number) => {
const updated = [...lineData];
(updated[idx] as any)[month] = value || 0;
updated[idx].annual_total = monthKeys.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
setLineData(updated);
};
const handleCancelEdit = () => {
setIsEditing(false);
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
};
const handleDownloadTemplate = async () => {
try {
const yr = selectedYear || currentYear;
const response = await api.get(`/board-planning/budget-plans/${yr}/template`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `budget_template_${yr}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err: any) {
notifications.show({ message: 'Failed to download template', color: 'red' });
}
};
const handleImportCSV = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
const rows = parseCSV(text);
if (rows.length === 0) { notifications.show({ message: 'No data rows found in CSV', color: 'red' }); return; }
importMutation.mutate(rows);
};
reader.readAsText(file);
event.target.value = '';
};
const hasPlan = !!plan?.id;
const status = plan?.status || 'planning';
const cellsEditable = !isReadOnly && isEditing && status !== 'ratified';
const incomeLines = lineData.filter((b) => b.account_type === 'income');
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
const expenseLines = lineData.filter((b) => b.account_type === 'expense');
const totalOperatingIncome = operatingIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
const totalReserveIncome = reserveIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
const totalExpense = expenseLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
return (
<Stack>
{/* Header */}
<Group justify="space-between" align="flex-start">
<Group align="center">
<Title order={2}>Budget Planning</Title>
{hasPlan && (
<Badge size="lg" color={statusColors[status]}>
{status}
</Badge>
)}
</Group>
<Group>
<Select
data={allYearOptions}
value={selectedYear}
onChange={setSelectedYear}
w={180}
placeholder="Select year"
/>
<Button
variant="outline"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
size="sm"
>
Download Template
</Button>
</Group>
</Group>
{/* Hidden file input for CSV import */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
{isLoading && <Center h={300}><Loader /></Center>}
{/* Empty state - no base budget exists at all */}
{!isLoading && !hasPlan && selectedYear && !hasBaseBudget && (
<Alert icon={<IconInfoCircle size={16} />} color="orange" variant="light">
<Stack gap="sm">
<Text fw={600}>No budget data found in the system</Text>
<Text size="sm">
To get started with budget planning, you need to load an initial budget.
You can either create a new budget from scratch or import an existing budget from a CSV file.
</Text>
<Text size="sm" c="dimmed">
Use <Text span fw={600}>Download Template</Text> above to get a CSV with your chart of accounts pre-populated,
fill in the monthly amounts, then import it below.
</Text>
<Group>
<Button
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import Budget from CSV
</Button>
<Button
variant="light"
onClick={() => createMutation.mutate()}
loading={createMutation.isPending}
>
Create Empty Budget Plan
</Button>
</Group>
</Stack>
</Alert>
)}
{/* Empty state - base budget exists but no plan for this year */}
{!isLoading && !hasPlan && selectedYear && hasBaseBudget && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
<Stack gap="sm">
<Text>No budget plan exists for {selectedYear}. Create one based on the {availableYears?.latestBudgetYear} budget with an inflation adjustment, or import a CSV directly.</Text>
<Group>
<NumberInput
label="Inflation Rate (%)"
value={inflationInput}
onChange={(v) => setInflationInput(Number(v) || 0)}
min={0}
max={50}
step={0.5}
decimalScale={2}
w={160}
size="sm"
/>
<Button
mt={24}
onClick={() => createMutation.mutate()}
loading={createMutation.isPending}
>
Create Budget Plan for {selectedYear}
</Button>
<Text mt={24} c="dimmed">or</Text>
<Button
mt={24}
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import from CSV
</Button>
</Group>
<Text size="xs" c="dimmed">
Base year: {availableYears?.latestBudgetYear}. Each monthly amount will be compounded annually by the specified inflation rate.
</Text>
</Stack>
</Alert>
)}
{/* Plan controls */}
{hasPlan && (
<>
<Group justify="space-between">
<Group>
<NumberInput
label="Inflation Rate (%)"
value={inflationInput}
onChange={(v) => setInflationInput(Number(v) || 0)}
min={0}
max={50}
step={0.5}
decimalScale={2}
w={140}
size="xs"
disabled={status === 'ratified' || isReadOnly}
/>
<Button
mt={24}
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => {
setConfirmModal({
action: 'inflation',
title: 'Apply Inflation Rate',
message: `This will recalculate all non-manually-adjusted lines using ${inflationInput}% inflation compounded annually from the base year (${plan.base_year}). Manually adjusted lines will be preserved.`,
});
}}
disabled={status === 'ratified' || isReadOnly}
>
Apply
</Button>
<Text size="xs" c="dimmed" mt={24}>Base year: {plan.base_year}</Text>
</Group>
<Group>
{!isReadOnly && (
<>
{/* Import CSV into existing plan */}
{status !== 'ratified' && (
<Button
size="sm"
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import CSV
</Button>
)}
{/* Status actions */}
{status === 'planning' && (
<>
<Button
size="sm"
variant="light"
color="yellow"
leftSection={<IconCheck size={16} />}
onClick={() => setConfirmModal({
action: 'approved',
title: 'Approve Budget Plan',
message: `Mark the ${selectedYear} budget plan as approved? This indicates the board has reviewed and accepted the plan.`,
})}
>
Approve
</Button>
<Button
size="sm"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => setConfirmModal({
action: 'delete',
title: 'Delete Budget Plan',
message: `Permanently delete the ${selectedYear} budget plan? This cannot be undone.`,
})}
>
Delete
</Button>
</>
)}
{status === 'approved' && (
<>
<Button
size="sm"
variant="light"
leftSection={<IconArrowBack size={16} />}
onClick={() => setConfirmModal({
action: 'planning',
title: 'Revert to Planning',
message: `Revert the ${selectedYear} budget plan back to planning status?`,
})}
>
Revert to Planning
</Button>
<Button
size="sm"
color="green"
leftSection={<IconCheck size={16} />}
onClick={() => setConfirmModal({
action: 'ratified',
title: 'Ratify Budget',
message: `Ratify the ${selectedYear} budget? This will create the official budget for ${selectedYear} in Financials, overwriting any existing budget data for that year.`,
})}
>
Ratify Budget
</Button>
</>
)}
{status === 'ratified' && (
<Button
size="sm"
variant="light"
color="orange"
leftSection={<IconArrowBack size={16} />}
onClick={() => setConfirmModal({
action: 'approved',
title: 'Revert from Ratified',
message: `Revert the ${selectedYear} budget from ratified to approved? This will remove the official budget for ${selectedYear} from Financials.`,
})}
>
Revert to Approved
</Button>
)}
{/* Edit/Save */}
{status !== 'ratified' && (
<>
{!isEditing ? (
<Button
size="sm"
variant="outline"
leftSection={<IconPencil size={16} />}
onClick={() => setIsEditing(true)}
>
Edit
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
color="gray"
leftSection={<IconX size={16} />}
onClick={handleCancelEdit}
>
Cancel
</Button>
<Button
size="sm"
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
>
Save
</Button>
</>
)}
</>
)}
</>
)}
</Group>
</Group>
{/* Summary cards */}
<Group>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Operating Income</Text>
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
</Card>
{totalReserveIncome > 0 && (
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Reserve Income</Text>
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
</Card>
)}
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Expenses</Text>
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
</Card>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Net (Operating)</Text>
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalOperatingIncome - totalExpense)}
</Text>
</Card>
</Group>
{/* Data table */}
<div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
{monthLabels.map((m) => (
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
))}
<Table.Th ta="right" style={{ minWidth: 110 }}>Annual</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lineData.length === 0 && (
<Table.Tr>
<Table.Td colSpan={15}>
<Text ta="center" c="dimmed" py="lg">No budget plan lines.</Text>
</Table.Td>
</Table.Tr>
)}
{['income', 'expense'].map((type) => {
const lines = lineData.filter((b) => b.account_type === type);
if (lines.length === 0) return null;
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
const sectionTotal = lines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
return [
<Table.Tr key={`header-${type}`} style={{ background: sectionBg }}>
<Table.Td
colSpan={2}
fw={700}
tt="capitalize"
style={{ position: 'sticky', left: 0, background: sectionBg, zIndex: 2 }}
>
{type}
</Table.Td>
{monthLabels.map((m) => <Table.Td key={m} />)}
<Table.Td ta="right" fw={700} ff="monospace">{fmt(sectionTotal)}</Table.Td>
</Table.Tr>,
...lines.map((line) => {
const idx = lineData.indexOf(line);
return (
<Table.Tr key={line.id || `${line.account_id}-${line.fund_type}`}>
<Table.Td
style={{
position: 'sticky', left: 0, background: stickyBg,
zIndex: 1, borderRight: `1px solid ${stickyBorder}`,
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td>
<Table.Td
style={{
position: 'sticky', left: 120, background: stickyBg,
zIndex: 1, borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>{line.account_name}</Text>
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
{line.is_manually_adjusted && <Badge size="xs" color="orange" variant="dot">edited</Badge>}
</Group>
</Table.Td>
{monthKeys.map((m) => (
<Table.Td key={m} p={2}>
{cellsEditable ? (
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ta="right" ff="monospace">
{fmt((line as any)[m] || 0)}
</Text>
)}
</Table.Td>
))}
<Table.Td ta="right" fw={500} ff="monospace">
{fmt(line.annual_total || 0)}
</Table.Td>
</Table.Tr>
);
}),
];
})}
</Table.Tbody>
</Table>
</div>
</>
)}
{/* Confirmation modal */}
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal?.title || ''}
centered
>
<Stack>
<Text size="sm">{confirmModal?.message}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
color={confirmModal?.action === 'delete' ? 'red' : undefined}
loading={statusMutation.isPending || deleteMutation.isPending || inflationMutation.isPending}
onClick={() => {
if (!confirmModal) return;
if (confirmModal.action === 'delete') {
deleteMutation.mutate();
} else if (confirmModal.action === 'inflation') {
inflationMutation.mutate();
setConfirmModal(null);
} else {
statusMutation.mutate(confirmModal.action);
}
}}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,330 @@
import { useState } from 'react';
import {
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import {
IconPlus, IconArrowLeft, IconTrash, IconEdit,
IconPlayerPlay, IconCoin, IconTrendingUp,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
import { InvestmentForm } from './components/InvestmentForm';
import { ProjectionChart } from './components/ProjectionChart';
import { InvestmentTimeline } from './components/InvestmentTimeline';
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const fmtDec = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
const statusColors: Record<string, string> = {
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
};
export function InvestmentScenarioDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [editInv, setEditInv] = useState<any>(null);
const [executeInv, setExecuteInv] = useState<any>(null);
const [executionDate, setExecutionDate] = useState<Date | null>(new Date());
const { data: scenario, isLoading } = useQuery({
queryKey: ['board-planning-scenario', id],
queryFn: async () => {
const { data } = await api.get(`/board-planning/scenarios/${id}`);
return data;
},
});
const { data: projection, isLoading: projLoading } = useQuery({
queryKey: ['board-planning-projection', id],
queryFn: async () => {
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
return data;
},
enabled: !!id,
});
const addMutation = useMutation({
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
setAddOpen(false);
notifications.show({ message: 'Investment added', color: 'green' });
},
});
const updateMutation = useMutation({
mutationFn: ({ invId, ...dto }: any) => api.put(`/board-planning/investments/${invId}`, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
setEditInv(null);
notifications.show({ message: 'Investment updated', color: 'green' });
},
});
const removeMutation = useMutation({
mutationFn: (invId: string) => api.delete(`/board-planning/investments/${invId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
notifications.show({ message: 'Investment removed', color: 'orange' });
},
});
const executeMutation = useMutation({
mutationFn: ({ invId, executionDate }: { invId: string; executionDate: string }) =>
api.post(`/board-planning/investments/${invId}/execute`, { executionDate }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
setExecuteInv(null);
notifications.show({ message: 'Investment executed and recorded', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Execution failed', color: 'red' });
},
});
const statusMutation = useMutation({
mutationFn: (status: string) => api.put(`/board-planning/scenarios/${id}`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
},
});
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
const investments = scenario.investments || [];
const summary = projection?.summary;
// Build a lookup of per-investment interest from the projection
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
if (summary?.investment_interest_details) {
for (const d of summary.investment_interest_details) {
interestDetailMap[d.id] = { interest: d.interest, principal: d.principal };
}
}
return (
<Stack>
{/* Header */}
<Group justify="space-between" align="flex-start">
<Group>
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/investments')}>
<IconArrowLeft size={20} />
</ActionIcon>
<div>
<Group gap="xs">
<Title order={2}>{scenario.name}</Title>
<Badge color={statusColors[scenario.status]}>{scenario.status}</Badge>
</Group>
{scenario.description && <Text c="dimmed" size="sm">{scenario.description}</Text>}
</div>
</Group>
<Group>
<Select
size="xs"
value={scenario.status}
onChange={(v) => v && statusMutation.mutate(v)}
data={[
{ value: 'draft', label: 'Draft' },
{ value: 'active', label: 'Active' },
{ value: 'approved', label: 'Approved' },
]}
/>
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
Add Investment
</Button>
</Group>
</Group>
{/* Summary Cards */}
{summary && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Principal</Text>
<Text fw={700} size="xl" ff="monospace">{fmt(summary.total_principal_invested || 0)}</Text>
<Text size="xs" c="dimmed">{investments.filter((i: any) => !i.executed_investment_id).length} planned investments</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Projected Interest Earned</Text>
<Text fw={700} size="xl" ff="monospace" c="green">
{summary.total_interest_earned > 0 ? `+${fmtDec(summary.total_interest_earned)}` : '$0.00'}
</Text>
{summary.total_interest_earned > 0 && (
<Text size="xs" c="dimmed">Over projection period</Text>
)}
{summary.total_interest_earned === 0 && investments.length > 0 && (
<Text size="xs" c="orange">Set purchase & maturity dates to calculate</Text>
)}
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Return on Investment</Text>
<Text fw={700} size="xl" ff="monospace" c={summary.roi_percentage > 0 ? 'green' : undefined}>
{summary.roi_percentage > 0 ? `${summary.roi_percentage.toFixed(2)}%` : '-'}
</Text>
{summary.roi_percentage > 0 && (
<Text size="xs" c="dimmed">Interest / Principal</Text>
)}
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
<Text fw={700} size="xl" ff="monospace">{fmt(summary.end_liquidity || 0)}</Text>
<Text size="xs" c={summary.period_change >= 0 ? 'green' : 'red'}>
{summary.period_change >= 0 ? '+' : ''}{fmt(summary.period_change || 0)} over period
</Text>
</Card>
</SimpleGrid>
)}
{/* Investments Table */}
<Card withBorder p="lg">
<Title order={4} mb="md">Planned Investments ({investments.length})</Title>
{investments.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Principal</Table.Th>
<Table.Th ta="right">Rate</Table.Th>
<Table.Th ta="right">Est. Interest</Table.Th>
<Table.Th>Purchase</Table.Th>
<Table.Th>Maturity</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th w={100}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{investments.map((inv: any) => {
const detail = interestDetailMap[inv.id];
return (
<Table.Tr key={inv.id}>
<Table.Td fw={500}>{inv.label}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{inv.investment_type || '-'}</Badge></Table.Td>
<Table.Td><Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}>{inv.fund_type}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(parseFloat(inv.principal))}</Table.Td>
<Table.Td ta="right">{inv.interest_rate ? `${parseFloat(inv.interest_rate).toFixed(2)}%` : '-'}</Table.Td>
<Table.Td ta="right" ff="monospace" c={detail?.interest ? 'green' : 'dimmed'}>
{detail?.interest ? `+${fmtDec(detail.interest)}` : '-'}
</Table.Td>
<Table.Td>{inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : <Text size="sm" c="orange">-</Text>}</Table.Td>
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : <Text size="sm" c="orange">-</Text>}</Table.Td>
<Table.Td>
{inv.executed_investment_id
? <Badge size="sm" color="green">Executed</Badge>
: <Badge size="sm" color="gray">Planned</Badge>}
</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditInv(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
{!inv.executed_investment_id && (
<Tooltip label="Execute">
<ActionIcon variant="subtle" color="green" size="sm" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
<IconPlayerPlay size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Remove">
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(inv.id)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
) : (
<Text ta="center" c="dimmed" py="lg">
No investments added yet. Click &quot;Add Investment&quot; to model an investment allocation.
</Text>
)}
</Card>
{/* Investment Timeline */}
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
{/* Projection Chart */}
{projection && (
<ProjectionChart
datapoints={projection.datapoints || []}
title="Scenario Projection"
summary={projection.summary}
/>
)}
{projLoading && <Center py="xl"><Loader /></Center>}
{/* Add/Edit Investment Modal */}
<InvestmentForm
opened={addOpen || !!editInv}
onClose={() => { setAddOpen(false); setEditInv(null); }}
onSubmit={(data) => {
if (editInv) {
updateMutation.mutate({ invId: editInv.id, ...data });
} else {
addMutation.mutate(data);
}
}}
initialData={editInv}
loading={addMutation.isPending || updateMutation.isPending}
/>
{/* Execute Confirmation Modal */}
<Modal opened={!!executeInv} onClose={() => setExecuteInv(null)} title="Execute Investment">
<Stack>
<Alert color="blue" variant="light">
This will create a real investment account record and post a journal entry transferring funds.
</Alert>
{executeInv && (
<>
<Text size="sm"><strong>Investment:</strong> {executeInv.label}</Text>
<Text size="sm"><strong>Amount:</strong> {fmt(parseFloat(executeInv.principal))}</Text>
<DateInput
label="Execution Date"
required
value={executionDate}
onChange={setExecutionDate}
description="The date the investment is actually purchased"
/>
</>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => setExecuteInv(null)}>Cancel</Button>
<Button
color="green"
leftSection={<IconPlayerPlay size={16} />}
onClick={() => {
if (executeInv && executionDate) {
executeMutation.mutate({
invId: executeInv.id,
executionDate: executionDate.toISOString().split('T')[0],
});
}
}}
loading={executeMutation.isPending}
>
Execute Investment
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { Title, Text, Stack, Group, Button, SimpleGrid, Modal, TextInput, Textarea, Loader, Center } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
import { ScenarioCard } from './components/ScenarioCard';
export function InvestmentScenariosPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editScenario, setEditScenario] = useState<any>(null);
const [form, setForm] = useState({ name: '', description: '' });
const { data: scenarios, isLoading } = useQuery<any[]>({
queryKey: ['board-planning-scenarios', 'investment'],
queryFn: async () => {
const { data } = await api.get('/board-planning/scenarios?type=investment');
return data;
},
});
const createMutation = useMutation({
mutationFn: (dto: any) => api.post('/board-planning/scenarios', dto),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
setCreateOpen(false);
setForm({ name: '', description: '' });
notifications.show({ message: 'Scenario created', color: 'green' });
navigate(`/board-planning/investments/${res.data.id}`);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, ...dto }: any) => api.put(`/board-planning/scenarios/${id}`, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
setEditScenario(null);
notifications.show({ message: 'Scenario updated', color: 'green' });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/board-planning/scenarios/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
notifications.show({ message: 'Scenario archived', color: 'orange' });
},
});
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
return (
<Stack>
<Group justify="space-between" align="flex-start">
<div>
<Title order={2}>Investment Scenarios</Title>
<Text c="dimmed" size="sm">
Model different investment strategies and compare their impact on liquidity and income
</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
New Scenario
</Button>
</Group>
{scenarios && scenarios.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{scenarios.map((s) => (
<ScenarioCard
key={s.id}
scenario={s}
onClick={() => navigate(`/board-planning/investments/${s.id}`)}
onEdit={() => { setEditScenario(s); setForm({ name: s.name, description: s.description || '' }); }}
onDelete={() => deleteMutation.mutate(s.id)}
/>
))}
</SimpleGrid>
) : (
<Center py="xl">
<Stack align="center" gap="sm">
<Text c="dimmed">No investment scenarios yet</Text>
<Text size="sm" c="dimmed" maw={400} ta="center">
Create a scenario to model investment allocations, timing, and their impact on reserves and liquidity.
</Text>
</Stack>
</Center>
)}
{/* Create Modal */}
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title="New Investment Scenario">
<Stack>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Conservative CD Ladder" />
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe this investment strategy..." />
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
onClick={() => createMutation.mutate({ name: form.name, description: form.description, scenarioType: 'investment' })}
loading={createMutation.isPending}
disabled={!form.name}
>
Create Scenario
</Button>
</Group>
</Stack>
</Modal>
{/* Edit Modal */}
<Modal opened={!!editScenario} onClose={() => setEditScenario(null)} title="Edit Scenario">
<Stack>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<Group justify="flex-end">
<Button variant="default" onClick={() => setEditScenario(null)}>Cancel</Button>
<Button
onClick={() => updateMutation.mutate({ id: editScenario.id, name: form.name, description: form.description })}
loading={updateMutation.isPending}
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,210 @@
import { useState } from 'react';
import {
Title, Text, Stack, Group, Card, MultiSelect, Loader, Center, Badge,
SimpleGrid, Table,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer,
} from 'recharts';
import api from '../../services/api';
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const COLORS = ['#228be6', '#40c057', '#7950f2', '#fd7e14'];
export function ScenarioComparisonPage() {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Load all scenarios for the selector
const { data: allScenarios } = useQuery<any[]>({
queryKey: ['board-planning-scenarios-all'],
queryFn: async () => {
const { data } = await api.get('/board-planning/scenarios');
return data;
},
});
// Load comparison data when scenarios are selected
const { data: comparison, isLoading: compLoading } = useQuery({
queryKey: ['board-planning-compare', selectedIds],
queryFn: async () => {
const { data } = await api.get(`/board-planning/compare?ids=${selectedIds.join(',')}`);
return data;
},
enabled: selectedIds.length >= 1,
});
const selectorData = (allScenarios || []).map((s) => ({
value: s.id,
label: `${s.name} (${s.scenario_type})`,
}));
// Build merged chart data with all scenarios
const chartData = (() => {
if (!comparison?.scenarios?.length) return [];
const firstScenario = comparison.scenarios[0];
if (!firstScenario?.projection?.datapoints) return [];
return firstScenario.projection.datapoints.map((_: any, idx: number) => {
const point: any = { month: firstScenario.projection.datapoints[idx].month };
comparison.scenarios.forEach((s: any, sIdx: number) => {
const dp = s.projection?.datapoints?.[idx];
if (dp) {
point[`total_${sIdx}`] =
dp.operating_cash + dp.operating_investments + dp.reserve_cash + dp.reserve_investments;
}
});
return point;
});
})();
return (
<Stack>
<div>
<Title order={2}>Compare Scenarios</Title>
<Text c="dimmed" size="sm">
Select up to 4 scenarios to compare their projected financial impact side-by-side
</Text>
</div>
<MultiSelect
label="Select Scenarios"
placeholder="Choose scenarios to compare..."
data={selectorData}
value={selectedIds}
onChange={setSelectedIds}
maxValues={4}
searchable
/>
{compLoading && <Center py="xl"><Loader size="lg" /></Center>}
{comparison?.scenarios?.length > 0 && (
<>
{/* Overlaid Line Chart */}
<Card withBorder p="lg">
<Title order={4} mb="md">Total Liquidity Projection</Title>
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip
formatter={(v: number) => fmt(v)}
labelStyle={{ fontWeight: 600 }}
/>
<Legend />
{comparison.scenarios.map((s: any, idx: number) => (
<Line
key={s.id}
type="monotone"
dataKey={`total_${idx}`}
name={s.name}
stroke={COLORS[idx]}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</Card>
{/* Summary Metrics Comparison */}
<Card withBorder p="lg">
<Title order={4} mb="md">Summary Comparison</Title>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Metric</Table.Th>
{comparison.scenarios.map((s: any, idx: number) => (
<Table.Th key={s.id} ta="right">
<Group gap={4} justify="flex-end">
<div style={{ width: 10, height: 10, borderRadius: 2, background: COLORS[idx] }} />
<Text size="sm" fw={600}>{s.name}</Text>
</Group>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td fw={500}>End Liquidity</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
{fmt(s.projection?.summary?.end_liquidity || 0)}
</Table.Td>
))}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>Minimum Liquidity</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}
c={(s.projection?.summary?.min_liquidity || 0) < 0 ? 'red' : undefined}
>
{fmt(s.projection?.summary?.min_liquidity || 0)}
</Table.Td>
))}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>Period Change</Table.Td>
{comparison.scenarios.map((s: any) => {
const change = s.projection?.summary?.period_change || 0;
return (
<Table.Td key={s.id} ta="right" ff="monospace" fw={600} c={change >= 0 ? 'green' : 'red'}>
{change >= 0 ? '+' : ''}{fmt(change)}
</Table.Td>
);
})}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>Reserve Coverage</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
{(s.projection?.summary?.reserve_coverage_months || 0).toFixed(1)} months
</Table.Td>
))}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>End Operating Cash</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace">
{fmt(s.projection?.summary?.end_operating_cash || 0)}
</Table.Td>
))}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>End Reserve Cash</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace">
{fmt(s.projection?.summary?.end_reserve_cash || 0)}
</Table.Td>
))}
</Table.Tr>
</Table.Tbody>
</Table>
</Card>
{/* Risk Flags */}
{comparison.scenarios.some((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0) && (
<Card withBorder p="lg" bg="red.0">
<Title order={4} c="red" mb="sm">Liquidity Warnings</Title>
{comparison.scenarios.filter((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0).map((s: any) => (
<Text key={s.id} size="sm" c="red">
{s.name}: projected negative liquidity of {fmt(s.projection.summary.min_liquidity)}
</Text>
))}
</Card>
)}
</>
)}
{selectedIds.length === 0 && (
<Center py="xl">
<Text c="dimmed">Select one or more scenarios above to compare their financial projections</Text>
</Center>
)}
</Stack>
);
}

View File

@@ -0,0 +1,159 @@
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Text } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useState, useEffect } from 'react';
interface Props {
opened: boolean;
onClose: () => void;
onSubmit: (data: any) => void;
initialData?: any;
loading?: boolean;
}
export function AssessmentChangeForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
const [form, setForm] = useState({
changeType: 'dues_increase' as string,
label: '',
targetFund: 'operating',
percentageChange: 0,
flatAmountChange: 0,
specialTotal: 0,
specialPerUnit: 0,
specialInstallments: 1,
effectiveDate: null as Date | null,
endDate: null as Date | null,
notes: '',
});
useEffect(() => {
if (initialData) {
setForm({
changeType: initialData.change_type || initialData.changeType || 'dues_increase',
label: initialData.label || '',
targetFund: initialData.target_fund || initialData.targetFund || 'operating',
percentageChange: parseFloat(initialData.percentage_change || initialData.percentageChange) || 0,
flatAmountChange: parseFloat(initialData.flat_amount_change || initialData.flatAmountChange) || 0,
specialTotal: parseFloat(initialData.special_total || initialData.specialTotal) || 0,
specialPerUnit: parseFloat(initialData.special_per_unit || initialData.specialPerUnit) || 0,
specialInstallments: initialData.special_installments || initialData.specialInstallments || 1,
effectiveDate: initialData.effective_date ? new Date(initialData.effective_date) : null,
endDate: initialData.end_date ? new Date(initialData.end_date) : null,
notes: initialData.notes || '',
});
} else {
setForm({
changeType: 'dues_increase', label: '', targetFund: 'operating',
percentageChange: 0, flatAmountChange: 0, specialTotal: 0, specialPerUnit: 0,
specialInstallments: 1, effectiveDate: null, endDate: null, notes: '',
});
}
}, [initialData, opened]);
const handleSubmit = () => {
onSubmit({
...form,
effectiveDate: form.effectiveDate?.toISOString().split('T')[0] || null,
endDate: form.endDate?.toISOString().split('T')[0] || null,
});
};
const isSpecial = form.changeType === 'special_assessment';
return (
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Assessment Change' : 'Add Assessment Change'} size="lg">
<Stack>
<Select
label="Change Type"
value={form.changeType}
onChange={(v) => setForm({ ...form, changeType: v || 'dues_increase' })}
data={[
{ value: 'dues_increase', label: 'Dues Increase' },
{ value: 'dues_decrease', label: 'Dues Decrease' },
{ value: 'special_assessment', label: 'Special Assessment' },
]}
/>
<TextInput
label="Label"
required
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
placeholder={isSpecial ? 'e.g. Roof Replacement Assessment' : 'e.g. 5% Annual Increase'}
/>
<Select
label="Target Fund"
value={form.targetFund}
onChange={(v) => setForm({ ...form, targetFund: v || 'operating' })}
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
{ value: 'both', label: 'Both' },
]}
/>
{!isSpecial && (
<>
<Text size="sm" fw={500} c="dimmed">Set either a percentage or flat amount (not both):</Text>
<Group grow>
<NumberInput
label="Percentage Change (%)"
value={form.percentageChange}
onChange={(v) => setForm({ ...form, percentageChange: Number(v) || 0, flatAmountChange: 0 })}
min={0}
max={100}
decimalScale={2}
suffix="%"
/>
<NumberInput
label="Flat Amount Change ($/unit/mo)"
value={form.flatAmountChange}
onChange={(v) => setForm({ ...form, flatAmountChange: Number(v) || 0, percentageChange: 0 })}
min={0}
decimalScale={2}
prefix="$"
/>
</Group>
</>
)}
{isSpecial && (
<>
<NumberInput
label="Per Unit Amount"
description="Total amount each unit will be assessed"
value={form.specialPerUnit}
onChange={(v) => setForm({ ...form, specialPerUnit: Number(v) || 0 })}
min={0}
decimalScale={2}
thousandSeparator=","
prefix="$"
/>
<Select
label="Duration"
description="How the assessment is collected"
value={String(form.specialInstallments)}
onChange={(v) => setForm({ ...form, specialInstallments: Number(v) || 1 })}
data={[
{ value: '1', label: 'One-time (lump sum)' },
{ value: '3', label: 'Quarterly (3 monthly payments)' },
{ value: '6', label: '6 months' },
{ value: '12', label: 'Annual (12 monthly payments)' },
]}
/>
</>
)}
<Group grow>
<DateInput label="Effective Date" required value={form.effectiveDate} onChange={(v) => setForm({ ...form, effectiveDate: v })} />
<DateInput label="End Date (optional)" value={form.endDate} onChange={(v) => setForm({ ...form, endDate: v })} clearable />
</Group>
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.effectiveDate}>
{initialData ? 'Update' : 'Add Change'}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,110 @@
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Switch } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useState, useEffect } from 'react';
interface Props {
opened: boolean;
onClose: () => void;
onSubmit: (data: any) => void;
initialData?: any;
loading?: boolean;
}
export function InvestmentForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
const [form, setForm] = useState({
label: '',
investmentType: 'cd',
fundType: 'reserve',
principal: 0,
interestRate: 0,
termMonths: 12,
institution: '',
purchaseDate: null as Date | null,
maturityDate: null as Date | null,
autoRenew: false,
notes: '',
});
useEffect(() => {
if (initialData) {
setForm({
label: initialData.label || '',
investmentType: initialData.investment_type || initialData.investmentType || 'cd',
fundType: initialData.fund_type || initialData.fundType || 'reserve',
principal: parseFloat(initialData.principal) || 0,
interestRate: parseFloat(initialData.interest_rate || initialData.interestRate) || 0,
termMonths: initialData.term_months || initialData.termMonths || 12,
institution: initialData.institution || '',
purchaseDate: initialData.purchase_date ? new Date(initialData.purchase_date) : null,
maturityDate: initialData.maturity_date ? new Date(initialData.maturity_date) : null,
autoRenew: initialData.auto_renew || initialData.autoRenew || false,
notes: initialData.notes || '',
});
} else {
setForm({
label: '', investmentType: 'cd', fundType: 'reserve', principal: 0,
interestRate: 0, termMonths: 12, institution: '', purchaseDate: null,
maturityDate: null, autoRenew: false, notes: '',
});
}
}, [initialData, opened]);
const handleSubmit = () => {
onSubmit({
...form,
purchaseDate: form.purchaseDate?.toISOString().split('T')[0] || null,
maturityDate: form.maturityDate?.toISOString().split('T')[0] || null,
});
};
return (
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Investment' : 'Add Investment'} size="lg">
<Stack>
<TextInput label="Label" required value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="e.g. 6-Month Treasury" />
<Group grow>
<Select
label="Type"
value={form.investmentType}
onChange={(v) => setForm({ ...form, investmentType: v || 'cd' })}
data={[
{ value: 'cd', label: 'CD' },
{ value: 'money_market', label: 'Money Market' },
{ value: 'treasury', label: 'Treasury' },
{ value: 'savings', label: 'Savings' },
{ value: 'other', label: 'Other' },
]}
/>
<Select
label="Fund"
value={form.fundType}
onChange={(v) => setForm({ ...form, fundType: v || 'reserve' })}
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
]}
/>
</Group>
<Group grow>
<NumberInput label="Principal ($)" required value={form.principal} onChange={(v) => setForm({ ...form, principal: Number(v) || 0 })} min={0} decimalScale={2} thousandSeparator="," prefix="$" />
<NumberInput label="Interest Rate (%)" value={form.interestRate} onChange={(v) => setForm({ ...form, interestRate: Number(v) || 0 })} min={0} max={20} decimalScale={3} suffix="%" />
</Group>
<Group grow>
<NumberInput label="Term (months)" value={form.termMonths} onChange={(v) => setForm({ ...form, termMonths: Number(v) || 0 })} min={1} max={120} />
<TextInput label="Institution" value={form.institution} onChange={(e) => setForm({ ...form, institution: e.target.value })} placeholder="e.g. First National Bank" />
</Group>
<Group grow>
<DateInput label="Purchase Date" value={form.purchaseDate} onChange={(v) => setForm({ ...form, purchaseDate: v })} clearable />
<DateInput label="Maturity Date" value={form.maturityDate} onChange={(v) => setForm({ ...form, maturityDate: v })} clearable />
</Group>
<Switch label="Auto-renew at maturity" checked={form.autoRenew} onChange={(e) => setForm({ ...form, autoRenew: e.currentTarget.checked })} />
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.principal}>
{initialData ? 'Update' : 'Add Investment'}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,154 @@
import { Card, Title, Text, Group, Badge, Tooltip } from '@mantine/core';
import { useMemo } from 'react';
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const typeColors: Record<string, string> = {
cd: '#228be6',
money_market: '#40c057',
treasury: '#7950f2',
savings: '#fd7e14',
other: '#868e96',
};
interface Props {
investments: any[];
}
export function InvestmentTimeline({ investments }: Props) {
const { items, startDate, endDate, totalMonths } = useMemo(() => {
const now = new Date();
const items = investments
.filter((inv: any) => inv.purchase_date || inv.maturity_date)
.map((inv: any) => ({
...inv,
start: inv.purchase_date ? new Date(inv.purchase_date) : now,
end: inv.maturity_date ? new Date(inv.maturity_date) : null,
}));
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())));
const totalMonths = Math.max(
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
1,
);
return { items, startDate, endDate, totalMonths };
}, [investments]);
if (!items.length) return null;
const getPercent = (date: Date) => {
const months = (date.getFullYear() - startDate.getFullYear()) * 12 + (date.getMonth() - startDate.getMonth());
return Math.max(0, Math.min(100, (months / totalMonths) * 100));
};
// Generate year labels
const yearLabels: { year: number; percent: number }[] = [];
for (let y = startDate.getFullYear(); y <= endDate.getFullYear(); y++) {
const janDate = new Date(y, 0, 1);
if (janDate >= startDate && janDate <= endDate) {
yearLabels.push({ year: y, percent: getPercent(janDate) });
}
}
return (
<Card withBorder p="lg">
<Title order={4} mb="md">Investment Timeline</Title>
{/* Year markers */}
<div style={{ position: 'relative', height: 20, marginBottom: 8 }}>
{yearLabels.map((yl) => (
<Text
key={yl.year}
size="xs"
c="dimmed"
fw={700}
style={{ position: 'absolute', left: `${yl.percent}%`, transform: 'translateX(-50%)' }}
>
{yl.year}
</Text>
))}
</div>
{/* Timeline bars */}
<div style={{ position: 'relative', minHeight: items.length * 40 + 10 }}>
{/* Background grid */}
<div style={{
position: 'absolute', inset: 0, borderLeft: '1px solid var(--mantine-color-gray-3)',
borderRight: '1px solid var(--mantine-color-gray-3)',
}}>
{yearLabels.map((yl) => (
<div
key={yl.year}
style={{
position: 'absolute', left: `${yl.percent}%`, top: 0, bottom: 0,
borderLeft: '1px dashed var(--mantine-color-gray-3)',
}}
/>
))}
</div>
{items.map((inv: any, idx: number) => {
const leftPct = getPercent(inv.start);
const rightPct = inv.end ? getPercent(inv.end) : leftPct + 2;
const widthPct = Math.max(rightPct - leftPct, 1);
const color = typeColors[inv.investment_type] || '#868e96';
return (
<Tooltip
key={inv.id}
label={
<div>
<Text size="xs" fw={600}>{inv.label}</Text>
<Text size="xs">{fmt(parseFloat(inv.principal))} @ {parseFloat(inv.interest_rate || 0).toFixed(2)}%</Text>
{inv.purchase_date && <Text size="xs">Start: {new Date(inv.purchase_date).toLocaleDateString()}</Text>}
{inv.maturity_date && <Text size="xs">Maturity: {new Date(inv.maturity_date).toLocaleDateString()}</Text>}
</div>
}
position="top"
multiline
withArrow
>
<div
style={{
position: 'absolute',
left: `${leftPct}%`,
width: `${widthPct}%`,
top: idx * 40 + 4,
height: 28,
borderRadius: 4,
background: color,
opacity: inv.executed_investment_id ? 0.5 : 0.85,
display: 'flex',
alignItems: 'center',
paddingLeft: 8,
paddingRight: 8,
cursor: 'pointer',
minWidth: 60,
}}
>
<Text size="xs" c="white" fw={600} truncate style={{ lineHeight: 1 }}>
{inv.label} {fmt(parseFloat(inv.principal))}
</Text>
</div>
</Tooltip>
);
})}
</div>
{/* Legend */}
<Group gap="md" mt="md">
{Object.entries(typeColors).map(([type, color]) => (
<Group key={type} gap={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: color }} />
<Text size="xs" c="dimmed">{type.replace('_', ' ')}</Text>
</Group>
))}
</Group>
</Card>
);
}

View File

@@ -0,0 +1,124 @@
import { useMemo } from 'react';
import { Card, Title, Text, Group, Badge, SegmentedControl, Stack } from '@mantine/core';
import { useState } from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer, ReferenceLine,
} from 'recharts';
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
interface Datapoint {
month: string;
year: number;
monthNum: number;
is_forecast: boolean;
operating_cash: number;
operating_investments: number;
reserve_cash: number;
reserve_investments: number;
}
interface Props {
datapoints: Datapoint[];
title?: string;
summary?: any;
}
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
const [fundFilter, setFundFilter] = useState('all');
const chartData = useMemo(() => {
return datapoints.map((d) => ({
...d,
label: `${d.month}`,
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
}));
}, [datapoints]);
// Find first forecast month for reference line
const forecastStart = chartData.findIndex((d) => d.is_forecast);
const CustomTooltip = ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<Card shadow="sm" p="xs" withBorder style={{ background: 'var(--mantine-color-body)' }}>
<Text fw={600} size="sm" mb={4}>{label}</Text>
{payload.map((p: any) => (
<Group key={p.name} justify="space-between" gap="xl">
<Text size="xs" c={p.color}>{p.name}</Text>
<Text size="xs" fw={600} ff="monospace">{fmt(p.value)}</Text>
</Group>
))}
</Card>
);
};
const showOp = fundFilter === 'all' || fundFilter === 'operating';
const showRes = fundFilter === 'all' || fundFilter === 'reserve';
return (
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<div>
<Title order={4}>{title}</Title>
{summary && (
<Group gap="md" mt={4}>
<Badge variant="light" color="teal">End Liquidity: {fmt(summary.end_liquidity || 0)}</Badge>
<Badge variant="light" color="orange">Min Liquidity: {fmt(summary.min_liquidity || 0)}</Badge>
{summary.reserve_coverage_months != null && (
<Badge variant="light" color="violet">
Reserve Coverage: {summary.reserve_coverage_months.toFixed(1)} mo
</Badge>
)}
</Group>
)}
</div>
<SegmentedControl
size="xs"
value={fundFilter}
onChange={setFundFilter}
data={[
{ label: 'All', value: 'all' },
{ label: 'Operating', value: 'operating' },
{ label: 'Reserve', value: 'reserve' },
]}
/>
</Group>
<ResponsiveContainer width="100%" height={350}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
</linearGradient>
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
</linearGradient>
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
</linearGradient>
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip content={<CustomTooltip />} />
<Legend />
{forecastStart > 0 && (
<ReferenceLine x={chartData[forecastStart]?.month} stroke="#aaa" strokeDasharray="5 5" label="Forecast" />
)}
{showOp && <Area type="monotone" dataKey="operating_cash" name="Operating Cash" stroke="#228be6" fill="url(#opCash)" stackId="1" />}
{showOp && <Area type="monotone" dataKey="operating_investments" name="Operating Investments" stroke="#74c0fc" fill="url(#opInv)" stackId="1" />}
{showRes && <Area type="monotone" dataKey="reserve_cash" name="Reserve Cash" stroke="#7950f2" fill="url(#resCash)" stackId="1" />}
{showRes && <Area type="monotone" dataKey="reserve_investments" name="Reserve Investments" stroke="#b197fc" fill="url(#resInv)" stackId="1" />}
</AreaChart>
</ResponsiveContainer>
</Card>
);
}

View File

@@ -0,0 +1,74 @@
import { Card, Group, Text, Badge, ActionIcon, Menu } from '@mantine/core';
import { IconDots, IconTrash, IconEdit, IconPlayerPlay } from '@tabler/icons-react';
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const statusColors: Record<string, string> = {
draft: 'gray',
active: 'blue',
approved: 'green',
archived: 'red',
};
interface Props {
scenario: any;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function ScenarioCard({ scenario, onClick, onEdit, onDelete }: Props) {
return (
<Card withBorder p="lg" style={{ cursor: 'pointer' }} onClick={onClick}>
<Group justify="space-between" mb="xs">
<Group gap="xs">
<Text fw={600}>{scenario.name}</Text>
<Badge size="xs" color={statusColors[scenario.status] || 'gray'}>
{scenario.status}
</Badge>
</Group>
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" onClick={(e: any) => e.stopPropagation()}>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEdit size={14} />} onClick={(e: any) => { e.stopPropagation(); onEdit(); }}>
Edit
</Menu.Item>
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={(e: any) => { e.stopPropagation(); onDelete(); }}>
Archive
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
{scenario.description && (
<Text size="sm" c="dimmed" mb="sm" lineClamp={2}>{scenario.description}</Text>
)}
<Group gap="lg">
{scenario.scenario_type === 'investment' && (
<>
<div>
<Text size="xs" c="dimmed">Investments</Text>
<Text fw={600}>{scenario.investment_count || 0}</Text>
</div>
<div>
<Text size="xs" c="dimmed">Total Principal</Text>
<Text fw={600} ff="monospace">{fmt(parseFloat(scenario.total_principal) || 0)}</Text>
</div>
</>
)}
{scenario.scenario_type === 'assessment' && (
<div>
<Text size="xs" c="dimmed">Changes</Text>
<Text fw={600}>{scenario.assessment_count || 0}</Text>
</div>
)}
</Group>
<Text size="xs" c="dimmed" mt="sm">
Updated {new Date(scenario.updated_at).toLocaleDateString()}
</Text>
</Card>
);
}

View File

@@ -1,12 +1,15 @@
import { useState, useRef } from 'react'; import { useState, useMemo } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, NumberInput, Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card, Alert, Select, Loader, Center, Badge, Card, Alert,
} from '@mantine/core'; } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react'; import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetLine { interface BudgetLine {
account_id: string; account_id: string;
@@ -23,27 +26,6 @@ interface BudgetLine {
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt']; const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/**
* Parse a currency-formatted value: "$48,065.21", "$(13,000.00)", " $- "
*/
function parseCurrencyValue(val: string): number {
if (!val) return 0;
let s = val.trim();
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
const isNegative = s.includes('(') && s.includes(')');
s = s.replace(/[$,\s()]/g, '');
if (!s || s === '-') return 0;
const num = parseFloat(s);
if (isNaN(num)) return 0;
return isNegative ? -num : num;
}
/**
* Ensure all monthly values are numbers (PostgreSQL can return strings for NUMERIC columns)
* and compute annual_total as the sum of all monthly values.
*/
function hydrateBudgetLine(row: any): BudgetLine { function hydrateBudgetLine(row: any): BudgetLine {
const line: any = { ...row }; const line: any = { ...row };
for (const m of months) { for (const m of months) {
@@ -53,76 +35,64 @@ function hydrateBudgetLine(row: any): BudgetLine {
return line as BudgetLine; return line as BudgetLine;
} }
function parseCSV(text: string): Record<string, string>[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Handle quoted fields containing commas
const values: string[] = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < line.length; j++) {
const ch = line[j];
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += ch;
}
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, idx) => {
row[h] = values[idx] || '';
});
rows.push(row);
}
return rows;
}
export function BudgetsPage() { export function BudgetsPage() {
const [year, setYear] = useState(new Date().getFullYear().toString()); const [year, setYear] = useState(new Date().getFullYear().toString());
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]); const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const navigate = useNavigate();
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const { isLoading } = useQuery<BudgetLine[]>({ // Query is the single source of truth for budget data
const { data: queryData, isLoading, isFetching } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year], queryKey: ['budgets', year],
queryFn: async () => { queryFn: async () => {
const { data } = await api.get(`/budgets/${year}`); const { data } = await api.get(`/budgets/${year}`);
// Hydrate each line: ensure numbers and compute annual_total return (data as any[]).map(hydrateBudgetLine);
const hydrated = (data as any[]).map(hydrateBudgetLine);
setBudgetData(hydrated);
return hydrated;
}, },
}); });
// Use edit data when editing, otherwise use query data
const isEditing = editData !== null;
const budgetData = isEditing ? editData : (queryData || []);
const hasBudget = budgetData.length > 0;
const cellsEditable = !isReadOnly && isEditing;
const handleStartEdit = () => {
setEditData(queryData ? [...queryData] : []);
};
const handleCancelEdit = () => {
setEditData(null);
};
const handleYearChange = (v: string | null) => {
if (v) {
setYear(v);
setEditData(null); // Cancel any in-progress edit when switching years
}
};
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const lines = budgetData const payload = budgetData
.filter((b) => months.some((m) => (b as any)[m] > 0)) .filter((b) => months.some((m) => (b as any)[m] > 0))
.map((b) => ({ .map((b) => ({
account_id: b.account_id, accountId: b.account_id,
fund_type: b.fund_type, fundType: b.fund_type,
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr, jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug, may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt, sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
})); }));
return api.put(`/budgets/${year}`, { lines }); return api.put(`/budgets/${year}`, payload);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] }); queryClient.invalidateQueries({ queryKey: ['budgets', year] });
setEditData(null);
notifications.show({ message: 'Budget saved', color: 'green' }); notifications.show({ message: 'Budget saved', color: 'green' });
}, },
onError: (err: any) => { onError: (err: any) => {
@@ -130,114 +100,30 @@ export function BudgetsPage() {
}, },
}); });
const importMutation = useMutation({
mutationFn: async (lines: Record<string, string>[]) => {
const parsed = lines.map((row) => ({
account_number: row.account_number || row.accountnumber || '',
account_name: row.account_name || row.accountname || '',
jan: parseCurrencyValue(row.jan),
feb: parseCurrencyValue(row.feb),
mar: parseCurrencyValue(row.mar),
apr: parseCurrencyValue(row.apr),
may: parseCurrencyValue(row.may),
jun: parseCurrencyValue(row.jun),
jul: parseCurrencyValue(row.jul),
aug: parseCurrencyValue(row.aug),
sep: parseCurrencyValue(row.sep),
oct: parseCurrencyValue(row.oct),
nov: parseCurrencyValue(row.nov),
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
}));
const { data } = await api.post(`/budgets/${year}/import`, parsed);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
let msg = `Imported ${data.imported} budget line(s)`;
if (data.created?.length) {
msg += `. Created ${data.created.length} new account(s)`;
}
if (data.errors?.length) {
msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
}
notifications.show({
message: msg,
color: data.errors?.length ? 'yellow' : 'green',
autoClose: 10000,
});
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
},
});
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${year}/template`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `budget_template_${year}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err: any) {
notifications.show({ message: 'Failed to download template', color: 'red' });
}
};
const handleImportCSV = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!text) {
notifications.show({ message: 'Could not read file', color: 'red' });
return;
}
const rows = parseCSV(text);
if (rows.length === 0) {
notifications.show({ message: 'No data rows found in CSV', color: 'red' });
return;
}
importMutation.mutate(rows);
};
reader.readAsText(file);
// Reset input so the same file can be re-selected
event.target.value = '';
};
const updateCell = (idx: number, month: string, value: number) => { const updateCell = (idx: number, month: string, value: number) => {
const updated = [...budgetData]; if (!editData) return;
const updated = [...editData];
(updated[idx] as any)[month] = value || 0; (updated[idx] as any)[month] = value || 0;
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0); updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
setBudgetData(updated); setEditData(updated);
}; };
const yearOptions = Array.from({ length: 5 }, (_, i) => { const yearOptions = useMemo(() => Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 1 + i; const y = new Date().getFullYear() - 1 + i;
return { value: String(y), label: String(y) }; return { value: String(y), label: String(y) };
}); }), []);
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 }); const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
// Show loader on initial load or when switching years with no cached data
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
const incomeLines = budgetData.filter((b) => b.account_type === 'income'); const incomeLines = budgetData.filter((b) => b.account_type === 'income');
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
const expenseLines = budgetData.filter((b) => b.account_type === 'expense'); const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
const totalIncome = incomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return ( return (
@@ -245,56 +131,81 @@ export function BudgetsPage() {
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Budget Manager</Title> <Title order={2}>Budget Manager</Title>
<Group> <Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} /> <Select data={yearOptions} value={year} onChange={handleYearChange} w={120} />
<Button {isFetching && !isLoading && <Loader size="xs" />}
variant="outline" {!isReadOnly && hasBudget && (
leftSection={<IconDownload size={16} />} <>
onClick={handleDownloadTemplate} {!isEditing ? (
> <Button
Download Template variant="outline"
</Button> leftSection={<IconPencil size={16} />}
<Button onClick={handleStartEdit}
variant="outline" >
leftSection={<IconUpload size={16} />} Edit Budget
onClick={handleImportCSV} </Button>
loading={importMutation.isPending} ) : (
> <>
Import CSV <Button
</Button> variant="outline"
<input color="gray"
type="file" leftSection={<IconX size={16} />}
ref={fileInputRef} onClick={handleCancelEdit}
style={{ display: 'none' }} >
accept=".csv,.txt" Cancel
onChange={handleFileChange} </Button>
/> <Button
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}> leftSection={<IconDeviceFloppy size={16} />}
Save Budget onClick={() => saveMutation.mutate()}
</Button> loading={saveMutation.isPending}
>
Save Budget
</Button>
</>
)}
</>
)}
</Group> </Group>
</Group> </Group>
{budgetData.length === 0 && !isLoading && ( {!hasBudget && !isLoading && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light"> <Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
No budget data for {year}. Import a CSV to get started. Your CSV should have columns:{' '} <Stack gap="sm">
<Text span ff="monospace" size="xs">account_number, account_name, jan, feb, ..., dec</Text>. <Text>No budget data for {year}.</Text>
Accounts will be auto-created if they don&apos;t exist yet. <Text size="sm">
To create or import a budget, use the <Text span fw={600}>Budget Planner</Text> to build,
review, and ratify a budget for this year. Once ratified, it will appear here.
</Text>
<Button
variant="light"
leftSection={<IconArrowRight size={16} />}
w="fit-content"
onClick={() => navigate('/board-planning/budgets')}
>
Go to Budget Planner
</Button>
</Stack>
</Alert> </Alert>
)} )}
<Group> <Group>
<Card withBorder p="sm"> <Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Income</Text> <Text size="xs" c="dimmed">Operating Income</Text>
<Text fw={700} c="green">{fmt(totalIncome)}</Text> <Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
</Card> </Card>
{totalReserveIncome > 0 && (
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Reserve Income</Text>
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
</Card>
)}
<Card withBorder p="sm"> <Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Expenses</Text> <Text size="xs" c="dimmed">Total Expenses</Text>
<Text fw={700} c="red">{fmt(totalExpense)}</Text> <Text fw={700} c="red">{fmt(totalExpense)}</Text>
</Card> </Card>
<Card withBorder p="sm"> <Card withBorder p="sm">
<Text size="xs" c="dimmed">Net</Text> <Text size="xs" c="dimmed">Net (Operating)</Text>
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}> <Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalIncome - totalExpense)} {fmt(totalOperatingIncome - totalExpense)}
</Text> </Text>
</Card> </Card>
</Group> </Group>
@@ -303,8 +214,8 @@ export function BudgetsPage() {
<Table striped highlightOnHover style={{ minWidth: 1600 }}> <Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th> <Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th> <Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
{monthLabels.map((m) => ( {monthLabels.map((m) => (
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th> <Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
))} ))}
@@ -315,7 +226,7 @@ export function BudgetsPage() {
{budgetData.length === 0 && ( {budgetData.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={15}> <Table.Td colSpan={15}>
<Text ta="center" c="dimmed" py="lg">No budget data. Import a CSV or add income/expense accounts to get started.</Text> <Text ta="center" c="dimmed" py="lg">No budget data for this year.</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
)} )}
@@ -323,7 +234,7 @@ export function BudgetsPage() {
const lines = budgetData.filter((b) => b.account_type === type); const lines = budgetData.filter((b) => b.account_type === type);
if (lines.length === 0) return null; if (lines.length === 0) return null;
const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8'; const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0); const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return [ return [
@@ -354,9 +265,9 @@ export function BudgetsPage() {
style={{ style={{
position: 'sticky', position: 'sticky',
left: 0, left: 0,
background: 'white', background: stickyBg,
zIndex: 1, zIndex: 1,
borderRight: '1px solid #e9ecef', borderRight: `1px solid ${stickyBorder}`,
}} }}
> >
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text> <Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
@@ -365,9 +276,9 @@ export function BudgetsPage() {
style={{ style={{
position: 'sticky', position: 'sticky',
left: 120, left: 120,
background: 'white', background: stickyBg,
zIndex: 1, zIndex: 1,
borderRight: '1px solid #e9ecef', borderRight: `1px solid ${stickyBorder}`,
}} }}
> >
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
@@ -377,15 +288,21 @@ export function BudgetsPage() {
</Table.Td> </Table.Td>
{months.map((m) => ( {months.map((m) => (
<Table.Td key={m} p={2}> <Table.Td key={m} p={2}>
<NumberInput {cellsEditable ? (
value={(line as any)[m] || 0} <NumberInput
onChange={(v) => updateCell(idx, m, Number(v) || 0)} value={(line as any)[m] || 0}
size="xs" onChange={(v) => updateCell(idx, m, Number(v) || 0)}
hideControls size="xs"
decimalScale={2} hideControls
min={0} decimalScale={2}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} min={0}
/> styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ta="right" ff="monospace">
{fmt((line as any)[m] || 0)}
</Text>
)}
</Table.Td> </Table.Td>
))} ))}
<Table.Td ta="right" fw={500} ff="monospace"> <Table.Td ta="right" fw={500} ff="monospace">

View File

@@ -14,6 +14,7 @@ import {
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types & constants // Types & constants
@@ -29,7 +30,7 @@ interface Project {
fund_source: string; fund_source: string;
funded_percentage: string; funded_percentage: string;
planned_date: string; planned_date: string;
target_year: number; target_year: number | null;
target_month: number; target_month: number;
status: string; status: string;
priority: number; priority: number;
@@ -37,6 +38,7 @@ interface Project {
} }
const FUTURE_YEAR = 9999; const FUTURE_YEAR = 9999;
const UNSCHEDULED = -1; // sentinel for projects with no target_year
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow', planned: 'blue', approved: 'green', in_progress: 'yellow',
@@ -48,7 +50,8 @@ const priorityColor = (p: number) => (p <= 2 ? 'red' : p <= 3 ? 'yellow' : 'gray
const fmt = (v: string | number) => const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year)); const yearLabel = (year: number) =>
year === FUTURE_YEAR ? 'Future' : year === UNSCHEDULED ? 'Unscheduled' : String(year);
const formatPlannedDate = (d: string | null | undefined) => { const formatPlannedDate = (d: string | null | undefined) => {
if (!d) return null; if (!d) return null;
@@ -73,6 +76,9 @@ interface KanbanCardProps {
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) { function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date); const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear();
const isBeyondWindow = project.target_year !== null && project.target_year > currentYear + 4 && project.target_year !== FUTURE_YEAR;
return ( return (
<Card <Card
@@ -104,6 +110,11 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
<Badge size="xs" color={priorityColor(project.priority)} variant="outline"> <Badge size="xs" color={priorityColor(project.priority)} variant="outline">
P{project.priority} P{project.priority}
</Badge> </Badge>
{isBeyondWindow && (
<Badge size="xs" variant="light" color="gray">
{project.target_year}
</Badge>
)}
</Group> </Group>
<Text size="xs" ff="monospace" fw={500} mb={4}> <Text size="xs" ff="monospace" fw={500} mb={4}>
@@ -144,19 +155,26 @@ function KanbanColumn({
isDragOver, onDragOverHandler, onDragLeave, isDragOver, onDragOverHandler, onDragLeave,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0); const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR;
const isUnscheduled = year === UNSCHEDULED;
const useWideLayout = (isFuture || isUnscheduled) && projects.length > 3;
return ( return (
<Paper <Paper
withBorder withBorder
radius="md" radius="md"
p="sm" p="sm"
miw={280} miw={useWideLayout ? 580 : 280}
maw={320} maw={useWideLayout ? 640 : 320}
style={{ style={{
flexShrink: 0, flexShrink: 0,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: isDragOver ? 'var(--mantine-color-blue-0)' : undefined, backgroundColor: isDragOver
? 'var(--mantine-color-blue-0)'
: isUnscheduled
? 'var(--mantine-color-orange-0)'
: undefined,
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined, border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease', transition: 'background-color 150ms ease, border 150ms ease',
}} }}
@@ -166,7 +184,12 @@ function KanbanColumn({
> >
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title> <Title order={5}>{yearLabel(year)}</Title>
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge> <Group gap={6}>
{isUnscheduled && projects.length > 0 && (
<Badge size="xs" variant="light" color="orange">needs scheduling</Badge>
)}
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
</Group>
</Group> </Group>
<Text size="xs" c="dimmed" mb="xs"> <Text size="xs" c="dimmed" mb="xs">
@@ -178,6 +201,16 @@ function KanbanColumn({
<Text size="xs" c="dimmed" ta="center" py="lg"> <Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here Drop projects here
</Text> </Text>
) : useWideLayout ? (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 'var(--mantine-spacing-xs)',
}}>
{projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
))}
</div>
) : ( ) : (
projects.map((p) => ( projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
@@ -215,6 +248,7 @@ export function CapitalProjectsPage() {
const [dragOverYear, setDragOverYear] = useState<number | null>(null); const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false); const printModeRef = useRef(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ---- Data fetching ---- // ---- Data fetching ----
@@ -287,10 +321,10 @@ export function CapitalProjectsPage() {
}); });
const moveProjectMutation = useMutation({ const moveProjectMutation = useMutation({
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number; target_month: number }) => { mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number | null; target_month: number }) => {
const payload: Record<string, unknown> = { target_year }; const payload: Record<string, unknown> = { target_year };
// Derive planned_date based on the new year // Derive planned_date based on the new year
if (target_year === FUTURE_YEAR) { if (target_year === null || target_year === FUTURE_YEAR) {
payload.planned_date = null; payload.planned_date = null;
} else { } else {
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`; payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
@@ -329,7 +363,7 @@ export function CapitalProjectsPage() {
form.setValues({ form.setValues({
status: p.status || 'planned', status: p.status || 'planned',
priority: p.priority || 3, priority: p.priority || 3,
target_year: p.target_year, target_year: p.target_year ?? currentYear,
target_month: p.target_month || 6, target_month: p.target_month || 6,
planned_date: p.planned_date || '', planned_date: p.planned_date || '',
notes: p.notes || '', notes: p.notes || '',
@@ -352,7 +386,7 @@ export function CapitalProjectsPage() {
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => { const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
e.dataTransfer.setData('application/json', JSON.stringify({ e.dataTransfer.setData('application/json', JSON.stringify({
id: project.id, id: project.id,
source_year: project.target_year, source_year: project.target_year ?? UNSCHEDULED,
target_month: project.target_month, target_month: project.target_month,
})); }));
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -376,7 +410,7 @@ export function CapitalProjectsPage() {
if (payload.source_year !== targetYear) { if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({ moveProjectMutation.mutate({
id: payload.id, id: payload.id,
target_year: targetYear, target_year: targetYear === UNSCHEDULED ? null : targetYear,
target_month: payload.target_month || 6, target_month: payload.target_month || 6,
}); });
} }
@@ -389,15 +423,20 @@ export function CapitalProjectsPage() {
// Always show current year through current+4, plus FUTURE_YEAR if any projects have it // Always show current year through current+4, plus FUTURE_YEAR if any projects have it
const baseYears = Array.from({ length: 5 }, (_, i) => currentYear + i); const baseYears = Array.from({ length: 5 }, (_, i) => currentYear + i);
const projectYears = [...new Set(projects.map((p) => p.target_year))]; const projectYears = [...new Set(projects.map((p) => p.target_year).filter((y): y is number => y !== null))];
const hasFutureProjects = projectYears.includes(FUTURE_YEAR); const hasFutureProjects = projectYears.includes(FUTURE_YEAR);
const hasUnscheduledProjects = projects.some((p) => p.target_year === null);
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now) // Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort(); const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
const years = hasFutureProjects ? [...regularYears, FUTURE_YEAR] : regularYears; const years = [
...regularYears,
...(hasFutureProjects ? [FUTURE_YEAR] : []),
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
];
// Kanban columns: always current..current+4 plus Future // Kanban columns: current..current+4 + Future + Unscheduled (rightmost)
const kanbanYears = [...baseYears, FUTURE_YEAR]; const kanbanYears = [...baseYears, FUTURE_YEAR, UNSCHEDULED];
// ---- Loading state ---- // ---- Loading state ----
@@ -417,12 +456,11 @@ export function CapitalProjectsPage() {
<Stack align="center" gap="md" maw={420}> <Stack align="center" gap="md" maw={420}>
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} /> <IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
<Title order={3} c="dimmed" ta="center"> <Title order={3} c="dimmed" ta="center">
No projects in the capital plan No projects yet
</Title> </Title>
<Text c="dimmed" ta="center" size="sm"> <Text c="dimmed" ta="center" size="sm">
Capital Planning displays projects that have a target year assigned.
Head over to the Projects page to define your reserve and operating Head over to the Projects page to define your reserve and operating
projects, then assign target years to see them here. projects. They'll appear here for capital planning and scheduling.
</Text> </Text>
<Button <Button
variant="light" variant="light"
@@ -448,7 +486,9 @@ export function CapitalProjectsPage() {
</Text> </Text>
) : ( ) : (
years.map((year) => { years.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year); const yearProjects = year === UNSCHEDULED
? projects.filter((p) => p.target_year === null)
: projects.filter((p) => p.target_year === year);
if (yearProjects.length === 0) return null; if (yearProjects.length === 0) return null;
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0); const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return ( return (
@@ -479,16 +519,18 @@ export function CapitalProjectsPage() {
<Table.Td fw={500}>{p.name}</Table.Td> <Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.category || '-'}</Table.Td> <Table.Td>{p.category || '-'}</Table.Td>
<Table.Td> <Table.Td>
{p.target_year === FUTURE_YEAR {p.target_year === null
? 'Future' ? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
: ( : p.target_year === FUTURE_YEAR
<> ? 'Future'
{p.target_month : (
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' }) <>
: ''}{' '} {p.target_month
{p.target_year} ? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
</> : ''}{' '}
) {p.target_year}
</>
)
} }
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
@@ -511,9 +553,9 @@ export function CapitalProjectsPage() {
</Table.Td> </Table.Td>
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td> <Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
<Table.Td> <Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}> {!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} /> <IconEdit size={16} />
</ActionIcon> </ActionIcon>}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
@@ -528,11 +570,20 @@ export function CapitalProjectsPage() {
// ---- Render: Kanban view ---- // ---- Render: Kanban view ----
const maxPlannedYear = currentYear + 4; // last year in the 5-year window
const renderKanbanView = () => ( const renderKanbanView = () => (
<ScrollArea type="auto" offsetScrollbars> <ScrollArea type="auto" offsetScrollbars>
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}> <Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
{kanbanYears.map((year) => { {kanbanYears.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year); // Unscheduled: projects with no target_year
// Future: projects with target_year === 9999 OR beyond the 5-year window
// Otherwise: exact year match
const yearProjects = year === UNSCHEDULED
? projects.filter((p) => p.target_year === null)
: year === FUTURE_YEAR
? projects.filter((p) => p.target_year === FUTURE_YEAR || (p.target_year !== null && p.target_year > maxPlannedYear))
: projects.filter((p) => p.target_year === year);
return ( return (
<KanbanColumn <KanbanColumn
key={year} key={year}

View File

@@ -8,6 +8,7 @@ import {
IconArrowLeft, IconArrowRight, IconCalendar, IconArrowLeft, IconArrowRight, IconCalendar,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
@@ -79,6 +80,7 @@ export function CashFlowForecastPage() {
const now = new Date(); const now = new Date();
const currentYear = now.getFullYear(); const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1; const currentMonth = now.getMonth() + 1;
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
// Filter: All, Operating, Reserve // Filter: All, Operating, Reserve
const [fundFilter, setFundFilter] = useState<string>('all'); const [fundFilter, setFundFilter] = useState<string>('all');
@@ -418,10 +420,10 @@ export function CashFlowForecastPage() {
<tr <tr
key={d.month} key={d.month}
style={{ style={{
borderBottom: '1px solid var(--mantine-color-gray-2)', borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`,
backgroundColor: d.is_forecast backgroundColor: d.is_forecast
? 'var(--mantine-color-orange-0)' ? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)')
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)', : i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : 'var(--mantine-color-gray-0)'),
}} }}
> >
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td> <td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>

View File

@@ -1,17 +1,296 @@
import { import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table, Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center, Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
Popover, List,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconCash, IconCash,
IconFileInvoice, IconFileInvoice,
IconShieldCheck, IconShieldCheck,
IconAlertTriangle, IconAlertTriangle,
IconBuildingBank,
IconTrendingUp,
IconTrendingDown,
IconMinus,
IconHeartbeat,
IconRefresh,
IconInfoCircle,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api'; import api from '../../services/api';
interface HealthScore {
id: string;
score_type: string;
score: number;
previous_score: number | null;
trajectory: string | null;
label: string;
summary: string;
factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>;
recommendations: Array<{ priority: string; text: string }>;
missing_data: string[] | null;
status: string;
response_time_ms: number | null;
calculated_at: string;
}
interface HealthScoresData {
operating: HealthScore | null;
reserve: HealthScore | null;
operating_last_failed?: boolean;
reserve_last_failed?: boolean;
}
function getScoreColor(score: number): string {
if (score >= 75) return 'green';
if (score >= 60) return 'yellow';
if (score >= 40) return 'orange';
return 'red';
}
function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
if (trajectory === 'improving') return <IconTrendingUp size={16} color="var(--mantine-color-green-6)" />;
if (trajectory === 'declining') return <IconTrendingDown size={16} color="var(--mantine-color-red-6)" />;
if (trajectory === 'stable') return <IconMinus size={16} color="var(--mantine-color-gray-6)" />;
return null;
}
function HealthScoreCard({
score,
title,
icon,
isRefreshing,
onRefresh,
lastFailed,
}: {
score: HealthScore | null;
title: string;
icon: React.ReactNode;
isRefreshing?: boolean;
onRefresh?: () => void;
lastFailed?: boolean;
}) {
// No score at all yet
if (!score) {
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
<Group gap={6}>
{onRefresh && (
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
</Tooltip>
)}
{icon}
</Group>
</Group>
<Center h={100}>
<Text c="dimmed" size="sm">No health score yet</Text>
</Center>
</Card>
);
}
// Pending — missing data, can't calculate
if (score.status === 'pending') {
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
<Group gap={6}>
{onRefresh && (
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
</Tooltip>
)}
{icon}
</Group>
</Group>
<Center>
<Stack align="center" gap="xs">
<Badge color="gray" variant="light" size="lg">Pending</Badge>
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
{missingItems.map((item: string, i: number) => (
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
))}
</Stack>
</Center>
</Card>
);
}
// For error status, we still render the score data (cached from the previous
// successful run) rather than blanking the card with "Error calculating score".
// A small watermark under the timestamp tells the user it's stale.
const showAsError = score.status === 'error' && score.score === 0 && !score.summary;
// Pure error with no cached data to fall back on
if (showAsError) {
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
<Group gap={6}>
{onRefresh && (
<Tooltip label={`Retry ${title.toLowerCase()} score`}>
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
loading={isRefreshing} onClick={onRefresh}>Retry</Button>
</Tooltip>
)}
{icon}
</Group>
</Group>
<Center h={100}>
<Stack align="center" gap={4}>
<Badge color="red" variant="light">Error calculating score</Badge>
<Text size="xs" c="dimmed">Click Retry to try again</Text>
</Stack>
</Center>
</Card>
);
}
// Normal display — works for both 'complete' and 'error' (with cached data)
const color = getScoreColor(score.score);
const factors = Array.isArray(score.factors) ? score.factors :
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
const recommendations = Array.isArray(score.recommendations) ? score.recommendations :
(typeof score.recommendations === 'string' ? JSON.parse(score.recommendations) : []);
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
<Group gap={6}>
{onRefresh && (
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
</Tooltip>
)}
{icon}
</Group>
</Group>
<Group align="flex-start" gap="lg">
<RingProgress
size={120}
thickness={12}
roundCaps
sections={[{ value: score.score, color }]}
label={
<Stack align="center" gap={0}>
<Text fw={700} size="xl" ta="center" lh={1}>{score.score}</Text>
<Text size="xs" c="dimmed" ta="center">/100</Text>
</Stack>
}
/>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6}>
<Badge color={color} variant="light" size="sm">{score.label}</Badge>
{score.trajectory && (
<Tooltip label={`Trend: ${score.trajectory}`}>
<Group gap={2}>
<TrajectoryIcon trajectory={score.trajectory} />
<Text size="xs" c="dimmed">{score.trajectory}</Text>
</Group>
</Tooltip>
)}
{score.previous_score !== null && (
<Text size="xs" c="dimmed">(prev: {score.previous_score})</Text>
)}
</Group>
<Text size="sm" lineClamp={2}>{score.summary}</Text>
<Group gap={4} mt={2}>
{factors.slice(0, 3).map((f: any, i: number) => (
<Tooltip key={i} label={f.detail} multiline w={280}>
<Badge
size="xs"
variant="dot"
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
>
{f.name}
</Badge>
</Tooltip>
))}
{(factors.length > 3 || recommendations.length > 0) && (
<Popover width={350} position="bottom" shadow="md">
<Popover.Target>
<Badge size="xs" variant="light" color="blue" style={{ cursor: 'pointer' }}>
<IconInfoCircle size={10} /> Details
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
{factors.length > 0 && (
<>
<Text fw={600} size="xs">Factors</Text>
{factors.map((f: any, i: number) => (
<Group key={i} gap={6} wrap="nowrap">
<Badge
size="xs"
variant="dot"
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
style={{ flexShrink: 0 }}
>
{f.name}
</Badge>
<Text size="xs" c="dimmed">{f.detail}</Text>
</Group>
))}
</>
)}
{recommendations.length > 0 && (
<>
<Divider my={4} />
<Text fw={600} size="xs">Recommendations</Text>
<List size="xs" spacing={4}>
{recommendations.map((r: any, i: number) => (
<List.Item key={i}>
<Badge size="xs" color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'} variant="light" mr={4}>
{r.priority}
</Badge>
{r.text}
</List.Item>
))}
</List>
</>
)}
{score.calculated_at && (
<Text size="xs" c="dimmed" ta="right" mt={4}>
Updated: {new Date(score.calculated_at).toLocaleString()}
</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
)}
</Group>
</Stack>
</Group>
{score.calculated_at && (
<Stack gap={0} mt={6} align="flex-end">
<Text size="10px" c="dimmed" style={{ opacity: 0.7 }}>
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
{lastFailed && (
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
last analysis failed showing cached data
</Text>
)}
</Stack>
)}
</Card>
);
}
interface DashboardData { interface DashboardData {
total_cash: string; total_cash: string;
total_receivables: string; total_receivables: string;
@@ -20,10 +299,25 @@ interface DashboardData {
recent_transactions: { recent_transactions: {
id: string; entry_date: string; description: string; entry_type: string; amount: string; id: string; entry_date: string; description: string; entry_type: string; amount: string;
}[]; }[];
// Enhanced split data
operating_cash: string;
reserve_cash: string;
operating_investments: string;
reserve_investments: string;
est_monthly_interest: string;
interest_earned_ytd: string;
interest_last_year: string;
interest_projected: string;
planned_capital_spend: string;
} }
export function DashboardPage() { export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg); const currentOrg = useAuthStore((s) => s.currentOrg);
const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
const [reserveRefreshing, setReserveRefreshing] = useState(false);
const { data, isLoading } = useQuery<DashboardData>({ const { data, isLoading } = useQuery<DashboardData>({
queryKey: ['dashboard'], queryKey: ['dashboard'],
@@ -31,15 +325,76 @@ export function DashboardPage() {
enabled: !!currentOrg, enabled: !!currentOrg,
}); });
const { data: healthScores } = useQuery<HealthScoresData>({
queryKey: ['health-scores'],
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
enabled: !!currentOrg,
// Poll every 3 seconds while a refresh is in progress
refetchInterval: (operatingRefreshing || reserveRefreshing) ? 3000 : false,
});
// Async refresh handlers — trigger the backend and poll for results
const handleRefreshOperating = useCallback(async () => {
const prevId = healthScores?.operating?.id;
setOperatingRefreshing(true);
try {
await api.post('/health-scores/calculate/operating');
} catch {
// Trigger failed at network level — polling will pick up any backend-saved error
}
// Start polling — watch for the health score to change (new id or updated timestamp)
const pollUntilDone = () => {
const checkInterval = setInterval(async () => {
try {
const { data: latest } = await api.get('/health-scores/latest');
const newScore = latest?.operating;
if (newScore && newScore.id !== prevId) {
setOperatingRefreshing(false);
queryClient.setQueryData(['health-scores'], latest);
clearInterval(checkInterval);
}
} catch {
// Keep polling
}
}, 3000);
// Safety timeout — stop polling after 11 minutes
setTimeout(() => { clearInterval(checkInterval); setOperatingRefreshing(false); }, 660000);
};
pollUntilDone();
}, [healthScores?.operating?.id, queryClient]);
const handleRefreshReserve = useCallback(async () => {
const prevId = healthScores?.reserve?.id;
setReserveRefreshing(true);
try {
await api.post('/health-scores/calculate/reserve');
} catch {
// Trigger failed at network level
}
const pollUntilDone = () => {
const checkInterval = setInterval(async () => {
try {
const { data: latest } = await api.get('/health-scores/latest');
const newScore = latest?.reserve;
if (newScore && newScore.id !== prevId) {
setReserveRefreshing(false);
queryClient.setQueryData(['health-scores'], latest);
clearInterval(checkInterval);
}
} catch {
// Keep polling
}
}, 3000);
setTimeout(() => { clearInterval(checkInterval); setReserveRefreshing(false); }, 660000);
};
pollUntilDone();
}, [healthScores?.reserve?.id, queryClient]);
const fmt = (v: string | number) => const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const stats = [ const opInv = parseFloat(data?.operating_investments || '0');
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' }, const resInv = parseFloat(data?.reserve_investments || '0');
{ title: 'Total Receivables', value: fmt(data?.total_receivables || '0'), icon: IconFileInvoice, color: 'blue' },
{ title: 'Reserve Fund', value: fmt(data?.reserve_fund_balance || '0'), icon: IconShieldCheck, color: 'violet' },
{ title: 'Delinquent Accounts', value: String(data?.delinquent_units || 0), icon: IconAlertTriangle, color: 'orange' },
];
const entryTypeColors: Record<string, string> = { const entryTypeColors: Record<string, string> = {
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red', manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
@@ -47,13 +402,8 @@ export function DashboardPage() {
}; };
return ( return (
<Stack> <Stack data-tour="dashboard-content">
<div> <Title order={2}>Dashboard</Title>
<Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
</Text>
</div>
{!currentOrg ? ( {!currentOrg ? (
<Card withBorder p="xl" ta="center"> <Card withBorder p="xl" ta="center">
@@ -66,24 +416,80 @@ export function DashboardPage() {
<Center h={200}><Loader /></Center> <Center h={200}><Loader /></Center>
) : ( ) : (
<> <>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<HealthScoreCard
score={healthScores?.operating || null}
title="Operating Fund"
icon={
<ThemeIcon color="green" variant="light" size={36} radius="md">
<IconHeartbeat size={20} />
</ThemeIcon>
}
isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating}
lastFailed={!!healthScores?.operating_last_failed}
/>
<HealthScoreCard
score={healthScores?.reserve || null}
title="Reserve Fund"
icon={
<ThemeIcon color="violet" variant="light" size={36} radius="md">
<IconHeartbeat size={20} />
</ThemeIcon>
}
isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve}
lastFailed={!!healthScores?.reserve_last_failed}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}> <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
{stats.map((stat) => ( <Card withBorder padding="lg" radius="md">
<Card key={stat.title} withBorder padding="lg" radius="md"> <Group justify="space-between">
<Group justify="space-between"> <div>
<div> <Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}> <Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
{stat.title} {opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
</Text> </div>
<Text fw={700} size="xl"> <ThemeIcon color="green" variant="light" size={48} radius="md">
{stat.value} <IconCash size={28} />
</Text> </ThemeIcon>
</div> </Group>
<ThemeIcon color={stat.color} variant="light" size={48} radius="md"> </Card>
<stat.icon size={28} /> <Card withBorder padding="lg" radius="md">
</ThemeIcon> <Group justify="space-between">
</Group> <div>
</Card> <Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Fund</Text>
))} <Text fw={700} size="xl">{fmt(data?.reserve_cash || '0')}</Text>
{resInv > 0 && <Text size="xs" c="teal">Investments: {fmt(resInv)}</Text>}
</div>
<ThemeIcon color="violet" variant="light" size={48} radius="md">
<IconShieldCheck size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Receivables</Text>
<Text fw={700} size="xl">{fmt(data?.total_receivables || '0')}</Text>
</div>
<ThemeIcon color="blue" variant="light" size={48} radius="md">
<IconFileInvoice size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Delinquent Accounts</Text>
<Text fw={700} size="xl">{String(data?.delinquent_units || 0)}</Text>
</div>
<ThemeIcon color="orange" variant="light" size={48} radius="md">
<IconAlertTriangle size={28} />
</ThemeIcon>
</Group>
</Card>
</SimpleGrid> </SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }}> <SimpleGrid cols={{ base: 1, md: 2 }}>
@@ -120,17 +526,54 @@ export function DashboardPage() {
<Title order={4}>Quick Stats</Title> <Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs"> <Stack mt="sm" gap="xs">
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Cash Position</Text> <Text size="sm" c="dimmed">Operating Cash</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text> <Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
</Group> </Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Reserve Cash</Text>
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
</Group>
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Interest Earned YoY</Text>
<Group gap={6}>
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_projected || '0')}</Text>
<Text size="xs" c="dimmed">proj</Text>
<Text size="xs" c="dimmed">vs</Text>
<Text size="sm" fw={500} c="gray">{fmt(data?.interest_last_year || '0')}</Text>
<Text size="xs" c="dimmed">prev</Text>
{(() => {
const proj = parseFloat(data?.interest_projected || '0');
const prev = parseFloat(data?.interest_last_year || '0');
const diff = proj - prev;
if (prev === 0 && proj === 0) return null;
return (
<Badge size="xs" color={diff >= 0 ? 'green' : 'red'} variant="light">
{diff >= 0 ? '+' : ''}{prev > 0 ? ((diff / prev) * 100).toFixed(0) : '—'}%
</Badge>
);
})()}
</Group>
</Group>
<Divider my={4} />
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Capital Projects</Text>
<Group justify="space-between">
<Text size="sm" c="dimmed">Planned Capital Spend {new Date().getFullYear()}</Text>
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
</Group>
<Divider my={4} />
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Outstanding AR</Text> <Text size="sm" c="dimmed">Outstanding AR</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text> <Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
</Group> </Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Reserve Funding</Text>
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_fund_balance || '0')}</Text>
</Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Delinquent Units</Text> <Text size="sm" c="dimmed">Delinquent Units</Text>
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}> <Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Title, Title,
Text, Text,
@@ -16,6 +16,13 @@ import {
Divider, Divider,
Accordion, Accordion,
Paper, Paper,
Tabs,
Collapse,
ActionIcon,
Modal,
Select,
TextInput,
Progress,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconBulb, IconBulb,
@@ -27,9 +34,14 @@ import {
IconRefresh, IconRefresh,
IconCoin, IconCoin,
IconPigMoney, IconPigMoney,
IconChevronDown,
IconChevronUp,
IconPlaylistAdd,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation } from '@tanstack/react-query'; import { DateInput } from '@mantine/dates';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
// ── Types ── // ── Types ──
@@ -59,15 +71,31 @@ interface FinancialSnapshot {
}>; }>;
} }
interface CdRate { interface MarketRate {
bank_name: string; bank_name: string;
apy: string; apy: string;
min_deposit: string | null; min_deposit: string | null;
term: string; term: string;
term_months: number | null; term_months: number | null;
rate_type: string;
fetched_at: string; fetched_at: string;
} }
interface MarketRatesResponse {
cd: MarketRate[];
money_market: MarketRate[];
high_yield_savings: MarketRate[];
}
interface RecommendationComponent {
label: string;
amount: number;
term_months: number;
rate: number;
bank_name?: string;
investment_type?: string;
}
interface Recommendation { interface Recommendation {
type: string; type: string;
priority: 'high' | 'medium' | 'low'; priority: 'high' | 'medium' | 'low';
@@ -80,6 +108,7 @@ interface Recommendation {
suggested_rate?: number; suggested_rate?: number;
bank_name?: string; bank_name?: string;
rationale: string; rationale: string;
components?: RecommendationComponent[];
} }
interface AIResponse { interface AIResponse {
@@ -88,6 +117,18 @@ interface AIResponse {
risk_notes: string[]; risk_notes: string[];
} }
interface SavedRecommendation {
id: string;
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
response_time_ms: number;
created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
}
// ── Helpers ── // ── Helpers ──
const fmt = (v: number) => const fmt = (v: number) =>
@@ -117,10 +158,309 @@ const typeLabels: Record<string, string> = {
general: 'General', general: 'General',
}; };
// ── Component ── // ── Rate Table Component ──
function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean }) {
if (rates.length === 0) {
return (
<Text ta="center" c="dimmed" py="lg">
No rates available. Run the market rate fetcher to populate data.
</Text>
);
}
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Bank</Table.Th>
<Table.Th ta="right">APY</Table.Th>
{showTerm && <Table.Th>Term</Table.Th>}
<Table.Th ta="right">Min Deposit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rates.map((r, i) => (
<Table.Tr key={i}>
<Table.Td fw={500}>{r.bank_name}</Table.Td>
<Table.Td ta="right" fw={700} c="green">
{parseFloat(r.apy).toFixed(2)}%
</Table.Td>
{showTerm && <Table.Td>{r.term}</Table.Td>}
<Table.Td ta="right" ff="monospace">
{r.min_deposit
? `$${parseFloat(r.min_deposit).toLocaleString()}`
: '-'}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
// ── Recommendations Display Component ──
function RecommendationsDisplay({
aiResult,
lastUpdated,
lastFailed,
onAddToPlan,
}: {
aiResult: AIResponse;
lastUpdated?: string;
lastFailed?: boolean;
onAddToPlan?: (rec: Recommendation) => void;
}) {
return (
<Stack>
{/* Last Updated timestamp + failure message */}
{lastUpdated && (
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
{lastFailed && (
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
last analysis failed showing cached data
</Text>
)}
</Stack>
)}
{/* Overall Assessment */}
<Alert color="blue" variant="light" title="Overall Assessment">
<Text size="sm">{aiResult.overall_assessment}</Text>
</Alert>
{/* Risk Notes */}
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
<Alert
color="yellow"
variant="light"
title="Risk Notes"
icon={<IconAlertTriangle />}
>
<Stack gap={4}>
{aiResult.risk_notes.map((note, i) => (
<Text key={i} size="sm">
{note}
</Text>
))}
</Stack>
</Alert>
)}
{/* Recommendation Cards */}
{aiResult.recommendations.length > 0 ? (
<Accordion variant="separated">
{aiResult.recommendations.map((rec, i) => {
const Icon = typeIcons[rec.type] || IconBulb;
return (
<Accordion.Item key={i} value={`rec-${i}`}>
<Accordion.Control>
<Group>
<ThemeIcon
variant="light"
color={priorityColors[rec.priority] || 'gray'}
size="md"
>
<Icon size={16} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={600}>{rec.title}</Text>
<Badge
size="xs"
color={priorityColors[rec.priority]}
>
{rec.priority}
</Badge>
<Badge size="xs" variant="light">
{typeLabels[rec.type] || rec.type}
</Badge>
<Badge
size="xs"
variant="dot"
color={
rec.fund_type === 'reserve'
? 'violet'
: rec.fund_type === 'operating'
? 'blue'
: 'gray'
}
>
{rec.fund_type}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={2}>
{rec.summary}
</Text>
</div>
{rec.suggested_amount != null && (
<Text fw={700} ff="monospace" c="green" size="lg">
{fmt(rec.suggested_amount)}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm">{rec.details}</Text>
{(rec.suggested_term ||
rec.suggested_rate != null ||
rec.bank_name) && (
<Paper withBorder p="sm" radius="sm">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{rec.suggested_term && (
<div>
<Text size="xs" c="dimmed">
Suggested Term
</Text>
<Text fw={600}>{rec.suggested_term}</Text>
</div>
)}
{rec.suggested_rate != null && (
<div>
<Text size="xs" c="dimmed">
Target Rate
</Text>
<Text fw={600}>
{rec.suggested_rate}% APY
</Text>
</div>
)}
{rec.bank_name && (
<div>
<Text size="xs" c="dimmed">
Bank
</Text>
<Text fw={600}>{rec.bank_name}</Text>
</div>
)}
</SimpleGrid>
</Paper>
)}
<Alert variant="light" color="gray" title="Rationale">
<Text size="sm">{rec.rationale}</Text>
</Alert>
{onAddToPlan && rec.type !== 'liquidity_warning' && rec.type !== 'general' && (
<Button
size="sm"
variant="light"
leftSection={<IconPlaylistAdd size={16} />}
onClick={() => onAddToPlan(rec)}
>
Add to Investment Plan
</Button>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text ta="center" c="dimmed" py="lg">
No specific recommendations at this time.
</Text>
)}
</Stack>
);
}
// ── Main Component ──
export function InvestmentPlanningPage() { export function InvestmentPlanningPage() {
const [aiResult, setAiResult] = useState<AIResponse | null>(null); const navigate = useNavigate();
const queryClient = useQueryClient();
const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false);
const [planModalOpen, setPlanModalOpen] = useState(false);
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
// Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({
queryKey: ['board-planning-scenarios', 'investment'],
queryFn: async () => {
const { data } = await api.get('/board-planning/scenarios?type=investment');
return data;
},
});
const addToPlanMutation = useMutation({
mutationFn: async ({ scenarioId, rec }: { scenarioId: string; rec: Recommendation }) => {
await api.post(`/board-planning/scenarios/${scenarioId}/investments/from-recommendation`, {
title: rec.title,
investmentType: rec.type === 'cd_ladder' ? 'cd' : rec.type === 'new_investment' ? undefined : undefined,
fundType: rec.fund_type || 'reserve',
suggestedAmount: rec.suggested_amount,
suggestedRate: rec.suggested_rate,
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
bankName: rec.bank_name,
rationale: rec.rationale,
components: rec.components || undefined,
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
});
return scenarioId;
},
onSuccess: (scenarioId) => {
setPlanModalOpen(false);
setSelectedRec(null);
setTargetScenarioId(null);
notifications.show({
message: 'Recommendation added to investment scenario',
color: 'green',
autoClose: 5000,
});
},
});
const createAndAddMutation = useMutation({
mutationFn: async ({ name, rec }: { name: string; rec: Recommendation }) => {
const { data: scenario } = await api.post('/board-planning/scenarios', {
name, scenarioType: 'investment',
});
await api.post(`/board-planning/scenarios/${scenario.id}/investments/from-recommendation`, {
title: rec.title,
investmentType: rec.type === 'cd_ladder' ? 'cd' : undefined,
fundType: rec.fund_type || 'reserve',
suggestedAmount: rec.suggested_amount,
suggestedRate: rec.suggested_rate,
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
bankName: rec.bank_name,
rationale: rec.rationale,
components: rec.components || undefined,
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
});
return scenario.id;
},
onSuccess: (scenarioId) => {
setPlanModalOpen(false);
setSelectedRec(null);
setNewScenarioName('');
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
notifications.show({
message: 'New scenario created with recommendation',
color: 'green',
autoClose: 5000,
});
},
});
const handleAddToPlan = (rec: Recommendation) => {
setSelectedRec(rec);
setTargetScenarioId(null);
setNewScenarioName('');
setInvestmentStartDate(new Date());
setPlanModalOpen(true);
};
// Load financial snapshot on mount // Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({ const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -131,38 +471,107 @@ export function InvestmentPlanningPage() {
}, },
}); });
// Load CD rates on mount // Load market rates (all types) on mount
const { data: cdRates = [], isLoading: ratesLoading } = useQuery<CdRate[]>({ const { data: marketRates, isLoading: ratesLoading } = useQuery<MarketRatesResponse>({
queryKey: ['investment-planning-cd-rates'], queryKey: ['investment-planning-market-rates'],
queryFn: async () => { queryFn: async () => {
const { data } = await api.get('/investment-planning/cd-rates'); const { data } = await api.get('/investment-planning/market-rates');
return data; return data;
}, },
}); });
// AI recommendation (on-demand) // Load saved recommendation — polls every 3s when processing
const aiMutation = useMutation({ const { data: savedRec } = useQuery<SavedRecommendation | null>({
mutationFn: async () => { queryKey: ['investment-planning-saved-recommendation'],
const { data } = await api.post('/investment-planning/recommendations'); queryFn: async () => {
return data as AIResponse; const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
}, },
onSuccess: (data) => { refetchInterval: (query) => {
setAiResult(data); const rec = query.state.data;
if (data.recommendations.length > 0) { // Poll every 3 seconds while processing
notifications.show({ if (rec?.status === 'processing') return 3000;
message: `Generated ${data.recommendations.length} investment recommendations`, // Also poll if we just triggered (status may not be 'processing' yet)
color: 'green', if (isTriggering) return 3000;
}); return false;
}
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to get AI recommendations',
color: 'red',
});
}, },
}); });
// Derive display state from saved recommendation
const isProcessing = savedRec?.status === 'processing' || isTriggering;
const lastFailed = savedRec?.last_failed || false;
const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0;
const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length;
// Clear triggering flag once backend confirms processing or completes
useEffect(() => {
if (isTriggering && savedRec?.status === 'processing') {
setIsTriggering(false);
}
if (isTriggering && savedRec?.status === 'complete') {
setIsTriggering(false);
}
}, [savedRec?.status, isTriggering]);
// Ref for scrolling to AI section on completion
const aiSectionRef = useRef<HTMLDivElement>(null);
// Show notification when processing completes (transition from processing)
const prevStatusRef = useState<string | null>(null);
useEffect(() => {
const [prevStatus, setPrevStatus] = prevStatusRef;
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
notifications.show({
title: 'AI Analysis Complete',
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
color: 'green',
autoClose: 8000,
});
// Scroll the AI section into view so user sees the new results
setTimeout(() => {
aiSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 300);
}
if (prevStatus === 'processing' && savedRec?.status === 'error') {
notifications.show({
title: 'AI Analysis Failed',
message: savedRec.error_message || 'AI recommendation analysis failed',
color: 'red',
autoClose: 8000,
});
}
setPrevStatus(savedRec?.status || null);
}, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps
// Trigger AI recommendations (async — returns immediately)
const handleTriggerAI = useCallback(async () => {
setIsTriggering(true);
try {
await api.post('/investment-planning/recommendations');
} catch (err: any) {
setIsTriggering(false);
notifications.show({
message: err.response?.data?.message || 'Failed to start AI analysis',
color: 'red',
});
}
}, []);
// Build AI result from saved recommendation for display
const aiResult: AIResponse | null = hasResults
? {
recommendations: savedRec!.recommendations,
overall_assessment: savedRec!.overall_assessment,
risk_notes: savedRec!.risk_notes,
}
: (lastFailed && savedRec?.recommendations?.length)
? {
recommendations: savedRec!.recommendations,
overall_assessment: savedRec!.overall_assessment,
risk_notes: savedRec!.risk_notes,
}
: null;
if (snapshotLoading) { if (snapshotLoading) {
return ( return (
<Center h={400}> <Center h={400}>
@@ -173,6 +582,23 @@ export function InvestmentPlanningPage() {
const s = snapshot?.summary; const s = snapshot?.summary;
// Determine the latest fetched_at timestamp across all rate types
const allRatesList = [
...(marketRates?.cd || []),
...(marketRates?.money_market || []),
...(marketRates?.high_yield_savings || []),
];
const latestFetchedAt = allRatesList.length > 0
? allRatesList.reduce((latest, r) =>
new Date(r.fetched_at) > new Date(latest.fetched_at) ? r : latest,
).fetched_at
: null;
const totalRateCount =
(marketRates?.cd?.length || 0) +
(marketRates?.money_market?.length || 0) +
(marketRates?.high_yield_savings?.length || 0);
return ( return (
<Stack> <Stack>
{/* Page Header */} {/* Page Header */}
@@ -312,63 +738,77 @@ export function InvestmentPlanningPage() {
</Card> </Card>
)} )}
{/* ── Section 3: Market CD Rates ── */} {/* ── Section 3: Today's Market Rates (Collapsible with Tabs) ── */}
<Card withBorder p="lg"> <Card withBorder p="lg">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb={ratesExpanded ? 'md' : 0}>
<Title order={4}>Market CD Rates</Title> <Group gap="xs">
{cdRates.length > 0 && ( <Title order={4}>Today&apos;s Market Rates</Title>
<Text size="xs" c="dimmed"> {totalRateCount > 0 && (
Last fetched: {new Date(cdRates[0].fetched_at).toLocaleString()} <Badge size="sm" variant="light" color="gray">
</Text> {totalRateCount} rates
)} </Badge>
)}
</Group>
<Group gap="xs">
{latestFetchedAt && (
<Text size="xs" c="dimmed">
Last fetched: {new Date(latestFetchedAt).toLocaleString()}
</Text>
)}
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setRatesExpanded((v) => !v)}
title={ratesExpanded ? 'Collapse rates' : 'Expand rates'}
>
{ratesExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
</ActionIcon>
</Group>
</Group> </Group>
{ratesLoading ? (
<Center py="lg"> <Collapse in={ratesExpanded}>
<Loader /> {ratesLoading ? (
</Center> <Center py="lg">
) : ( <Loader />
<Table striped highlightOnHover> </Center>
<Table.Thead> ) : (
<Table.Tr> <Tabs defaultValue="cd">
<Table.Th>Bank</Table.Th> <Tabs.List>
<Table.Th ta="right">APY</Table.Th> <Tabs.Tab value="cd">
<Table.Th>Term</Table.Th> CDs {(marketRates?.cd?.length || 0) > 0 && (
<Table.Th ta="right">Min Deposit</Table.Th> <Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
</Table.Tr> )}
</Table.Thead> </Tabs.Tab>
<Table.Tbody> <Tabs.Tab value="money_market">
{cdRates.map((r, i) => ( Money Market {(marketRates?.money_market?.length || 0) > 0 && (
<Table.Tr key={i}> <Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
<Table.Td fw={500}>{r.bank_name}</Table.Td> )}
<Table.Td ta="right" fw={700} c="green"> </Tabs.Tab>
{parseFloat(r.apy).toFixed(2)}% <Tabs.Tab value="high_yield_savings">
</Table.Td> High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
<Table.Td>{r.term}</Table.Td> <Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
<Table.Td ta="right" ff="monospace"> )}
{r.min_deposit </Tabs.Tab>
? `$${parseFloat(r.min_deposit).toLocaleString()}` </Tabs.List>
: '-'}
</Table.Td> <Tabs.Panel value="cd" pt="sm">
</Table.Tr> <RateTable rates={marketRates?.cd || []} showTerm={true} />
))} </Tabs.Panel>
{cdRates.length === 0 && ( <Tabs.Panel value="money_market" pt="sm">
<Table.Tr> <RateTable rates={marketRates?.money_market || []} showTerm={false} />
<Table.Td colSpan={4}> </Tabs.Panel>
<Text ta="center" c="dimmed" py="lg"> <Tabs.Panel value="high_yield_savings" pt="sm">
No CD rates available. Run the fetch-cd-rates script to populate market data. <RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
</Text> </Tabs.Panel>
</Table.Td> </Tabs>
</Table.Tr> )}
)} </Collapse>
</Table.Tbody>
</Table>
)}
</Card> </Card>
<Divider /> <Divider />
{/* ── Section 4: AI Investment Recommendations ── */} {/* ── Section 4: AI Investment Recommendations ── */}
<Card withBorder p="lg"> <Card withBorder p="lg" ref={aiSectionRef}>
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Group gap="xs"> <Group gap="xs">
<ThemeIcon variant="light" color="grape" size="md"> <ThemeIcon variant="light" color="grape" size="md">
@@ -383,8 +823,8 @@ export function InvestmentPlanningPage() {
</Group> </Group>
<Button <Button
leftSection={<IconSparkles size={16} />} leftSection={<IconSparkles size={16} />}
onClick={() => aiMutation.mutate()} onClick={handleTriggerAI}
loading={aiMutation.isPending} loading={isProcessing}
variant="gradient" variant="gradient"
gradient={{ from: 'grape', to: 'violet' }} gradient={{ from: 'grape', to: 'violet' }}
> >
@@ -392,158 +832,47 @@ export function InvestmentPlanningPage() {
</Button> </Button>
</Group> </Group>
{/* Loading State */} {/* Processing State - shown as banner when refreshing with existing results */}
{aiMutation.isPending && ( {isProcessing && (
<Center py="xl"> <Alert variant="light" color="grape" mb="md" styles={{ root: { overflow: 'visible' } }}>
<Stack align="center" gap="sm"> <Group gap="sm">
<Loader size="lg" type="dots" /> <Loader size="sm" color="grape" />
<Text c="dimmed" size="sm"> <div style={{ flex: 1 }}>
Analyzing your financial data and market rates... <Text size="sm" fw={500}>
</Text> {aiResult ? 'Refreshing AI analysis...' : 'Running AI analysis...'}
<Text c="dimmed" size="xs"> </Text>
This may take up to 30 seconds <Text size="xs" c="dimmed">
</Text> Analyzing your financial data, accounts, budgets, and current market rates
</Stack> </Text>
</Center> </div>
</Group>
<Progress value={100} animated color="grape" size="xs" mt="xs" />
</Alert>
)} )}
{/* Results */} {/* Error State (no cached data) */}
{aiResult && !aiMutation.isPending && ( {hasError && !isProcessing && (
<Stack> <Alert color="red" variant="light" title="Analysis Failed" mb="md">
{/* Overall Assessment */} <Text size="sm">
<Alert color="blue" variant="light" title="Overall Assessment"> {savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
<Text size="sm">{aiResult.overall_assessment}</Text> </Text>
</Alert> </Alert>
{/* Risk Notes */}
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
<Alert
color="yellow"
variant="light"
title="Risk Notes"
icon={<IconAlertTriangle />}
>
<Stack gap={4}>
{aiResult.risk_notes.map((note, i) => (
<Text key={i} size="sm">
{note}
</Text>
))}
</Stack>
</Alert>
)}
{/* Recommendation Cards */}
{aiResult.recommendations.length > 0 ? (
<Accordion variant="separated">
{aiResult.recommendations.map((rec, i) => {
const Icon = typeIcons[rec.type] || IconBulb;
return (
<Accordion.Item key={i} value={`rec-${i}`}>
<Accordion.Control>
<Group>
<ThemeIcon
variant="light"
color={priorityColors[rec.priority] || 'gray'}
size="md"
>
<Icon size={16} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={600}>{rec.title}</Text>
<Badge
size="xs"
color={priorityColors[rec.priority]}
>
{rec.priority}
</Badge>
<Badge size="xs" variant="light">
{typeLabels[rec.type] || rec.type}
</Badge>
<Badge
size="xs"
variant="dot"
color={
rec.fund_type === 'reserve'
? 'violet'
: rec.fund_type === 'operating'
? 'blue'
: 'gray'
}
>
{rec.fund_type}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={2}>
{rec.summary}
</Text>
</div>
{rec.suggested_amount != null && (
<Text fw={700} ff="monospace" c="green" size="lg">
{fmt(rec.suggested_amount)}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm">{rec.details}</Text>
{(rec.suggested_term ||
rec.suggested_rate != null ||
rec.bank_name) && (
<Paper withBorder p="sm" radius="sm">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{rec.suggested_term && (
<div>
<Text size="xs" c="dimmed">
Suggested Term
</Text>
<Text fw={600}>{rec.suggested_term}</Text>
</div>
)}
{rec.suggested_rate != null && (
<div>
<Text size="xs" c="dimmed">
Target Rate
</Text>
<Text fw={600}>
{rec.suggested_rate}% APY
</Text>
</div>
)}
{rec.bank_name && (
<div>
<Text size="xs" c="dimmed">
Bank
</Text>
<Text fw={600}>{rec.bank_name}</Text>
</div>
)}
</SimpleGrid>
</Paper>
)}
<Alert variant="light" color="gray" title="Rationale">
<Text size="sm">{rec.rationale}</Text>
</Alert>
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text ta="center" c="dimmed" py="lg">
No specific recommendations at this time.
</Text>
)}
</Stack>
)} )}
{/* Empty State */} {/* Results - keep visible even while refreshing (with optional failure watermark) */}
{!aiResult && !aiMutation.isPending && ( {aiResult && (
<div style={isProcessing ? { opacity: 0.5, pointerEvents: 'none' } : undefined}>
<RecommendationsDisplay
aiResult={aiResult}
lastUpdated={savedRec?.created_at || undefined}
lastFailed={lastFailed}
onAddToPlan={handleAddToPlan}
/>
</div>
)}
{/* Empty State - only when no results and not processing */}
{!aiResult && !isProcessing && !hasError && (
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}> <Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md"> <ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
<IconSparkles size={28} /> <IconSparkles size={28} />
@@ -552,7 +881,7 @@ export function InvestmentPlanningPage() {
AI-Powered Investment Analysis AI-Powered Investment Analysis
</Text> </Text>
<Text c="dimmed" size="sm" maw={500} mx="auto"> <Text c="dimmed" size="sm" maw={500} mx="auto">
Click "Get AI Recommendations" to analyze your accounts, cash flow, Click &quot;Get AI Recommendations&quot; to analyze your accounts, cash flow,
budget, and capital projects against current market rates. The AI will budget, and capital projects against current market rates. The AI will
suggest specific investment moves to maximize interest income while suggest specific investment moves to maximize interest income while
maintaining adequate liquidity. maintaining adequate liquidity.
@@ -560,6 +889,77 @@ export function InvestmentPlanningPage() {
</Paper> </Paper>
)} )}
</Card> </Card>
{/* Add to Investment Plan Modal */}
<Modal opened={planModalOpen} onClose={() => setPlanModalOpen(false)} title="Add to Investment Plan">
<Stack>
{selectedRec && (
<Alert variant="light" color="blue">
<Text size="sm" fw={600}>{selectedRec.title}</Text>
{selectedRec.suggested_amount != null && (
<Text size="sm">Amount: {fmt(selectedRec.suggested_amount)}</Text>
)}
{selectedRec.components && selectedRec.components.length > 0 && (
<Stack gap={2} mt={6}>
<Text size="xs" c="dimmed" fw={600}>{selectedRec.components.length} investments will be created:</Text>
{selectedRec.components.map((c, i) => (
<Text key={i} size="xs" c="dimmed">
{c.label}: {fmt(c.amount)} @ {c.rate}% ({c.term_months} mo)
</Text>
))}
</Stack>
)}
</Alert>
)}
<DateInput
label="Start Date"
description="Purchase date for the investment(s). Maturity dates are calculated automatically from term length."
value={investmentStartDate}
onChange={setInvestmentStartDate}
/>
{investmentScenarios && investmentScenarios.length > 0 && (
<Select
label="Add to existing scenario"
placeholder="Select a scenario..."
data={investmentScenarios.map((s: any) => ({ value: s.id, label: s.name }))}
value={targetScenarioId}
onChange={setTargetScenarioId}
clearable
/>
)}
<Divider label="or" labelPosition="center" />
<TextInput
label="Create new scenario"
placeholder="e.g. Conservative Strategy"
value={newScenarioName}
onChange={(e) => { setNewScenarioName(e.target.value); setTargetScenarioId(null); }}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
{targetScenarioId && selectedRec && (
<Button
onClick={() => addToPlanMutation.mutate({ scenarioId: targetScenarioId, rec: selectedRec })}
loading={addToPlanMutation.isPending}
>
Add to Scenario
</Button>
)}
{newScenarioName && !targetScenarioId && selectedRec && (
<Button
onClick={() => createAndAddMutation.mutate({ name: newScenarioName, rec: selectedRec })}
loading={createAndAddMutation.isPending}
>
Create & Add
</Button>
)}
</Group>
</Stack>
</Modal>
</Stack> </Stack>
); );
} }

View File

@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react'; import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Investment { interface Investment {
id: string; name: string; institution: string; account_number_last4: string; id: string; name: string; institution: string; account_number_last4: string;
@@ -25,6 +26,7 @@ export function InvestmentsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Investment | null>(null); const [editing, setEditing] = useState<Investment | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: investments = [], isLoading } = useQuery<Investment[]>({ const { data: investments = [], isLoading } = useQuery<Investment[]>({
queryKey: ['investments'], queryKey: ['investments'],
@@ -76,6 +78,11 @@ export function InvestmentsPage() {
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0); const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0); const totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0);
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0; const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
const projectedInterest = investments.reduce((s, i) => {
const value = parseFloat(i.current_value || i.principal || '0');
const rate = parseFloat(i.interest_rate || '0');
return s + (value * rate / 100);
}, 0);
const daysRemainingColor = (days: number | null) => { const daysRemainingColor = (days: number | null) => {
if (days === null) return 'gray'; if (days === null) return 'gray';
@@ -90,12 +97,13 @@ export function InvestmentsPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Investment Accounts</Title> <Title order={2}>Investment Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button> {!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>}
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 4 }}> <SimpleGrid cols={{ base: 1, sm: 3, lg: 5 }}>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card> <Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card> <Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</Text></Card> <Card withBorder p="md"><Text size="xs" c="dimmed">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Projected Annual Interest</Text><Text fw={700} size="xl" c="blue">{fmt(projectedInterest)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card> <Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
</SimpleGrid> </SimpleGrid>
<Table striped highlightOnHover> <Table striped highlightOnHover>
@@ -133,7 +141,7 @@ export function InvestmentsPage() {
) : '-'} ) : '-'}
</Table.Td> </Table.Td>
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td> <Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td> <Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>} {investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}

View File

@@ -1,13 +1,12 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, Badge, Modal, Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, Loader, Center, Card, NumberInput, Select, Loader, Center, Card, Alert,
} from '@mantine/core'; } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconFileInvoice, IconSend } from '@tabler/icons-react'; import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
@@ -15,15 +14,55 @@ interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string; id: string; invoice_number: string; unit_number: string; unit_id: string;
invoice_date: string; due_date: string; invoice_type: string; invoice_date: string; due_date: string; invoice_type: string;
description: string; amount: string; amount_paid: string; balance_due: string; description: string; amount: string; amount_paid: string; balance_due: string;
status: string; status: string; period_start: string; period_end: string;
assessment_group_name: string; frequency: string; owner_name: string;
}
interface PreviewGroup {
id: string;
name: string;
frequency: string;
active_units: number;
regular_assessment: string;
special_assessment: string;
is_billing_month: boolean;
total_amount: number;
period_description: string;
}
interface Preview {
month: number;
year: number;
month_name: string;
groups: PreviewGroup[];
summary: {
total_groups_billing: number;
total_invoices: number;
total_amount: number;
};
} }
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark', draft: 'gray', pending: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
};
const frequencyColors: Record<string, string> = {
monthly: 'blue', quarterly: 'teal', annual: 'violet',
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
/** Extract last name from "First Last" format */
const getLastName = (ownerName: string | null) => {
if (!ownerName) return '-';
const parts = ownerName.trim().split(/\s+/);
return parts.length > 1 ? parts[parts.length - 1] : ownerName;
}; };
export function InvoicesPage() { export function InvoicesPage() {
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false); const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({ const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
@@ -35,13 +74,36 @@ export function InvoicesPage() {
initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() }, initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() },
}); });
// Fetch preview when month/year changes
const fetchPreview = async (month: number, year: number) => {
setPreviewLoading(true);
try {
const { data } = await api.post('/invoices/generate-preview', { month, year });
setPreview(data);
} catch {
setPreview(null);
}
setPreviewLoading(false);
};
useEffect(() => {
if (bulkOpened) {
fetchPreview(bulkForm.values.month, bulkForm.values.year);
}
}, [bulkOpened, bulkForm.values.month, bulkForm.values.year]);
const bulkMutation = useMutation({ const bulkMutation = useMutation({
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values), mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
onSuccess: (res) => { onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] }); queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
notifications.show({ message: `Generated ${res.data.created} invoices`, color: 'green' }); const groupInfo = res.data.groups?.map((g: any) => `${g.group_name}: ${g.invoices_created}`).join(', ') || '';
notifications.show({
message: `Generated ${res.data.created} invoices${groupInfo ? ` (${groupInfo})` : ''}`,
color: 'green',
});
closeBulk(); closeBulk();
setPreview(null);
}, },
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
}); });
@@ -54,8 +116,6 @@ export function InvoicesPage() {
}, },
}); });
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0); const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0);
@@ -66,18 +126,20 @@ export function InvoicesPage() {
<Title order={2}>Invoices</Title> <Title order={2}>Invoices</Title>
<Group> <Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button> <Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Monthly Invoices</Button> <Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group> </Group>
</Group> </Group>
<Group> <Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card> <Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(String(totalOutstanding))}</Text></Card> <Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(totalOutstanding)}</Text></Card>
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Date</Table.Th> <Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Owner</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Type</Table.Th><Table.Th ta="right">Amount</Table.Th> <Table.Th>Group</Table.Th><Table.Th>Date</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Period</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th> <Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -86,27 +148,104 @@ export function InvoicesPage() {
<Table.Tr key={i.id}> <Table.Tr key={i.id}>
<Table.Td fw={500}>{i.invoice_number}</Table.Td> <Table.Td fw={500}>{i.invoice_number}</Table.Td>
<Table.Td>{i.unit_number}</Table.Td> <Table.Td>{i.unit_number}</Table.Td>
<Table.Td>{getLastName(i.owner_name)}</Table.Td>
<Table.Td>
{i.assessment_group_name ? (
<Badge size="sm" variant="light" color={frequencyColors[i.frequency] || 'gray'}>
{i.assessment_group_name}
</Badge>
) : (
<Badge size="sm" variant="light">{i.invoice_type}</Badge>
)}
</Table.Td>
<Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td> <Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td>
<Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td> <Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{i.invoice_type}</Badge></Table.Td> <Table.Td>
{i.period_start && i.period_end ? (
<Text size="xs" c="dimmed">
{new Date(i.period_start).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}
{i.period_start !== i.period_end && (
<> - {new Date(i.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}</>
)}
</Text>
) : (
<Text size="xs" c="dimmed">-</Text>
)}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td> <Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td> <Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td> <Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td>
<Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td> <Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={9}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>} {invoices.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<Modal opened={bulkOpened} onClose={closeBulk} title="Generate Monthly Assessments">
<Modal opened={bulkOpened} onClose={() => { closeBulk(); setPreview(null); }} title="Generate Assessments" size="lg">
<form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}> <form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}>
<Stack> <Stack>
<Group grow> <Group grow>
<Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} /> <Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} />
<NumberInput label="Year" {...bulkForm.getInputProps('year')} /> <NumberInput label="Year" {...bulkForm.getInputProps('year')} />
</Group> </Group>
<Text size="sm" c="dimmed">This will generate invoices for all active units based on their monthly assessment amount.</Text>
<Button type="submit" loading={bulkMutation.isPending}>Generate Invoices</Button> {previewLoading && <Center py="md"><Loader size="sm" /></Center>}
{preview && !previewLoading && (
<Stack gap="xs">
<Text size="sm" fw={600}>Billing Preview for {preview.month_name} {preview.year}</Text>
{preview.groups.map((g) => (
<Card key={g.id} withBorder p="xs" style={{ opacity: g.is_billing_month ? 1 : 0.5 }}>
<Group justify="space-between">
<Group gap="xs">
{g.is_billing_month && g.active_units > 0
? <IconCheck size={16} color="green" />
: <IconX size={16} color="gray" />
}
<div>
<Group gap={6}>
<Text size="sm" fw={500}>{g.name}</Text>
<Badge size="xs" color={frequencyColors[g.frequency]} variant="light">
{g.frequency}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{g.is_billing_month
? `${g.active_units} units - ${g.period_description}`
: `Not a billing month for this group`
}
</Text>
</div>
</Group>
{g.is_billing_month && (
<Text size="sm" fw={500} ff="monospace">{fmt(g.total_amount)}</Text>
)}
</Group>
</Card>
))}
{preview.summary.total_invoices > 0 ? (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Will generate {preview.summary.total_invoices} invoices across{' '}
{preview.summary.total_groups_billing} group(s) totaling {fmt(preview.summary.total_amount)}
</Alert>
) : (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">
No assessment groups have billing scheduled for {preview.month_name}. No invoices will be generated.
</Alert>
)}
</Stack>
)}
<Button
type="submit"
loading={bulkMutation.isPending}
disabled={!preview || preview.summary.total_invoices === 0}
>
Generate {preview?.summary.total_invoices || 0} Invoices
</Button>
</Stack> </Stack>
</form> </form>
</Modal> </Modal>

View File

@@ -1,14 +1,17 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, NumberInput, Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Card, SimpleGrid, Badge, Alert, Select, Loader, Center, Card, SimpleGrid, Badge, Alert, Modal,
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { import {
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconEdit,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel'; import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine { interface ActualLine {
@@ -63,7 +66,15 @@ export function MonthlyActualsPage() {
const [month, setMonth] = useState(defaults.month); const [month, setMonth] = useState(defaults.month);
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({}); const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
const [savedJEId, setSavedJEId] = useState<string | null>(null); const [savedJEId, setSavedJEId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const yearOptions = Array.from({ length: 5 }, (_, i) => { const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i; const y = new Date().getFullYear() - 2 + i;
@@ -76,10 +87,15 @@ export function MonthlyActualsPage() {
const { data } = await api.get(`/monthly-actuals/${year}/${month}`); const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
setEditedAmounts({}); setEditedAmounts({});
setSavedJEId(data.existing_journal_entry_id || null); setSavedJEId(data.existing_journal_entry_id || null);
// Default to read mode if actuals already exist, edit mode if new
setIsEditing(!data.existing_journal_entry_id);
return data; return data;
}, },
}); });
// Whether actuals have been previously saved (reconciled)
const hasExistingActuals = !!savedJEId;
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const lines = (grid?.lines || []) const lines = (grid?.lines || [])
@@ -99,6 +115,8 @@ export function MonthlyActualsPage() {
queryClient.invalidateQueries({ queryKey: ['accounts'] }); queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] }); queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
setSavedJEId(data.journal_entry_id); setSavedJEId(data.journal_entry_id);
setIsEditing(false);
setEditedAmounts({});
notifications.show({ notifications.show({
message: data.message || 'Actuals saved and reconciled', message: data.message || 'Actuals saved and reconciled',
color: 'green', color: 'green',
@@ -123,6 +141,19 @@ export function MonthlyActualsPage() {
setEditedAmounts((prev) => ({ ...prev, [accountId]: value })); setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
}; };
const handleEditClick = () => {
if (hasExistingActuals) {
openConfirm();
} else {
setIsEditing(true);
}
};
const handleConfirmEdit = () => {
closeConfirm();
setIsEditing(true);
};
const lines = grid?.lines || []; const lines = grid?.lines || [];
const incomeLines = lines.filter((l) => l.account_type === 'income'); const incomeLines = lines.filter((l) => l.account_type === 'income');
const expenseLines = lines.filter((l) => l.account_type === 'expense'); const expenseLines = lines.filter((l) => l.account_type === 'expense');
@@ -135,7 +166,6 @@ export function MonthlyActualsPage() {
return { incomeBudget, incomeActual, expenseBudget, expenseActual }; return { incomeBudget, incomeActual, expenseBudget, expenseActual };
}, [lines, editedAmounts]); }, [lines, editedAmounts]);
const hasChanges = Object.keys(editedAmounts).length > 0;
const monthLabel = monthOptions.find((m) => m.value === month)?.label || ''; const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
@@ -161,7 +191,7 @@ export function MonthlyActualsPage() {
{title} {title}
</Table.Td> </Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td> <Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
<Table.Td /> <Table.Td ta="right" fw={700} ff="monospace">{fmt(actualTotal)}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace" <Table.Td ta="right" fw={700} ff="monospace"
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))} c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
> >
@@ -176,16 +206,16 @@ export function MonthlyActualsPage() {
<Table.Tr key={line.account_id}> <Table.Tr key={line.account_id}>
<Table.Td <Table.Td
style={{ style={{
position: 'sticky', left: 0, background: 'white', zIndex: 1, position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
borderRight: '1px solid #e9ecef', borderRight: `1px solid ${stickyBorder}`,
}} }}
> >
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text> <Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td> </Table.Td>
<Table.Td <Table.Td
style={{ style={{
position: 'sticky', left: 120, background: 'white', zIndex: 1, position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
borderRight: '1px solid #e9ecef', borderRight: `1px solid ${stickyBorder}`,
}} }}
> >
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
@@ -196,16 +226,21 @@ export function MonthlyActualsPage() {
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}> <Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
{fmt(line.budget_amount)} {fmt(line.budget_amount)}
</Table.Td> </Table.Td>
<Table.Td p={2} style={{ minWidth: 130 }}> <Table.Td p={isEditing ? 2 : undefined} style={{ minWidth: 130 }}>
<NumberInput {isEditing ? (
value={amount} <NumberInput
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)} value={amount}
size="xs" onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
hideControls size="xs"
decimalScale={2} hideControls
allowNegative decimalScale={2}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} allowNegative
/> disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ff="monospace" ta="right">{fmt(amount)}</Text>
)}
</Table.Td> </Table.Td>
<Table.Td <Table.Td
ta="right" ff="monospace" style={{ minWidth: 110 }} ta="right" ff="monospace" style={{ minWidth: 110 }}
@@ -229,14 +264,26 @@ export function MonthlyActualsPage() {
<Group> <Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} /> <Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} /> <Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
<Button {!isReadOnly && !isEditing && (
leftSection={<IconDeviceFloppy size={16} />} <Button
onClick={() => saveMutation.mutate()} leftSection={<IconEdit size={16} />}
loading={saveMutation.isPending} variant="light"
disabled={lines.length === 0} onClick={handleEditClick}
> disabled={lines.length === 0}
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'} >
</Button> Edit Actuals
</Button>
)}
{!isReadOnly && isEditing && (
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={lines.length === 0}
>
Save Actuals
</Button>
)}
</Group> </Group>
</Group> </Group>
@@ -271,7 +318,7 @@ export function MonthlyActualsPage() {
</Alert> </Alert>
)} )}
{savedJEId && ( {hasExistingActuals && !isEditing && (
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light"> <Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
<Group justify="space-between" align="flex-start"> <Group justify="space-between" align="flex-start">
<Text size="sm"> <Text size="sm">
@@ -287,10 +334,10 @@ export function MonthlyActualsPage() {
<Table striped highlightOnHover style={{ minWidth: 700 }}> <Table striped highlightOnHover style={{ minWidth: 700 }}>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}> <Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
Acct # Acct #
</Table.Th> </Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}> <Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>
Account Name Account Name
</Table.Th> </Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th> <Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
@@ -299,8 +346,8 @@ export function MonthlyActualsPage() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)} {renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)} {renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</div> </div>
@@ -312,6 +359,26 @@ export function MonthlyActualsPage() {
<AttachmentPanel journalEntryId={savedJEId} /> <AttachmentPanel journalEntryId={savedJEId} />
</Card> </Card>
)} )}
{/* Confirmation modal for editing reconciled actuals */}
<Modal opened={confirmOpened} onClose={closeConfirm} title="Edit Reconciled Actuals" centered>
<Stack>
<Text size="sm">
Actuals for <Text span fw={700}>{monthLabel} {year}</Text> have already been
reconciled. Editing will void the existing journal entry and create a new one
when you save.
</Text>
<Text size="sm" c="dimmed">
Press Edit to proceed, or Cancel to keep the current values.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeConfirm}>Cancel</Button>
<Button color="orange" leftSection={<IconEdit size={16} />} onClick={handleConfirmEdit}>
Edit
</Button>
</Group>
</Stack>
</Modal>
</Stack> </Stack>
); );
} }

View File

@@ -13,7 +13,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
interface OrgMember { interface OrgMember {
id: string; id: string;
@@ -52,6 +52,7 @@ export function OrgMembersPage() {
const [editingMember, setEditingMember] = useState<OrgMember | null>(null); const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user, currentOrg } = useAuthStore(); const { user, currentOrg } = useAuthStore();
const isReadOnly = useIsReadOnly();
const { data: members = [], isLoading } = useQuery<OrgMember[]>({ const { data: members = [], isLoading } = useQuery<OrgMember[]>({
queryKey: ['org-members'], queryKey: ['org-members'],
@@ -162,9 +163,11 @@ export function OrgMembersPage() {
<Title order={2}>Organization Members</Title> <Title order={2}>Organization Members</Title>
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text> <Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
</div> </div>
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}> {!isReadOnly && (
Add Member <Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
</Button> Add Member
</Button>
)}
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}> <SimpleGrid cols={{ base: 1, sm: 3 }}>
@@ -259,20 +262,22 @@ export function OrgMembersPage() {
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'} {member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4}> {!isReadOnly && (
<Tooltip label="Change role"> <Group gap={4}>
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}> <Tooltip label="Change role">
<IconEdit size={16} /> <ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
</ActionIcon> <IconEdit size={16} />
</Tooltip>
{member.userId !== user?.id && (
<Tooltip label="Remove member">
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
<IconTrash size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} {member.userId !== user?.id && (
</Group> <Tooltip label="Remove member">
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -1,25 +1,29 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, Badge, Modal, Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, TextInput, Loader, Center, NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { DateInput } from '@mantine/dates'; import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Payment { interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: string; id: string; unit_id: string; unit_number: string; invoice_id: string;
invoice_number: string; payment_date: string; amount: string; invoice_number: string; payment_date: string; amount: string;
payment_method: string; reference_number: string; status: string; payment_method: string; reference_number: string; status: string; notes: string;
} }
export function PaymentsPage() { export function PaymentsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Payment | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: payments = [], isLoading } = useQuery<Payment[]>({ const { data: payments = [], isLoading } = useQuery<Payment[]>({
queryKey: ['payments'], queryKey: ['payments'],
@@ -37,10 +41,18 @@ export function PaymentsPage() {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
invoice_id: '', amount: 0, payment_method: 'check', invoice_id: '', amount: 0, payment_method: 'check',
reference_number: '', payment_date: new Date(), reference_number: '', payment_date: new Date(), notes: '',
}, },
}); });
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
};
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (values: any) => { mutationFn: (values: any) => {
const inv = invoices.find((i: any) => i.id === values.invoice_id); const inv = invoices.find((i: any) => i.id === values.invoice_id);
@@ -51,22 +63,88 @@ export function PaymentsPage() {
}); });
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] }); invalidateAll();
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Payment recorded', color: 'green' }); notifications.show({ message: 'Payment recorded', color: 'green' });
close(); form.reset(); close(); setEditing(null); form.reset();
}, },
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
}); });
const updateMutation = useMutation({
mutationFn: (values: any) => {
return api.put(`/payments/${editing!.id}`, {
payment_date: values.payment_date.toISOString().split('T')[0],
amount: values.amount,
payment_method: values.payment_method,
reference_number: values.reference_number,
notes: values.notes,
});
},
onSuccess: () => {
invalidateAll();
notifications.show({ message: 'Payment updated', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/payments/${id}`),
onSuccess: () => {
invalidateAll();
notifications.show({ message: 'Payment deleted', color: 'orange' });
setDeleteConfirm(null);
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (payment: Payment) => {
setEditing(payment);
form.setValues({
invoice_id: payment.invoice_id || '',
amount: parseFloat(payment.amount || '0'),
payment_method: payment.payment_method || 'check',
reference_number: payment.reference_number || '',
payment_date: new Date(payment.payment_date),
notes: payment.notes || '',
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
const handleSubmit = (values: any) => {
if (editing) {
updateMutation.mutate(values);
} else {
createMutation.mutate(values);
}
};
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' }); const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const invoiceOptions = invoices.map((i: any) => ({ const formatPeriod = (inv: any) => {
value: i.id, if (inv.period_start && inv.period_end) {
label: `${i.invoice_number} - ${i.unit_number || 'Unit'} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`, const start = new Date(inv.period_start).toLocaleDateString(undefined, { month: 'short' });
})); const end = new Date(inv.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
return inv.period_start === inv.period_end ? start : `${start}-${end}`;
}
return '';
};
const invoiceOptions = invoices.map((i: any) => {
const period = formatPeriod(i);
const periodStr = period ? ` - ${period}` : '';
return {
value: i.id,
label: `${i.invoice_number} - ${i.unit_number || 'Unit'}${periodStr} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
};
});
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
@@ -74,7 +152,7 @@ export function PaymentsPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Payments</Title> <Title order={2}>Payments</Title>
<Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button> {!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Record Payment</Button>}
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
@@ -82,6 +160,7 @@ export function PaymentsPage() {
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th> <Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th> <Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th> <Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -94,18 +173,34 @@ export function PaymentsPage() {
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td> <Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
<Table.Td>{p.reference_number}</Table.Td> <Table.Td>{p.reference_number}</Table.Td>
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td> <Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit payment">
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr> </Table.Tr>
))} ))}
{payments.length === 0 && ( {payments.length === 0 && (
<Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr> <Table.Tr><Table.Td colSpan={isReadOnly ? 7 : 8}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
)} )}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<Modal opened={opened} onClose={close} title="Record Payment">
<form onSubmit={form.onSubmit((v) => createMutation.mutate(v))}> {/* Create / Edit Payment Modal */}
<Modal opened={opened} onClose={() => { close(); setEditing(null); form.reset(); }} title={editing ? 'Edit Payment' : 'Record Payment'}>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<Select label="Invoice" required data={invoiceOptions} searchable {!editing && (
{...form.getInputProps('invoice_id')} /> <Select label="Invoice" required data={invoiceOptions} searchable
{...form.getInputProps('invoice_id')} />
)}
{editing && (
<TextInput label="Invoice" value={editing.invoice_number || 'N/A'} disabled />
)}
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} /> <DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01} <NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
{...form.getInputProps('amount')} /> {...form.getInputProps('amount')} />
@@ -116,10 +211,60 @@ export function PaymentsPage() {
]} {...form.getInputProps('payment_method')} /> ]} {...form.getInputProps('payment_method')} />
<TextInput label="Reference Number" placeholder="Check # or transaction ID" <TextInput label="Reference Number" placeholder="Check # or transaction ID"
{...form.getInputProps('reference_number')} /> {...form.getInputProps('reference_number')} />
<Button type="submit" loading={createMutation.isPending}>Record Payment</Button> <TextInput label="Notes" placeholder="Optional notes"
{...form.getInputProps('notes')} />
<Group justify="space-between">
{editing ? (
<>
<Button
variant="outline"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => setDeleteConfirm(editing)}
>
Delete Payment
</Button>
<Button type="submit" loading={updateMutation.isPending}>
Update Payment
</Button>
</>
) : (
<Button type="submit" fullWidth loading={createMutation.isPending}>Record Payment</Button>
)}
</Group>
</Stack> </Stack>
</form> </form>
</Modal> </Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Delete Payment"
size="sm"
>
<Stack>
<Text size="sm">
Are you sure you want to delete this payment of{' '}
<Text span fw={700}>{deleteConfirm ? fmt(deleteConfirm.amount) : ''}</Text>{' '}
for unit {deleteConfirm?.unit_number}?
</Text>
<Text size="xs" c="dimmed">
This will also remove the associated journal entry and recalculate the invoice balance.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
>
Delete Payment
</Button>
</Group>
</Stack>
</Modal>
</Stack> </Stack>
); );
} }

View File

@@ -6,9 +6,11 @@ import {
IconUser, IconPalette, IconClock, IconBell, IconEye, IconUser, IconPalette, IconClock, IconBell, IconEye,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
export function UserPreferencesPage() { export function UserPreferencesPage() {
const { user, currentOrg } = useAuthStore(); const { user, currentOrg } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
return ( return (
<Stack> <Stack>
@@ -66,7 +68,10 @@ export function UserPreferencesPage() {
<Text size="sm">Dark Mode</Text> <Text size="sm">Dark Mode</Text>
<Text size="xs" c="dimmed">Switch to dark color theme</Text> <Text size="xs" c="dimmed">Switch to dark color theme</Text>
</div> </div>
<Switch disabled /> <Switch
checked={colorScheme === 'dark'}
onChange={toggleColorScheme}
/>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<div> <div>
@@ -76,7 +81,7 @@ export function UserPreferencesPage() {
<Switch disabled /> <Switch disabled />
</Group> </Group>
<Divider /> <Divider />
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text> <Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
</Stack> </Stack>
</Card> </Card>

View File

@@ -12,6 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen }
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv'; import { parseCSV, downloadBlob } from '../../utils/csv';
import { useIsReadOnly } from '../../stores/authStore';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types & constants // Types & constants
@@ -78,6 +79,7 @@ export function ProjectsPage() {
const [editing, setEditing] = useState<Project | null>(null); const [editing, setEditing] = useState<Project | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
// ---- Data fetching ---- // ---- Data fetching ----
@@ -331,14 +333,16 @@ export function ProjectsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}> <Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
Export CSV Export CSV
</Button> </Button>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()} {!isReadOnly && (<>
loading={importMutation.isPending}> <Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
Import CSV loading={importMutation.isPending}>
</Button> Import CSV
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} /> </Button>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}> <input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
+ Add Project <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
</Button> + Add Project
</Button>
</>)}
</Group> </Group>
</Group> </Group>
@@ -451,9 +455,11 @@ export function ProjectsPage() {
</Table.Td> </Table.Td>
<Table.Td>{formatDate(p.planned_date)}</Table.Td> <Table.Td>{formatDate(p.planned_date)}</Table.Td>
<Table.Td> <Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}> {!isReadOnly && (
<IconEdit size={16} /> <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
</ActionIcon> <IconEdit size={16} />
</ActionIcon>
)}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -5,6 +5,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualLine { interface BudgetVsActualLine {
account_id: string; account_id: string;
@@ -46,6 +47,9 @@ const monthFilterOptions = [
export function BudgetVsActualPage() { export function BudgetVsActualPage() {
const [year, setYear] = useState(new Date().getFullYear().toString()); const [year, setYear] = useState(new Date().getFullYear().toString());
const [month, setMonth] = useState(''); const [month, setMonth] = useState('');
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const yearOptions = Array.from({ length: 5 }, (_, i) => { const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i; const y = new Date().getFullYear() - 2 + i;
@@ -92,7 +96,7 @@ export function BudgetVsActualPage() {
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => ( const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
<> <>
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}> <Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
<Table.Td colSpan={6} fw={700}>{title}</Table.Td> <Table.Td colSpan={6} fw={700}>{title}</Table.Td>
</Table.Tr> </Table.Tr>
{sectionLines.map((line) => { {sectionLines.map((line) => {

View File

@@ -6,7 +6,7 @@ import {
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
IconCash, IconArrowUpRight, IconArrowDownRight, IconCash, IconArrowUpRight, IconArrowDownRight,
IconWallet, IconReportMoney, IconSearch, IconWallet, IconReportMoney, IconSearch, IconHeartRateMonitor,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import api from '../../services/api'; import api from '../../services/api';
@@ -58,6 +58,16 @@ export function CashFlowPage() {
}, },
}); });
const { data: aiRec } = useQuery<{ overall_assessment?: string; risk_notes?: string[] } | null>({
queryKey: ['saved-recommendation'],
queryFn: async () => {
try {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
} catch { return null; }
},
});
const handleApply = () => { const handleApply = () => {
setQueryFrom(fromDate); setQueryFrom(fromDate);
setQueryTo(toDate); setQueryTo(toDate);
@@ -68,6 +78,10 @@ export function CashFlowPage() {
const totalOperating = parseFloat(data?.total_operating || '0'); const totalOperating = parseFloat(data?.total_operating || '0');
const totalReserve = parseFloat(data?.total_reserve || '0'); const totalReserve = parseFloat(data?.total_reserve || '0');
const opInflows = (data?.operating_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0);
const opOutflows = Math.abs((data?.operating_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0));
const resInflows = (data?.reserve_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0);
const resOutflows = Math.abs((data?.reserve_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0));
const beginningCash = parseFloat(data?.beginning_cash || '0'); const beginningCash = parseFloat(data?.beginning_cash || '0');
const endingCash = parseFloat(data?.ending_cash || '0'); const endingCash = parseFloat(data?.ending_cash || '0');
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash'; const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
@@ -132,10 +146,14 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm"> <ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />} {totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
</ThemeIcon> </ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Operating</Text> <Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Activity</Text>
</Group> </Group>
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}> <Group justify="space-between" mb={4}>
{fmt(totalOperating)} <Text size="xs" c="green">In: {fmt(opInflows)}</Text>
<Text size="xs" c="red">Out: {fmt(opOutflows)}</Text>
</Group>
<Text fw={700} size="lg" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
{totalOperating >= 0 ? '+' : ''}{fmt(totalOperating)}
</Text> </Text>
</Card> </Card>
<Card withBorder p="md"> <Card withBorder p="md">
@@ -143,20 +161,31 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm"> <ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
<IconReportMoney size={14} /> <IconReportMoney size={14} />
</ThemeIcon> </ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Reserve</Text> <Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Activity</Text>
</Group> </Group>
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}> <Group justify="space-between" mb={4}>
{fmt(totalReserve)} <Text size="xs" c="green">In: {fmt(resInflows)}</Text>
<Text size="xs" c="red">Out: {fmt(resOutflows)}</Text>
</Group>
<Text fw={700} size="lg" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
{totalReserve >= 0 ? '+' : ''}{fmt(totalReserve)}
</Text> </Text>
</Card> </Card>
<Card withBorder p="md"> <Card withBorder p="md">
<Group gap="xs" mb={4}> <Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="teal" size="sm"> <ThemeIcon variant="light" color={aiRec?.overall_assessment ? 'teal' : 'gray'} size="sm">
<IconCash size={14} /> <IconHeartRateMonitor size={14} />
</ThemeIcon> </ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending {balanceLabel}</Text> <Text size="xs" c="dimmed" tt="uppercase" fw={700}>Financial Health</Text>
</Group> </Group>
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text> {aiRec?.overall_assessment ? (
<Text fw={600} size="sm" lineClamp={3}>{aiRec.overall_assessment}</Text>
) : (
<>
<Text fw={700} size="xl" c="dimmed">TBD</Text>
<Text size="xs" c="dimmed">Pending AI Analysis</Text>
</>
)}
</Card> </Card>
</SimpleGrid> </SimpleGrid>

View File

@@ -0,0 +1,296 @@
import { useState } from 'react';
import {
Title, Table, Group, Stack, Text, Card, Loader, Center,
Badge, SimpleGrid, Select, ThemeIcon, Alert,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
} from '@tabler/icons-react';
import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualItem {
account_id: string;
account_number: string;
name: string;
account_type: string;
fund_type: string;
quarter_budget: number;
quarter_actual: number;
quarter_variance: number;
ytd_budget: number;
ytd_actual: number;
ytd_variance: number;
variance_pct?: string;
}
interface IncomeStatement {
income: { name: string; amount: string; fund_type: string }[];
expenses: { name: string; amount: string; fund_type: string }[];
total_income: string;
total_expenses: string;
net_income: string;
}
interface QuarterlyData {
year: number;
quarter: number;
quarter_label: string;
date_range: { from: string; to: string };
quarter_income_statement: IncomeStatement;
ytd_income_statement: IncomeStatement;
budget_vs_actual: BudgetVsActualItem[];
over_budget_items: BudgetVsActualItem[];
}
export function QuarterlyReportPage() {
const now = new Date();
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter;
const defaultYear = now.getFullYear();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const [year, setYear] = useState(String(defaultYear));
const [quarter, setQuarter] = useState(String(defaultQuarter));
const { data, isLoading } = useQuery<QuarterlyData>({
queryKey: ['quarterly-report', year, quarter],
queryFn: async () => {
const { data } = await api.get(`/reports/quarterly?year=${year}&quarter=${quarter}`);
return data;
},
});
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = now.getFullYear() - 2 + i;
return { value: String(y), label: String(y) };
});
const quarterOptions = [
{ value: '1', label: 'Q1 (Jan-Mar)' },
{ value: '2', label: 'Q2 (Apr-Jun)' },
{ value: '3', label: 'Q3 (Jul-Sep)' },
{ value: '4', label: 'Q4 (Oct-Dec)' },
];
if (isLoading) return <Center h={300}><Loader /></Center>;
const qIS = data?.quarter_income_statement;
const ytdIS = data?.ytd_income_statement;
const bva = data?.budget_vs_actual || [];
const overBudget = data?.over_budget_items || [];
const qRevenue = parseFloat(qIS?.total_income || '0');
const qExpenses = parseFloat(qIS?.total_expenses || '0');
const qNet = parseFloat(qIS?.net_income || '0');
const ytdNet = parseFloat(ytdIS?.net_income || '0');
const incomeItems = bva.filter((b) => b.account_type === 'income');
const expenseItems = bva.filter((b) => b.account_type === 'expense');
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Quarterly Financial Report</Title>
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={quarterOptions} value={quarter} onChange={(v) => v && setQuarter(v)} w={160} />
</Group>
</Group>
{data && (
<Text size="sm" c="dimmed">
{data.quarter_label} &middot; {new Date(data.date_range.from + 'T00:00:00').toLocaleDateString()} {new Date(data.date_range.to + 'T00:00:00').toLocaleDateString()}
</Text>
)}
{/* Summary Cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }}>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="green" size="sm"><IconTrendingUp size={14} /></ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Revenue</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c="green">{fmt(qRevenue)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="red" size="sm"><IconTrendingDown size={14} /></ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Expenses</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c="red">{fmt(qExpenses)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color={qNet >= 0 ? 'green' : 'red'} size="sm">
<IconChartBar size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Net</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={qNet >= 0 ? 'green' : 'red'}>{fmt(qNet)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color={ytdNet >= 0 ? 'green' : 'red'} size="sm">
<IconChartBar size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>YTD Net</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={ytdNet >= 0 ? 'green' : 'red'}>{fmt(ytdNet)}</Text>
</Card>
</SimpleGrid>
{/* Over-Budget Alert */}
{overBudget.length > 0 && (
<Card withBorder>
<Group mb="md">
<IconAlertTriangle size={20} color="var(--mantine-color-orange-6)" />
<Title order={4}>Over-Budget Items ({overBudget.length})</Title>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Budget</Table.Th>
<Table.Th ta="right">Actual</Table.Th>
<Table.Th ta="right">Over By</Table.Th>
<Table.Th ta="right">% Over</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{overBudget.map((item) => (
<Table.Tr key={item.account_id}>
<Table.Td>
<Text size="sm" fw={500}>{item.name}</Text>
<Text size="xs" c="dimmed">{item.account_number}</Text>
</Table.Td>
<Table.Td>
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{item.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_variance)}</Table.Td>
<Table.Td ta="right">
<Badge color="red" variant="light" size="sm">+{item.variance_pct}%</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
{/* Budget vs Actuals */}
<Card withBorder>
<Title order={4} mb="md">Budget vs Actuals</Title>
{bva.length === 0 ? (
<Alert variant="light" color="blue">No budget or actual data for this quarter.</Alert>
) : (
<div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 900 }}>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Q Budget</Table.Th>
<Table.Th ta="right">Q Actual</Table.Th>
<Table.Th ta="right">Q Variance</Table.Th>
<Table.Th ta="right">YTD Budget</Table.Th>
<Table.Th ta="right">YTD Actual</Table.Th>
<Table.Th ta="right">YTD Variance</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{incomeItems.length > 0 && (
<Table.Tr style={{ background: incomeBg }}>
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
</Table.Tr>
)}
{incomeItems.map((item) => (
<BVARow key={item.account_id} item={item} isExpense={false} />
))}
{incomeItems.length > 0 && (
<Table.Tr style={{ background: incomeBg }}>
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
</Table.Tr>
)}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: expenseBg }}>
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
</Table.Tr>
)}
{expenseItems.map((item) => (
<BVARow key={item.account_id} item={item} isExpense={true} />
))}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: expenseBg }}>
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
)}
</Card>
</Stack>
);
}
function BVARow({ item, isExpense }: { item: BudgetVsActualItem; isExpense: boolean }) {
const fmt = (v: number) =>
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
// For expenses, over budget (positive variance) is bad (red)
// For income, under budget (negative variance) is bad (red)
const qVarianceColor = isExpense
? (item.quarter_variance > 0 ? 'red' : 'green')
: (item.quarter_variance < 0 ? 'red' : 'green');
const ytdVarianceColor = isExpense
? (item.ytd_variance > 0 ? 'red' : 'green')
: (item.ytd_variance < 0 ? 'red' : 'green');
return (
<Table.Tr>
<Table.Td>
<Text size="sm">{item.name}</Text>
<Text size="xs" c="dimmed">{item.account_number}</Text>
</Table.Td>
<Table.Td>
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{item.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c={item.quarter_variance !== 0 ? qVarianceColor : undefined}>
{fmt(item.quarter_variance)}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c={item.ytd_variance !== 0 ? ytdVarianceColor : undefined}>
{fmt(item.ytd_variance)}
</Table.Td>
</Table.Tr>
);
}

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