66 Commits

Author SHA1 Message Date
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
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
d9bb9363dd Add admin enhancements: impersonation, plan management, org status enforcement
Enhancement 1 - Block suspended/archived org access:
- Add org status check in switchOrganization() (auth.service.ts)
- Filter suspended/archived orgs from login response (generateTokenResponse)
- Add org status guard with 60s cache in TenantMiddleware
- Frontend: filter orgs in SelectOrgPage, add 403 handler in api.ts

Enhancement 2 - Change tenant plan level:
- Add updatePlanLevel() to organizations.service.ts
- Add PUT /admin/organizations/:id/plan endpoint
- Frontend: clickable plan dropdown in Organizations table + confirmation modal
- Plan level Select in tenant detail drawer

Enhancement 3 - User impersonation:
- Add impersonateUser() to auth.service.ts with impersonatedBy JWT claim
- Add POST /admin/impersonate/:userId endpoint
- Frontend: Impersonate button in Users tab (disabled for admins)
- Impersonation state management in authStore (start/stop/persist)
- Orange impersonation banner in AppLayout header with stop button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:21:59 -05:00
e156cf7c87 Fix TypeORM entity type for confirmationNumber column
TypeORM needs explicit type: 'varchar' for nullable string columns
to avoid reflecting the union type as Object.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:53:54 -05:00
76ab63a200 Fix TypeScript nullable types for subscription fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:53:15 -05:00
a32d4cc179 Add comprehensive platform administration panel
- Database: Add login_history, ai_recommendation_log tables; is_platform_owner
  column on users; subscription fields on organizations (payment_date,
  confirmation_number, renewal_date)
- Backend: New AdminAnalyticsService with platform metrics, tenant detail, and
  health score calculations (0-100 based on activity, budget, transactions,
  members, AI usage)
- Backend: Login/org-switch now records to login_history; AI recommendations
  logged to ai_recommendation_log; platform owner protected from superadmin toggle
- Frontend: 4-tab admin panel (Dashboard, Organizations, Users, Tenant Health)
  with tenant detail drawer, subscription management, health scoring visualization
- Platform owner account (admin@hoaledgeriq.com) auto-redirects to admin panel
- Seed data includes platform owner account and sample login history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:51:39 -05:00
0bd30a0eb8 Add 12-month cash flow forecast to AI investment context
The AI was making incorrect liquidity warnings because it only saw
current cash positions and annual budget totals — it had no visibility
into the month-by-month cash flow forecast that includes:
- Special assessment collections (reserve fund income from homeowners)
- Monthly budget income/expense breakdown (not just annual totals)
- Investment maturity schedule (when CDs return cash)
- Capital project expense timing

Now the AI receives:
1. Full assessment schedule (regular + special, with frequency)
2. 12-month forward forecast with projected operating/reserve cash,
   investment balances, and per-month income/expense drivers
3. Updated system prompt instructing the AI to use the forecast for
   liquidity analysis rather than just current balances

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:04:57 -05:00
0626b8d496 Increase nginx proxy timeout for AI recommendations endpoint
The AI recommendation endpoint calls a large language model (397B params)
which can take 60-120 seconds to respond. Nginx's default 60s proxy_read_timeout
was killing the connection before the response arrived. Added a dedicated
location block with 180s timeout for the recommendations endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:55:05 -05:00
25663fc79e Add AI debug logging and switch from fetch to https module
- Add toggle-able debug logging (AI_DEBUG env var) that logs prompts,
  request metadata, raw responses, parsed output, and full error chains
- Replace Node.js native fetch() with https module for Docker Alpine
  compatibility (fixes "fetch failed" error with large payloads)
- Reduce max_tokens from 16384 to 4096 (qwen3.5 doesn't need thinking
  token budget)
- Strip <think> blocks from model responses
- Add AI_DEBUG to docker-compose.yml and .env.example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:50:39 -05:00
fe4989bbcc Switch AI model from kimi-k2.5 to qwen/qwen3.5-397b-a17b
kimi-k2.5 is a thinking model that times out on complex prompts (>3min).
qwen3.5-397b-a17b responds in ~2s with clean JSON output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:37:48 -05:00
36271585d9 Fix AI timeout and token limits for thinking model (kimi-k2.5)
- Increase max_tokens from 4096 to 16384 to accommodate reasoning tokens
- Increase timeout from 90s to 180s for thinking model latency
- Add logging for response diagnostics (content length, reasoning, finish reason)
- Better error message when model exhausts tokens on reasoning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:50:53 -05:00
18c7989983 Fix: units table uses status='active' not is_active column
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:37:47 -05:00
c28d7aeffc Fix TypeScript: export interfaces for controller return type resolution
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:32:55 -05:00
f7e9c98bd9 Phase 5: AI investment planning - CD rate fetcher and AI recommendation engine
- Add shared.cd_rates table for cross-tenant market data (CD rates from Bankrate)
- Create standalone Puppeteer scraper script (scripts/fetch-cd-rates.ts) for cron-based rate fetching
- Add investment-planning backend module with 3 endpoints: snapshot, cd-rates, recommendations
- AI service gathers tenant financial data (accounts, investments, budgets, projects, cash flow) and calls OpenAI-compatible API (NVIDIA endpoint) for structured investment recommendations
- Create InvestmentPlanningPage with summary cards, current investments table, market CD rates table, and AI recommendation accordion
- Add Investment Planning to sidebar under Planning menu
- Configure AI_API_URL, AI_API_KEY, AI_MODEL environment variables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:31:32 -05:00
110 changed files with 17827 additions and 894 deletions

View File

@@ -5,3 +5,15 @@ DATABASE_URL=postgresql://hoafinance:change_me@postgres:5432/hoafinance
REDIS_URL=redis://redis:6379
JWT_SECRET=change_me_to_random_string
NODE_ENV=development
# AI Investment Advisor (OpenAI-compatible API)
AI_API_URL=https://integrate.api.nvidia.com/v1
AI_API_KEY=your_nvidia_api_key_here
AI_MODEL=qwen/qwen3.5-397b-a17b
# Set to 'true' to enable detailed AI prompt/response logging
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/
pgdata/
# Database backups
backups/
*.dump
*.dump.gz
# SSL
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 . .
# 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
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",
"version": "0.2.0",
"version": "2026.3.11",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {
@@ -8,7 +8,7 @@
"start": "nest start",
"start:dev": "nest start --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\"",
"test": "jest",
"test:watch": "jest --watch",
@@ -23,12 +23,16 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.1.0",
"ioredis": "^5.4.2",
"newrelic": "latest",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",

View File

@@ -1,9 +1,12 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module';
@@ -23,6 +26,9 @@ import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-g
import { ProjectsModule } from './modules/projects/projects.module';
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [
@@ -38,8 +44,19 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
autoLoadEntities: true,
synchronize: 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,
AuthModule,
OrganizationsModule,
@@ -60,8 +77,17 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
ProjectsModule,
MonthlyActualsModule,
AttachmentsModule,
InvestmentPlanningModule,
HealthScoresModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
],
})
export class AppModule implements NestModule {
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,
unit_count INTEGER DEFAULT 0,
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_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -155,8 +157,11 @@ export class TenantSchemaService {
amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00,
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),
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
@@ -202,6 +207,7 @@ export class TenantSchemaService {
default_account_id UUID REFERENCES "${s}".accounts(id),
is_active BOOLEAN DEFAULT TRUE,
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
last_negotiated DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
@@ -316,6 +322,38 @@ export class TenantSchemaService {
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)
`CREATE TABLE "${s}".attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

View File

@@ -1,5 +1,6 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { Request, Response, NextFunction } from 'express';
import * as jwt from 'jsonwebtoken';
@@ -12,9 +13,16 @@ export interface TenantRequest extends Request {
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private configService: ConfigService) {}
// In-memory cache for org info to avoid DB hit per request
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
private static readonly CACHE_TTL = 60_000; // 60 seconds
use(req: TenantRequest, _res: Response, next: NextFunction) {
constructor(
private configService: ConfigService,
private dataSource: DataSource,
) {}
async use(req: TenantRequest, res: Response, next: NextFunction) {
// Try to extract tenant info from Authorization header JWT
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
@@ -22,11 +30,25 @@ export class TenantMiddleware implements NestMiddleware {
const token = authHeader.substring(7);
const secret = this.configService.get<string>('JWT_SECRET');
const decoded = jwt.verify(token, secret!) as any;
if (decoded?.orgSchema) {
req.tenantSchema = decoded.orgSchema;
if (decoded?.orgId) {
// Look up org info (status + schema) from orgId with caching
const orgInfo = await this.getOrgInfo(decoded.orgId);
if (orgInfo) {
if (['suspended', 'archived'].includes(orgInfo.status)) {
res.status(403).json({
statusCode: 403,
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
});
return;
}
req.tenantSchema = orgInfo.schemaName;
}
req.orgId = decoded.orgId;
req.userId = decoded.sub;
req.userRole = decoded.role;
} else if (decoded?.sub) {
// Superadmin or user without org — still set userId
req.userId = decoded.sub;
}
} catch {
// Token invalid or expired - let Passport handle the auth error
@@ -34,4 +56,28 @@ export class TenantMiddleware implements NestMiddleware {
}
next();
}
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
const cached = this.orgCache.get(orgId);
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
return { status: cached.status, schemaName: cached.schemaName };
}
try {
const result = await this.dataSource.query(
`SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (result.length > 0) {
this.orgCache.set(orgId, {
status: result[0].status,
schemaName: result[0].schemaName,
cachedAt: Date.now(),
});
return { status: result[0].status, schemaName: result[0].schemaName };
}
} catch {
// Non-critical — don't block requests on cache miss errors
}
return null;
}
}

View File

@@ -1,18 +1,72 @@
import * as _cluster from 'node:cluster';
import * as os from 'node:os';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
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() {
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');
// Request logging
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
app.use(
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(
new ValidationPipe({
@@ -22,21 +76,24 @@ async function bootstrap() {
}),
);
// CORS — in production nginx handles this; accept all origins behind the proxy
app.enableCors({
origin: ['http://localhost', 'http://localhost:5173'],
origin: isProduction ? true : ['http://localhost', 'http://localhost:5173'],
credentials: true,
});
// Swagger docs — disabled in production to avoid exposing API surface
if (!isProduction) {
const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ')
.setVersion('0.1.0')
.setVersion('2026.3.11')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
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) {

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';
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()
export class AssessmentGroupsService {
constructor(private tenant: TenantService) {}
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
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) {
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
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');
}
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(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
`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, $8, $9) RETURNING *`,
[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];
}
async update(id: string, dto: any) {
await this.findOne(id);
const existing = await this.findOne(id);
if (dto.isDefault === 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.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);
sets.push('updated_at = NOW()');

View File

@@ -0,0 +1,325 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class AdminAnalyticsService {
private readonly logger = new Logger(AdminAnalyticsService.name);
constructor(private dataSource: DataSource) {}
/**
* Platform-wide metrics for the admin dashboard.
*/
async getPlatformMetrics() {
const [
userStats,
orgStats,
planBreakdown,
statusBreakdown,
newTenantsPerMonth,
newUsersPerMonth,
aiStats,
activeUsers30d,
] = await Promise.all([
this.dataSource.query(`
SELECT
COUNT(*) as total_users,
COUNT(*) FILTER (WHERE is_superadmin = true) as superadmin_count,
COUNT(*) FILTER (WHERE is_platform_owner = true) as platform_owner_count
FROM shared.users
`),
this.dataSource.query(`
SELECT
COUNT(*) as total_organizations,
COUNT(*) FILTER (WHERE status = 'active') as active_count,
COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
COUNT(*) FILTER (WHERE status = 'suspended') as suspended_count,
COUNT(*) FILTER (WHERE status = 'trial') as trial_count
FROM shared.organizations
`),
this.dataSource.query(`
SELECT plan_level, COUNT(*) as count
FROM shared.organizations
WHERE status != 'archived'
GROUP BY plan_level
ORDER BY count DESC
`),
this.dataSource.query(`
SELECT status, COUNT(*) as count
FROM shared.organizations
GROUP BY status
ORDER BY count DESC
`),
this.dataSource.query(`
SELECT
DATE_TRUNC('month', created_at) as month,
COUNT(*) as count
FROM shared.organizations
WHERE created_at > NOW() - INTERVAL '6 months'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month DESC
`),
this.dataSource.query(`
SELECT
DATE_TRUNC('month', created_at) as month,
COUNT(*) as count
FROM shared.users
WHERE created_at > NOW() - INTERVAL '6 months'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month DESC
`),
this.dataSource.query(`
SELECT
COUNT(*) as total_requests,
COUNT(*) FILTER (WHERE status = 'success') as successful,
ROUND(AVG(response_time_ms)) as avg_response_ms
FROM shared.ai_recommendation_log
WHERE requested_at > NOW() - INTERVAL '30 days'
`),
this.dataSource.query(`
SELECT COUNT(DISTINCT user_id) as count
FROM shared.login_history
WHERE logged_in_at > NOW() - INTERVAL '30 days'
`),
]);
return {
totalUsers: parseInt(userStats[0]?.total_users || '0'),
superadminCount: parseInt(userStats[0]?.superadmin_count || '0'),
platformOwnerCount: parseInt(userStats[0]?.platform_owner_count || '0'),
activeUsers30d: parseInt(activeUsers30d[0]?.count || '0'),
totalOrganizations: parseInt(orgStats[0]?.total_organizations || '0'),
activeOrganizations: parseInt(orgStats[0]?.active_count || '0'),
archivedOrganizations: parseInt(orgStats[0]?.archived_count || '0'),
suspendedOrganizations: parseInt(orgStats[0]?.suspended_count || '0'),
trialOrganizations: parseInt(orgStats[0]?.trial_count || '0'),
planBreakdown: planBreakdown.map((r: any) => ({
plan: r.plan_level,
count: parseInt(r.count),
})),
statusBreakdown: statusBreakdown.map((r: any) => ({
status: r.status,
count: parseInt(r.count),
})),
newTenantsPerMonth: newTenantsPerMonth.map((r: any) => ({
month: r.month,
count: parseInt(r.count),
})),
newUsersPerMonth: newUsersPerMonth.map((r: any) => ({
month: r.month,
count: parseInt(r.count),
})),
aiRequestsLast30d: parseInt(aiStats[0]?.total_requests || '0'),
aiSuccessfulLast30d: parseInt(aiStats[0]?.successful || '0'),
aiAvgResponseMs: parseInt(aiStats[0]?.avg_response_ms || '0'),
};
}
/**
* Detailed analytics for a specific tenant/organization.
*/
async getTenantDetail(orgId: string) {
const [orgInfo, loginStats, weeklyLogins, monthlyLogins, aiCount, memberCount] = await Promise.all([
this.dataSource.query(
`SELECT o.*, (SELECT MAX(logged_in_at) FROM shared.login_history WHERE organization_id = o.id) as last_login
FROM shared.organizations o WHERE o.id = $1`,
[orgId],
),
this.dataSource.query(
`SELECT
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '7 days') as logins_this_week,
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as logins_this_month,
COUNT(DISTINCT user_id) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d
FROM shared.login_history WHERE organization_id = $1`,
[orgId],
),
this.dataSource.query(
`SELECT
DATE_TRUNC('week', logged_in_at) as week,
COUNT(*) as count
FROM shared.login_history
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '4 weeks'
GROUP BY DATE_TRUNC('week', logged_in_at)
ORDER BY week DESC`,
[orgId],
),
this.dataSource.query(
`SELECT
DATE_TRUNC('month', logged_in_at) as month,
COUNT(*) as count
FROM shared.login_history
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '6 months'
GROUP BY DATE_TRUNC('month', logged_in_at)
ORDER BY month DESC`,
[orgId],
),
this.dataSource.query(
`SELECT COUNT(*) as count
FROM shared.ai_recommendation_log
WHERE organization_id = $1 AND requested_at > NOW() - INTERVAL '30 days'`,
[orgId],
),
this.dataSource.query(
`SELECT COUNT(*) as count FROM shared.user_organizations WHERE organization_id = $1 AND is_active = true`,
[orgId],
),
]);
const org = orgInfo[0];
if (!org) return null;
// Cross-schema queries for tenant financial data
let cashOnHand = 0;
let hasBudget = false;
let recentTransactions = 0;
try {
const cashResult = await this.dataSource.query(`
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM "${org.schema_name}".accounts a
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
JOIN "${org.schema_name}".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
GROUP BY a.id
) sub
`);
cashOnHand = parseFloat(cashResult[0]?.total || '0');
const budgetResult = await this.dataSource.query(
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
[new Date().getFullYear()],
);
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
const txnResult = await this.dataSource.query(`
SELECT COUNT(*) as count
FROM "${org.schema_name}".journal_entries
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
`);
recentTransactions = parseInt(txnResult[0]?.count || '0');
} catch (err) {
this.logger.warn(`Failed to query tenant schema ${org.schema_name}: ${err.message}`);
}
return {
organization: org,
lastLogin: org.last_login,
loginsThisWeek: parseInt(loginStats[0]?.logins_this_week || '0'),
loginsThisMonth: parseInt(loginStats[0]?.logins_this_month || '0'),
activeUsers30d: parseInt(loginStats[0]?.active_users_30d || '0'),
weeklyLogins: weeklyLogins.map((r: any) => ({
week: r.week,
count: parseInt(r.count),
})),
monthlyLogins: monthlyLogins.map((r: any) => ({
month: r.month,
count: parseInt(r.count),
})),
aiRecommendations30d: parseInt(aiCount[0]?.count || '0'),
memberCount: parseInt(memberCount[0]?.count || '0'),
cashOnHand,
hasBudget,
recentTransactions,
};
}
/**
* All tenants with health scores for the Health & Adoption tab.
*
* Health Score (0-100):
* Active users 30d ≥ 1 → 25pts
* Has current-year budget → 25pts
* Journal entries 30d ≥ 1 → 25pts
* 2+ active members → 15pts
* AI usage 30d ≥ 1 → 10pts
*/
async getAllTenantsHealth() {
const orgs = await this.dataSource.query(`
SELECT
o.id, o.name, o.schema_name, o.status, o.plan_level, o.created_at,
o.payment_date, o.renewal_date,
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id AND is_active = true) as member_count,
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_login,
(SELECT COUNT(DISTINCT lh.user_id) FROM shared.login_history lh WHERE lh.organization_id = o.id AND lh.logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d,
(SELECT COUNT(*) FROM shared.ai_recommendation_log ar WHERE ar.organization_id = o.id AND ar.requested_at > NOW() - INTERVAL '30 days') as ai_usage_30d
FROM shared.organizations o
WHERE o.status != 'archived'
ORDER BY o.name
`);
const currentYear = new Date().getFullYear();
const results = [];
for (const org of orgs) {
let cashOnHand = 0;
let hasBudget = false;
let journalEntries30d = 0;
try {
const cashResult = await this.dataSource.query(`
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM "${org.schema_name}".accounts a
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
JOIN "${org.schema_name}".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
GROUP BY a.id
) sub
`);
cashOnHand = parseFloat(cashResult[0]?.total || '0');
const budgetResult = await this.dataSource.query(
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
[currentYear],
);
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
const jeResult = await this.dataSource.query(`
SELECT COUNT(*) as count
FROM "${org.schema_name}".journal_entries
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
`);
journalEntries30d = parseInt(jeResult[0]?.count || '0');
} catch (err) {
// Schema may not exist yet (new tenant)
this.logger.warn(`Health check skip for ${org.schema_name}: ${err.message}`);
}
// Calculate health score
const activeUsers = parseInt(org.active_users_30d) || 0;
const memberCount = parseInt(org.member_count) || 0;
const aiUsage = parseInt(org.ai_usage_30d) || 0;
let healthScore = 0;
if (activeUsers >= 1) healthScore += 25;
if (hasBudget) healthScore += 25;
if (journalEntries30d >= 1) healthScore += 25;
if (memberCount >= 2) healthScore += 15;
if (aiUsage >= 1) healthScore += 10;
results.push({
id: org.id,
name: org.name,
schemaName: org.schema_name,
status: org.status,
planLevel: org.plan_level,
createdAt: org.created_at,
paymentDate: org.payment_date,
renewalDate: org.renewal_date,
memberCount,
lastLogin: org.last_login,
activeUsers30d: activeUsers,
aiUsage30d: aiUsage,
cashOnHand,
hasBudget,
journalEntries30d,
healthScore,
});
}
return results;
}
}

View File

@@ -1,8 +1,10 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { OrganizationsService } from '../organizations/organizations.service';
import { AdminAnalyticsService } from './admin-analytics.service';
import * as bcrypt from 'bcryptjs';
@ApiTags('admin')
@@ -11,8 +13,10 @@ import * as bcrypt from 'bcryptjs';
@UseGuards(JwtAuthGuard)
export class AdminController {
constructor(
private authService: AuthService,
private usersService: UsersService,
private orgService: OrganizationsService,
private analyticsService: AdminAnalyticsService,
) {}
private async requireSuperadmin(req: any) {
@@ -22,25 +26,93 @@ export class AdminController {
}
}
// ── Platform Metrics ──
@Get('metrics')
async getPlatformMetrics(@Req() req: any) {
await this.requireSuperadmin(req);
return this.analyticsService.getPlatformMetrics();
}
// ── Users ──
@Get('users')
async listUsers(@Req() req: any) {
await this.requireSuperadmin(req);
const users = await this.usersService.findAllUsers();
return users.map(u => ({
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
isSuperadmin: u.isSuperadmin, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
isSuperadmin: u.isSuperadmin, isPlatformOwner: u.isPlatformOwner || false,
lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
organizations: u.userOrganizations?.map(uo => ({
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
})) || [],
}));
}
// ── Organizations ──
@Get('organizations')
async listOrganizations(@Req() req: any) {
await this.requireSuperadmin(req);
return this.usersService.findAllOrganizations();
}
@Get('organizations/:id/detail')
async getTenantDetail(@Req() req: any, @Param('id') id: string) {
await this.requireSuperadmin(req);
const detail = await this.analyticsService.getTenantDetail(id);
if (!detail) {
throw new BadRequestException('Organization not found');
}
return detail;
}
@Put('organizations/:id/subscription')
async updateSubscription(
@Req() req: any,
@Param('id') id: string,
@Body() body: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string },
) {
await this.requireSuperadmin(req);
const org = await this.orgService.updateSubscription(id, body);
return { success: true, organization: org };
}
@Put('organizations/:id/status')
async updateOrgStatus(
@Req() req: any,
@Param('id') id: string,
@Body() body: { status: string },
) {
await this.requireSuperadmin(req);
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
if (!validStatuses.includes(body.status)) {
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
const org = await this.orgService.updateStatus(id, body.status);
return { success: true, organization: org };
}
// ── Plan Level ──
@Put('organizations/:id/plan')
async updateOrgPlan(
@Req() req: any,
@Param('id') id: string,
@Body() body: { planLevel: string },
) {
await this.requireSuperadmin(req);
const validPlans = ['standard', 'premium', 'enterprise'];
if (!validPlans.includes(body.planLevel)) {
throw new BadRequestException(`Invalid plan. Must be one of: ${validPlans.join(', ')}`);
}
const org = await this.orgService.updatePlanLevel(id, body.planLevel);
return { success: true, organization: org };
}
// ── Superadmin Toggle ──
@Post('users/:id/superadmin')
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
await this.requireSuperadmin(req);
@@ -48,6 +120,25 @@ export class AdminController {
return { success: true };
}
// ── User Impersonation ──
@Post('impersonate/:userId')
async impersonateUser(@Req() req: any, @Param('userId') userId: string) {
await this.requireSuperadmin(req);
const adminUserId = req.user.userId || req.user.sub;
return this.authService.impersonateUser(adminUserId, userId);
}
// ── Tenant Health ──
@Get('tenants-health')
async getTenantsHealth(@Req() req: any) {
await this.requireSuperadmin(req);
return this.analyticsService.getAllTenantsHealth();
}
// ── Create Tenant ──
@Post('tenants')
async createTenant(@Req() req: any, @Body() body: {
orgName: string;
@@ -105,19 +196,4 @@ export class AdminController {
return { success: true, organization: org };
}
@Put('organizations/:id/status')
async updateOrgStatus(
@Req() req: any,
@Param('id') id: string,
@Body() body: { status: string },
) {
await this.requireSuperadmin(req);
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
if (!validStatuses.includes(body.status)) {
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
const org = await this.orgService.updateStatus(id, body.status);
return { success: true, organization: org };
}
}

View File

@@ -1,6 +1,7 @@
import {
Controller,
Post,
Patch,
Body,
UseGuards,
Request,
@@ -8,11 +9,13 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { SwitchOrgDto } from './dto/switch-org.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
@ApiTags('auth')
@Controller('auth')
@@ -21,15 +24,19 @@ export class AuthController {
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
@UseGuards(AuthGuard('local'))
async login(@Request() req: any, @Body() _dto: LoginDto) {
return this.authService.login(req.user);
const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent'];
return this.authService.login(req.user, ip, ua);
}
@Get('profile')
@@ -40,11 +47,24 @@ export class AuthController {
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')
@ApiOperation({ summary: 'Switch active organization' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
return this.authService.switchOrganization(req.user.sub, dto.organizationId);
const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent'];
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
}
}

View File

@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AdminController } from './admin.controller';
import { AuthService } from './auth.service';
import { AdminAnalyticsService } from './admin-analytics.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
@@ -25,7 +26,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
}),
],
controllers: [AuthController, AdminController],
providers: [AuthService, JwtStrategy, LocalStrategy],
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -2,8 +2,11 @@ import {
Injectable,
UnauthorizedException,
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
@@ -14,6 +17,7 @@ export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private dataSource: DataSource,
) {}
async register(dto: RegisterDto) {
@@ -47,7 +51,7 @@ export class AuthService {
return user;
}
async login(user: User) {
async login(user: User, ipAddress?: string, userAgent?: string) {
await this.usersService.updateLastLogin(user.id);
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
const u = fullUser || user;
@@ -65,6 +69,9 @@ export class AuthService {
}
}
// Record login in history (org_id is null at initial login)
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
return this.generateTokenResponse(u);
}
@@ -86,7 +93,7 @@ export class AuthService {
};
}
async switchOrganization(userId: string, organizationId: string) {
async switchOrganization(userId: string, organizationId: string, ipAddress?: string, userAgent?: string) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
throw new UnauthorizedException('User not found');
@@ -99,26 +106,62 @@ export class AuthService {
throw new UnauthorizedException('Not a member of this organization');
}
// Block access to suspended/archived organizations
const orgStatus = membership.organization?.status;
if (orgStatus && ['suspended', 'archived'].includes(orgStatus)) {
throw new ForbiddenException(
`This organization has been ${orgStatus}. Please contact your administrator.`,
);
}
const payload = {
sub: user.id,
email: user.email,
orgId: membership.organizationId,
orgSchema: membership.organization.schemaName,
role: membership.role,
};
// Record org switch in login history
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
return {
accessToken: this.jwtService.sign(payload),
organization: {
id: membership.organization.id,
name: membership.organization.name,
role: membership.role,
settings: membership.organization.settings || {},
},
};
}
private generateTokenResponse(user: User) {
const orgs = user.userOrganizations || [];
async markIntroSeen(userId: string): Promise<void> {
await this.usersService.markIntroSeen(userId);
}
private async recordLoginHistory(
userId: string,
organizationId: string | null,
ipAddress?: string,
userAgent?: string,
) {
try {
await this.dataSource.query(
`INSERT INTO shared.login_history (user_id, organization_id, ip_address, user_agent)
VALUES ($1, $2, $3, $4)`,
[userId, organizationId, ipAddress || null, userAgent || null],
);
} catch (err) {
// Non-critical — don't let login history failure block auth
}
}
private generateTokenResponse(user: User, impersonatedBy?: string) {
const allOrgs = user.userOrganizations || [];
// Filter out suspended/archived organizations
const orgs = allOrgs.filter(
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
);
const defaultOrg = orgs[0];
const payload: Record<string, any> = {
@@ -127,9 +170,12 @@ export class AuthService {
isSuperadmin: user.isSuperadmin || false,
};
if (impersonatedBy) {
payload.impersonatedBy = impersonatedBy;
}
if (defaultOrg) {
payload.orgId = defaultOrg.organizationId;
payload.orgSchema = defaultOrg.organization?.schemaName;
payload.role = defaultOrg.role;
}
@@ -141,13 +187,26 @@ export class AuthService {
firstName: user.firstName,
lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
schemaName: uo.organization?.schemaName,
status: uo.organization?.status,
role: uo.role,
})),
};
}
async impersonateUser(adminUserId: string, targetUserId: string) {
const targetUser = await this.usersService.findByIdWithOrgs(targetUserId);
if (!targetUser) {
throw new NotFoundException('User not found');
}
if (targetUser.isSuperadmin) {
throw new ForbiddenException('Cannot impersonate another superadmin');
}
return this.generateTokenResponse(targetUser, adminUserId);
}
}

View File

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

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

@@ -0,0 +1,44 @@
import { Controller, Get, Post, UseGuards, Req } 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 { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning')
@Controller('investment-planning')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class InvestmentPlanningController {
constructor(private service: InvestmentPlanningService) {}
@Get('snapshot')
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
getSnapshot() {
return this.service.getFinancialSnapshot();
}
@Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
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')
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
@AllowViewer()
triggerRecommendations(@Req() req: any) {
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { InvestmentPlanningController } from './investment-planning.controller';
import { InvestmentPlanningService } from './investment-planning.service';
@Module({
controllers: [InvestmentPlanningController],
providers: [InvestmentPlanningService],
})
export class InvestmentPlanningModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,11 @@ export class InvoicesController {
@Get(':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')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub);

View File

@@ -1,33 +1,135 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
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()
export class InvoicesService {
constructor(private tenant: TenantService) {}
async findAll() {
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
FROM invoices i
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
`);
}
async findOne(id: string) {
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]);
if (!rows.length) throw new NotFoundException('Invoice not found');
return rows[0];
}
async generateBulk(dto: { month: number; year: number }, userId: string) {
const units = await this.tenant.query(
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 0`,
/**
* Calculate billing period based on frequency and the billing month.
*/
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
let fp = await this.tenant.query(
@@ -41,9 +143,32 @@ export class InvoicesService {
}
const fiscalPeriodId = fp[0].id;
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
const dueDate = new Date(dto.year, dto.month - 1, 15);
// Look up GL accounts once
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;
const groupResults: any[] = [];
for (const group of groups) {
// Get active units in this assessment group
const units = await this.tenant.query(
`SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`,
[group.id],
);
if (!units.length) continue;
const frequency = group.frequency || 'monthly';
const period = this.calculatePeriod(frequency, dto.month, dto.year);
const dueDay = Math.min(group.due_day || 1, 28);
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
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}`;
@@ -54,19 +179,24 @@ export class InvoicesService {
);
if (existing.length) continue;
// Create the invoice
// Use unit-level override if set, otherwise use group amount
const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0
? (frequency === 'monthly'
? 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)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
`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],
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
unit.monthly_assessment],
period.description, unitAmount, period.start, period.end, group.id],
);
// Create journal entry: DR Accounts Receivable, CR Assessment Income
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 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)
@@ -75,16 +205,25 @@ export class InvoicesService {
);
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, unit.monthly_assessment, incomeAccount[0].id],
[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++;
}
return { created, month: dto.month, year: dto.year };
groupResults.push({
group_name: group.name,
frequency,
period: period.description,
invoices_created: groupCreated,
});
}
return { created, month: dto.month, year: dto.year, groups: groupResults };
}
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(`
SELECT i.*, u.unit_number FROM invoices i
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 (
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 || '%'
@@ -109,7 +248,7 @@ export class InvoicesService {
const lfNum = `LF-${inv.invoice_number}`;
await this.tenant.query(
`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],
);
applied++;

View File

@@ -13,6 +13,16 @@ export class JournalEntriesService {
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
let sql = `
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(
'id', jel.id, 'account_id', jel.account_id,
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,

View File

@@ -61,6 +61,15 @@ export class Organization {
@Column({ name: 'plan_level', default: 'standard' })
planLevel: string;
@Column({ name: 'payment_date', type: 'date', nullable: true })
paymentDate: Date;
@Column({ name: 'confirmation_number', type: 'varchar', nullable: true })
confirmationNumber: string;
@Column({ name: 'renewal_date', type: 'date', nullable: true })
renewalDate: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;

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 { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
@@ -23,6 +23,13 @@ export class OrganizationsController {
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 ──
private requireTenantAdmin(req: any) {

View File

@@ -62,6 +62,29 @@ export class OrganizationsService {
return this.orgRepository.save(org);
}
async updatePlanLevel(id: string, planLevel: string) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new NotFoundException('Organization not found');
org.planLevel = planLevel;
return this.orgRepository.save(org);
}
async updateSubscription(id: string, data: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string }) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new NotFoundException('Organization not found');
if (data.paymentDate !== undefined) (org as any).paymentDate = data.paymentDate ? new Date(data.paymentDate) : null;
if (data.confirmationNumber !== undefined) (org as any).confirmationNumber = data.confirmationNumber || null;
if (data.renewalDate !== undefined) (org as any).renewalDate = data.renewalDate ? new Date(data.renewalDate) : null;
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) {
const memberships = await this.userOrgRepository.find({
where: { userId, isActive: true },
@@ -130,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role;
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 {
// Create new user
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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PaymentsService } from './payments.service';
@@ -18,4 +18,12 @@ export class PaymentsController {
@Post()
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]);
}
// Update invoice if linked
// Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error
if (invoice) {
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
const invoiceAmt = parseFloat(invoice.amount);
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
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`,
[newPaid, newStatus, invoice.id],
`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, newStatus, invoice.id],
);
}
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() {
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);
}
@@ -20,7 +20,7 @@ export class ProjectsService {
async findForPlanning() {
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);
}
@@ -157,6 +157,9 @@ export class ProjectsService {
const params: any[] = [];
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
const fields: [string, string][] = [
['name', 'name'], ['description', 'description'], ['category', 'category'],
@@ -175,7 +178,8 @@ export class ProjectsService {
for (const [dtoKey, dbCol] of fields) {
if (dto[dtoKey] !== undefined) {
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);
const rows = await this.tenant.query(
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
[id, planned_date],
[id, planned_date || null],
);
return rows[0];
}

View File

@@ -24,8 +24,16 @@ export class ReportsController {
}
@Get('cash-flow-sankey')
getCashFlowSankey(@Query('year') year?: string) {
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
getCashFlowSankey(
@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')
@@ -66,4 +74,20 @@ export class ReportsController {
const mo = Math.min(parseInt(months || '') || 24, 48);
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)
END 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
LEFT JOIN (
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 <= $1
) ON jel.account_id = a.id
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
HAVING CASE
@@ -32,6 +34,71 @@ export class ReportsService {
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
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 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);
@@ -54,10 +121,12 @@ export class ReportsService {
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
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
LEFT JOIN (
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
) ON jel.account_id = a.id
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
HAVING CASE
@@ -83,33 +152,151 @@ export class ReportsService {
};
}
async getCashFlowSankey(year: number) {
// Get income accounts with amounts
const income = await this.tenant.query(`
async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
let income: any[];
let expenses: any[];
const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : '';
const fundParams = fundType !== 'all' ? [year, fundType] : [year];
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)`;
if (source === 'budget') {
income = await this.tenant.query(`
SELECT a.name, 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 = 'income' AND a.is_active = true${fundCondition}
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
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
`, [year]);
`, fundParams);
const expenses = await this.tenant.query(`
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
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
`, [year]);
`, fundParams);
}
if (!income.length && !expenses.length) {
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
@@ -222,20 +409,20 @@ export class ReportsService {
ORDER BY a.name
`, [from, to]);
// Asset filter: cash-only vs cash + investment accounts
const assetFilter = includeInvestments
? `a.account_type = 'asset'`
: `a.account_type = 'asset' AND a.name LIKE '%Cash%'`;
// Asset filter: all asset accounts (bank/checking/savings are the cash accounts)
const assetFilter = `a.account_type = 'asset'`;
// Cash beginning and ending balances
const beginCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
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
LEFT JOIN (
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 < $1
) ON jel.account_id = a.id
WHERE ${assetFilter} AND a.is_active = true
GROUP BY a.id
) sub
@@ -245,10 +432,12 @@ export class ReportsService {
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
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
LEFT JOIN (
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 <= $1
) ON jel.account_id = a.id
WHERE ${assetFilter} AND a.is_active = true
GROUP BY a.id
) sub
@@ -273,7 +462,8 @@ export class ReportsService {
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 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 {
from, to,
@@ -360,19 +550,22 @@ export class ReportsService {
const incomeStmt = await this.getIncomeStatement(from, 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(`
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
JOIN (
SELECT vendor_id, amount FROM invoices
WHERE EXTRACT(YEAR FROM invoice_date) = $1
AND status IN ('paid', 'partial')
) p ON p.vendor_id = v.id
LEFT JOIN (
SELECT jel.account_id, jel.debit as amount
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_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
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
`, [year]);
@@ -444,24 +637,43 @@ export class ReportsService {
}
async getDashboardKPIs() {
// Total cash: ALL asset accounts (not just those named "Cash")
// Uses proper double-entry balance: debit - credit for assets
const cash = await this.tenant.query(`
// Operating cash (asset accounts, fund_type=operating)
const opCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
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.is_active = true
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
GROUP BY a.id
) sub
`);
// Also include investment account current_value in total cash
const investmentCash = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE is_active = true
// Reserve cash (asset accounts, fund_type=reserve)
const resCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
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
const ar = await this.tenant.query(`
@@ -469,9 +681,7 @@ export class ReportsService {
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
`);
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100)
// 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
// Reserve fund balance via equity accounts + reserve investments
const reserves = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
@@ -482,17 +692,43 @@ export class ReportsService {
GROUP BY a.id
) 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)
const delinquent = await this.tenant.query(`
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: approximate from current_value - principal (unrealized gains)
const interestEarned = await this.tenant.query(`
SELECT COALESCE(SUM(current_value - principal), 0) as total
FROM investment_accounts WHERE is_active = true AND current_value > principal
`);
// Planned capital spend for current year
const currentYear = new Date().getFullYear();
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
const recentTx = await this.tenant.query(`
SELECT je.id, je.entry_date, je.description, je.entry_type,
@@ -504,9 +740,17 @@ export class ReportsService {
return {
total_cash: totalCash.toFixed(2),
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'),
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: interestEarned[0]?.total || '0.00',
planned_capital_spend: capitalSpend[0]?.total || '0.00',
};
}
@@ -795,4 +1039,168 @@ export class ReportsService {
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

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

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@@ -50,13 +50,23 @@ export class UsersService {
const dataSource = this.usersRepository.manager.connection;
return dataSource.query(`
SELECT o.*,
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count,
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_activity
FROM shared.organizations o
ORDER BY o.created_at DESC
`);
}
async markIntroSeen(id: string): Promise<void> {
await this.usersRepository.update(id, { hasSeenIntro: true });
}
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
// Protect platform owner from having superadmin removed
const user = await this.usersRepository.findOne({ where: { id: userId } });
if (user?.isPlatformOwner) {
throw new ForbiddenException('Cannot modify platform owner superadmin status');
}
await this.usersRepository.update(userId, { isSuperadmin });
}
}

View File

@@ -17,10 +17,10 @@ export class VendorsService {
async create(dto: any) {
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
`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, $12) RETURNING *`,
[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];
}
@@ -32,24 +32,25 @@ export class VendorsService {
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),
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 *`,
[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];
}
async exportCSV(): Promise<string> {
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`,
);
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(',')];
for (const r of rows) {
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);
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
}).join(','));
@@ -80,20 +81,22 @@ export class VendorsService {
zip_code = COALESCE(NULLIF($8, ''), zip_code),
tax_id = COALESCE(NULLIF($9, ''), tax_id),
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
updated_at = NOW()
WHERE id = $1`,
[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++;
} else {
await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
`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, $11)`,
[name, row.contact_name || null, row.email || null, row.phone || null,
row.address_line1 || null, row.city || null, row.state || 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++;
}

View File

@@ -26,6 +26,9 @@ CREATE TABLE shared.organizations (
email VARCHAR(255),
tax_id VARCHAR(20),
fiscal_year_start_month INTEGER DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12),
payment_date DATE,
confirmation_number VARCHAR(100),
renewal_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -45,6 +48,7 @@ CREATE TABLE shared.users (
oauth_provider_id VARCHAR(255),
last_login_at TIMESTAMPTZ,
is_superadmin BOOLEAN DEFAULT FALSE,
is_platform_owner BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -73,6 +77,43 @@ CREATE TABLE shared.invitations (
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Market Rates (cross-tenant market data for investment recommendations)
-- Supports CD, Money Market, and High Yield Savings rate types
CREATE TABLE shared.cd_rates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bank_name VARCHAR(255) NOT NULL,
apy DECIMAL(6,4) NOT NULL,
min_deposit DECIMAL(15,2),
term VARCHAR(100) NOT NULL,
term_months INTEGER,
rate_type VARCHAR(50) NOT NULL DEFAULT 'cd',
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Login history (track logins/org-switches for platform analytics)
CREATE TABLE shared.login_history (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address VARCHAR(45),
user_agent TEXT
);
-- AI recommendation log (track AI usage per tenant)
CREATE TABLE shared.ai_recommendation_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_schema VARCHAR(63),
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
recommendation_count INTEGER,
response_time_ms INTEGER,
status VARCHAR(20),
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id);
CREATE INDEX idx_user_orgs_org ON shared.user_organizations(organization_id);
@@ -80,3 +121,12 @@ CREATE INDEX idx_users_email ON shared.users(email);
CREATE INDEX idx_orgs_schema ON shared.organizations(schema_name);
CREATE INDEX idx_invitations_token ON shared.invitations(token);
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_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_user ON shared.login_history(user_id);
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);
CREATE INDEX idx_ai_rec_log_org ON shared.ai_recommendation_log(organization_id);
CREATE INDEX idx_ai_rec_log_time ON shared.ai_recommendation_log(requested_at DESC);

View File

@@ -0,0 +1,17 @@
-- Migration: Add CD rates table to shared schema
-- For existing deployments that already have the shared schema initialized
CREATE TABLE IF NOT EXISTS shared.cd_rates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bank_name VARCHAR(255) NOT NULL,
apy DECIMAL(6,4) NOT NULL,
min_deposit DECIMAL(15,2),
term VARCHAR(100) NOT NULL,
term_months INTEGER,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
CREATE INDEX IF NOT EXISTS idx_cd_rates_apy ON shared.cd_rates(apy DESC);

View File

@@ -0,0 +1,52 @@
-- ============================================================
-- Migration 006: Platform Administration Features
-- Adds: is_platform_owner, subscription fields, login_history, ai_recommendation_log
-- ============================================================
BEGIN;
-- 1. Add is_platform_owner to users
ALTER TABLE shared.users
ADD COLUMN IF NOT EXISTS is_platform_owner BOOLEAN DEFAULT FALSE;
-- 2. Add subscription fields to organizations
ALTER TABLE shared.organizations
ADD COLUMN IF NOT EXISTS payment_date DATE,
ADD COLUMN IF NOT EXISTS confirmation_number VARCHAR(100),
ADD COLUMN IF NOT EXISTS renewal_date DATE;
-- 3. Create login_history table
CREATE TABLE IF NOT EXISTS shared.login_history (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address VARCHAR(45),
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_login_history_org_time
ON shared.login_history(organization_id, logged_in_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_history_user
ON shared.login_history(user_id);
CREATE INDEX IF NOT EXISTS idx_login_history_time
ON shared.login_history(logged_in_at DESC);
-- 4. Create ai_recommendation_log table
CREATE TABLE IF NOT EXISTS shared.ai_recommendation_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_schema VARCHAR(63),
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
recommendation_count INTEGER,
response_time_ms INTEGER,
status VARCHAR(20),
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ai_rec_log_org
ON shared.ai_recommendation_log(organization_id);
CREATE INDEX IF NOT EXISTS idx_ai_rec_log_time
ON shared.ai_recommendation_log(requested_at DESC);
COMMIT;

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

@@ -16,6 +16,31 @@
-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================================
-- 0. Create platform owner account (admin@hoaledgeriq.com)
-- ============================================================
DO $$
DECLARE
v_platform_owner_id UUID;
BEGIN
SELECT id INTO v_platform_owner_id FROM shared.users WHERE email = 'admin@hoaledgeriq.com';
IF v_platform_owner_id IS NULL THEN
INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin, is_platform_owner)
VALUES (
uuid_generate_v4(),
'admin@hoaledgeriq.com',
-- bcrypt hash of platform owner password (cost 12)
'$2b$12$QRJEJYsjy.24Va.57h13Te7UX7nMTN9hWhW19bwuCAkr1Dm0FWqrm',
'Platform',
'Admin',
true,
true
) RETURNING id INTO v_platform_owner_id;
END IF;
-- Platform owner has NO org memberships — admin-only account
RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (id: %)', v_platform_owner_id;
END $$;
-- ============================================================
-- 1. Create test user and organization
-- ============================================================
@@ -179,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0,
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_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)', v_schema);
@@ -219,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices (
amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT ''draft'',
period_start DATE,
period_end DATE,
assessment_group_id UUID,
journal_entry_id UUID,
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
@@ -418,10 +449,10 @@ END LOOP;
-- ============================================================
-- 4b. Seed Assessment Groups
-- ============================================================
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20),
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15),
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15)
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, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 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)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1)
', v_schema);
-- ============================================================
@@ -836,7 +867,42 @@ EXECUTE format('INSERT INTO %I.capital_projects (name, description, estimated_co
(''Perimeter Fence Repair'', ''Replace damaged fence sections and repaint'', 8000, $1 + 4, 8, ''planned'', ''reserve'', 4)
', v_schema) USING v_year;
-- Add subscription data to the organization
UPDATE shared.organizations
SET payment_date = (CURRENT_DATE - INTERVAL '15 days')::DATE,
confirmation_number = 'PAY-2026-SVH-001',
renewal_date = (CURRENT_DATE + INTERVAL '350 days')::DATE
WHERE schema_name = v_schema;
-- ============================================================
-- 13. Seed login_history for demo analytics
-- ============================================================
-- Admin user: regular logins over the past 30 days
FOR v_month IN 0..29 LOOP
INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent)
VALUES (
v_user_id,
v_org_id,
NOW() - (v_month || ' days')::INTERVAL - (random() * 8 || ' hours')::INTERVAL,
'192.168.1.' || (10 + (random() * 50)::INT),
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
);
END LOOP;
-- Viewer user: occasional logins (every 3-5 days)
FOR v_month IN 0..9 LOOP
INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent)
VALUES (
(SELECT id FROM shared.users WHERE email = 'viewer@sunrisevalley.org'),
v_org_id,
NOW() - ((v_month * 3) || ' days')::INTERVAL - (random() * 12 || ' hours')::INTERVAL,
'10.0.0.' || (100 + (random() * 50)::INT),
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'
);
END LOOP;
RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!';
RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (SuperAdmin + Platform Owner)';
RAISE NOTICE 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)';
RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)';

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,13 +15,20 @@ services:
build:
context: ./backend
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
# No host port mapping — dev traffic goes through the Docker nginx container.
# Production overlay maps 127.0.0.1:3000 for the host reverse proxy.
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- JWT_SECRET=${JWT_SECRET}
- NODE_ENV=${NODE_ENV}
- 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}
volumes:
- ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json
@@ -39,8 +46,8 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile.dev
ports:
- "5173:5173"
# No host port mapping — dev traffic goes through the Docker nginx container.
# Production overlay maps 127.0.0.1:3001 for the host reverse proxy.
environment:
- NODE_ENV=${NODE_ENV}
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>
<div id="root"></div>
<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>
</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",
"version": "0.2.0",
"version": "2026.3.11",
"private": true,
"type": "module",
"scripts": {
@@ -11,31 +11,32 @@
},
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/form": "^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/notifications": "^7.15.3",
"@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-dom": "^18.3.1",
"react-joyride": "^2.9.3",
"react-router-dom": "^6.28.2",
"recharts": "^2.15.0",
"d3-sankey": "^0.12.3",
"zustand": "^4.5.5",
"axios": "^1.7.9",
"@tanstack/react-query": "^5.64.2",
"dayjs": "^1.11.13"
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/d3-sankey": "^0.12.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/d3-sankey": "^0.12.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^5.4.14",
"postcss": "^8.4.49",
"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 { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage';
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
@@ -29,6 +30,7 @@ import { AdminPage } from './pages/admin/AdminPage';
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
@@ -54,8 +56,14 @@ function SuperAdminRoute({ children }: { children: React.ReactNode }) {
function AuthRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const user = useAuthStore((s) => s.user);
const currentOrg = useAuthStore((s) => s.currentOrg);
const organizations = useAuthStore((s) => s.organizations);
if (token && currentOrg) return <Navigate to="/" replace />;
// Platform owner / superadmin with no org memberships → admin panel
if (token && user?.isSuperadmin && (!organizations || organizations.length === 0)) {
return <Navigate to="/admin" replace />;
}
if (token && !currentOrg) return <Navigate to="/select-org" replace />;
return <>{children}</>;
}
@@ -117,6 +125,7 @@ export function App() {
<Route path="projects" element={<ProjectsPage />} />
<Route path="investments" element={<InvestmentsPage />} />
<Route path="capital-projects" element={<CapitalProjectsPage />} />
<Route path="investment-planning" element={<InvestmentPlanningPage />} />
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
<Route path="cash-flow" element={<CashFlowForecastPage />} />
<Route path="monthly-actuals" element={<MonthlyActualsPage />} />
@@ -127,6 +136,7 @@ export function App() {
<Route path="reports/aging" element={<AgingReportPage />} />
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} />
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} />

View File

@@ -1,4 +1,5 @@
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar } 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 {
IconLogout,
@@ -7,33 +8,102 @@ import {
IconSettings,
IconUserCog,
IconUsersGroup,
IconEyeOff,
IconSun,
IconMoon,
} 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 { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import logoSrc from '../../assets/logo.svg';
export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout } = useAuthStore();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate();
const location = useLocation();
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 = () => {
logout();
navigate('/login');
};
const handleStopImpersonation = () => {
stopImpersonation();
navigate('/admin');
};
// Tenant admins (president role) can manage org members
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
return (
<AppShell
header={{ height: 60 }}
header={{ height: isImpersonating ? 100 : 60 }}
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
{isImpersonating && (
<Group
h={40}
px="md"
justify="center"
gap="xs"
style={{ backgroundColor: 'var(--mantine-color-orange-6)' }}
>
<Text size="sm" fw={600} c="white">
Impersonating {user?.firstName} {user?.lastName} ({user?.email})
</Text>
<Button
size="xs"
variant="white"
color="orange"
leftSection={<IconEyeOff size={14} />}
onClick={handleStopImpersonation}
>
Stop Impersonating
</Button>
</Group>
)}
<Group h={60} px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
@@ -42,11 +112,21 @@ export function AppLayout() {
{currentOrg && (
<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.Target>
<UnstyledButton>
<Group gap="xs">
<Avatar size="sm" radius="xl" color="blue">
<Avatar size="sm" radius="xl" color={isImpersonating ? 'orange' : 'blue'}>
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Avatar>
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
@@ -55,6 +135,18 @@ export function AppLayout() {
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{isImpersonating && (
<>
<Menu.Item
color="orange"
leftSection={<IconEyeOff size={14} />}
onClick={handleStopImpersonation}
>
Stop Impersonating
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Label>Account</Menu.Label>
<Menu.Item
leftSection={<IconUserCog size={14} />}
@@ -104,6 +196,10 @@ export function AppLayout() {
<AppShell.Main>
<Outlet />
</AppShell.Main>
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
</AppShell>
);
}

View File

@@ -16,6 +16,8 @@ import {
IconCategory,
IconChartAreaLine,
IconClipboardCheck,
IconSparkles,
IconHeartRateMonitor,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
@@ -28,23 +30,23 @@ const navSections = [
{
label: 'Financials',
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: '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',
items: [
{ 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',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
],
@@ -54,6 +56,7 @@ const navSections = [
items: [
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
],
},
@@ -63,6 +66,7 @@ const navSections = [
{
label: 'Reports',
icon: IconChartSankey,
tourId: 'nav-reports',
children: [
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
{ label: 'Income Statement', path: '/reports/income-statement' },
@@ -71,6 +75,7 @@ const navSections = [
{ label: 'Aging Report', path: '/reports/aging' },
{ label: 'Sankey Diagram', path: '/reports/sankey' },
{ label: 'Year-End', path: '/reports/year-end' },
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
],
},
],
@@ -85,14 +90,46 @@ export function Sidebar({ onNavigate }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
const currentOrg = useAuthStore((s) => s.currentOrg);
const organizations = useAuthStore((s) => s.organizations);
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
const go = (path: string) => {
navigate(path);
onNavigate?.();
};
// When on admin route with no org selected, show admin-only sidebar
if (isAdminOnly && user?.isSuperadmin) {
return (
<ScrollArea p="sm">
<Text size="xs" c="dimmed" fw={700} tt="uppercase" px="sm" pb={4}>
Platform Administration
</Text>
<NavLink
label="Admin Panel"
leftSection={<IconCrown size={18} />}
active={location.pathname === '/admin'}
onClick={() => go('/admin')}
color="red"
/>
{organizations && organizations.length > 0 && (
<>
<Divider my="sm" />
<NavLink
label="Switch to Tenant"
leftSection={<IconBuildingBank size={18} />}
onClick={() => go('/select-org')}
variant="subtle"
/>
</>
)}
</ScrollArea>
);
}
return (
<ScrollArea p="sm" data-tour="sidebar-nav">
{navSections.map((section, sIdx) => (
<div key={sIdx}>
{section.label && (
@@ -112,6 +149,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
defaultOpened={item.children.some((c: any) =>
location.pathname.startsWith(c.path),
)}
data-tour={item.tourId || undefined}
>
{item.children.map((child: any) => (
<NavLink
@@ -129,6 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
leftSection={<item.icon size={18} />}
active={location.pathname === 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 { App } from './App';
import { theme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({
defaultOptions: {
@@ -21,9 +22,11 @@ const queryClient = new QueryClient({
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MantineProvider theme={theme}>
function Root() {
const colorScheme = usePreferencesStore((s) => s.colorScheme);
return (
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
<Notifications position="top-right" />
<ModalsProvider>
<QueryClientProvider client={queryClient}>
@@ -33,5 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>,
);

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
MultiSelect,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
@@ -11,6 +12,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface AssessmentGroup {
id: string;
@@ -20,6 +22,8 @@ interface AssessmentGroup {
special_assessment: string;
unit_count: number;
frequency: string;
due_months: number[];
due_day: number;
actual_unit_count: string;
monthly_operating_income: string;
monthly_reserve_income: string;
@@ -48,10 +52,34 @@ const frequencyColors: Record<string, string> = {
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() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'],
@@ -71,18 +99,31 @@ export function AssessmentGroupsPage() {
specialAssessment: 0,
unitCount: 0,
frequency: 'monthly',
dueMonths: DEFAULT_DUE_MONTHS.monthly,
dueDay: 1,
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
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({
mutationFn: (values: any) =>
editing
? api.put(`/assessment-groups/${editing.id}`, values)
: api.post('/assessment-groups', values),
mutationFn: (values: any) => {
const payload = {
...values,
dueMonths: values.dueMonths.map(Number),
};
return editing
? api.put(`/assessment-groups/${editing.id}`, payload)
: api.post('/assessment-groups', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
@@ -119,6 +160,9 @@ export function AssessmentGroupsPage() {
const handleEdit = (group: AssessmentGroup) => {
setEditing(group);
const dueMonths = group.due_months
? group.due_months.map(String)
: DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly;
form.setValues({
name: group.name,
description: group.description || '',
@@ -126,6 +170,8 @@ export function AssessmentGroupsPage() {
specialAssessment: parseFloat(group.special_assessment || '0'),
unitCount: group.unit_count || 0,
frequency: group.frequency || 'monthly',
dueMonths,
dueDay: group.due_day || 1,
});
open();
};
@@ -136,6 +182,12 @@ export function AssessmentGroupsPage() {
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) =>
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>;
return (
@@ -156,9 +213,11 @@ export function AssessmentGroupsPage() {
<Title order={2}>Assessment Groups</Title>
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
</div>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Group
</Button>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
@@ -215,6 +274,7 @@ export function AssessmentGroupsPage() {
<Table.Th>Group Name</Table.Th>
<Table.Th ta="center">Units</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">Special Assessment</Table.Th>
<Table.Th ta="right">Monthly Equiv.</Table.Th>
@@ -225,7 +285,7 @@ export function AssessmentGroupsPage() {
<Table.Tbody>
{groups.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg">
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
</Text>
@@ -259,6 +319,9 @@ export function AssessmentGroupsPage() {
{frequencyLabels[g.frequency] || 'Monthly'}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDueMonths(g.due_months, g.frequency)}</Text>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
</Table.Td>
@@ -274,6 +337,7 @@ export function AssessmentGroupsPage() {
</Badge>
</Table.Td>
<Table.Td>
{!isReadOnly && (
<Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
<ActionIcon
@@ -296,6 +360,7 @@ export function AssessmentGroupsPage() {
<IconArchive size={16} />
</ActionIcon>
</Group>
)}
</Table.Td>
</Table.Tr>
))}
@@ -316,8 +381,22 @@ export function AssessmentGroupsPage() {
{ value: 'quarterly', label: 'Quarterly' },
{ 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>
<NumberInput
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
@@ -334,7 +413,16 @@ export function AssessmentGroupsPage() {
{...form.getInputProps('specialAssessment')}
/>
</Group>
<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}>
{editing ? 'Update' : 'Create'}
</Button>

View File

@@ -38,8 +38,11 @@ export function LoginPage() {
try {
const { data } = await api.post('/auth/login', values);
setAuth(data.accessToken, data.user, data.organizations);
// Platform owner / superadmin with no orgs → admin panel
if (data.user?.isSuperadmin && data.organizations.length === 0) {
navigate('/admin');
} else if (data.organizations.length >= 1) {
// Always go through org selection to ensure correct JWT with orgSchema
if (data.organizations.length >= 1) {
navigate('/select-org');
} else {
navigate('/');

View File

@@ -49,6 +49,11 @@ export function SelectOrgPage() {
},
});
// Filter out suspended/archived organizations (defense in depth)
const activeOrganizations = (organizations || []).filter(
(org: any) => !org.status || !['suspended', 'archived'].includes(org.status),
);
const handleSelect = async (org: any) => {
try {
const { data } = await api.post('/auth/switch-org', {
@@ -90,8 +95,15 @@ export function SelectOrgPage() {
Choose an HOA to manage or create a new one
</Text>
<Stack mt={30}>
{organizations.map((org) => (
{/* Filter out suspended/archived orgs (defense in depth — backend also filters) */}
{organizations.length > activeOrganizations.length && (
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light" mt="md">
Some organizations are currently suspended or archived and are not shown.
</Alert>
)}
<Stack mt={organizations.length > activeOrganizations.length ? 'sm' : 30}>
{activeOrganizations.map((org) => (
<Card
key={org.id}
shadow="sm"
@@ -108,11 +120,6 @@ export function SelectOrgPage() {
<Text fw={500}>{org.name}</Text>
<Group gap={4}>
<Badge size="sm" variant="light">{org.role}</Badge>
{org.schemaName && (
<Badge size="xs" variant="dot" color="gray">
{org.schemaName}
</Badge>
)}
</Group>
</div>
</Group>

View File

@@ -4,9 +4,11 @@ import {
Select, Loader, Center, Badge, Card, Alert,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, IconX } 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 BudgetLine {
account_id: string;
@@ -94,8 +96,20 @@ function parseCSV(text: string): Record<string, string>[] {
export function BudgetsPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
const [isEditing, setIsEditing] = useState(false);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
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';
// Budget exists when there is data loaded for the selected year
const hasBudget = budgetData.length > 0;
// Cells are editable only when editing an existing budget or creating a new one (no data yet)
const cellsEditable = !isReadOnly && (isEditing || !hasBudget);
const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year],
@@ -104,25 +118,27 @@ export function BudgetsPage() {
// Hydrate each line: ensure numbers and compute annual_total
const hydrated = (data as any[]).map(hydrateBudgetLine);
setBudgetData(hydrated);
setIsEditing(false); // Reset to view mode when year changes or data reloads
return hydrated;
},
});
const saveMutation = useMutation({
mutationFn: async () => {
const lines = budgetData
const payload = budgetData
.filter((b) => months.some((m) => (b as any)[m] > 0))
.map((b) => ({
account_id: b.account_id,
fund_type: b.fund_type,
accountId: b.account_id,
fundType: b.fund_type,
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
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: () => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
setIsEditing(false);
notifications.show({ message: 'Budget saved', color: 'green' });
},
onError: (err: any) => {
@@ -219,6 +235,12 @@ export function BudgetsPage() {
event.target.value = '';
};
const handleCancelEdit = () => {
setIsEditing(false);
// Re-fetch to discard unsaved changes
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
};
const updateCell = (idx: number, month: string, value: number) => {
const updated = [...budgetData];
(updated[idx] as any)[month] = value || 0;
@@ -236,8 +258,12 @@ export function BudgetsPage() {
if (isLoading) return <Center h={300}><Loader /></Center>;
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 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 totalIncome = totalOperatingIncome + totalReserveIncome;
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return (
@@ -253,6 +279,7 @@ export function BudgetsPage() {
>
Download Template
</Button>
{!isReadOnly && (<>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
@@ -268,9 +295,36 @@ export function BudgetsPage() {
accept=".csv,.txt"
onChange={handleFileChange}
/>
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
{hasBudget && !isEditing ? (
<Button
variant="outline"
leftSection={<IconPencil size={16} />}
onClick={() => setIsEditing(true)}
>
Edit Budget
</Button>
) : (
<>
{isEditing && (
<Button
variant="outline"
color="gray"
leftSection={<IconX size={16} />}
onClick={handleCancelEdit}
>
Cancel
</Button>
)}
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
>
Save Budget
</Button>
</>
)}
</>)}
</Group>
</Group>
@@ -284,17 +338,23 @@ export function BudgetsPage() {
<Group>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Income</Text>
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
<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</Text>
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalIncome - totalExpense)}
<Text size="xs" c="dimmed">Net (Operating)</Text>
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalOperatingIncome - totalExpense)}
</Text>
</Card>
</Group>
@@ -303,8 +363,8 @@ export function BudgetsPage() {
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', 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: 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>
))}
@@ -323,7 +383,7 @@ export function BudgetsPage() {
const lines = budgetData.filter((b) => b.account_type === type);
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);
return [
@@ -354,9 +414,9 @@ export function BudgetsPage() {
style={{
position: 'sticky',
left: 0,
background: 'white',
background: stickyBg,
zIndex: 1,
borderRight: '1px solid #e9ecef',
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
@@ -365,9 +425,9 @@ export function BudgetsPage() {
style={{
position: 'sticky',
left: 120,
background: 'white',
background: stickyBg,
zIndex: 1,
borderRight: '1px solid #e9ecef',
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
@@ -377,6 +437,7 @@ export function BudgetsPage() {
</Table.Td>
{months.map((m) => (
<Table.Td key={m} p={2}>
{cellsEditable ? (
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
@@ -386,6 +447,11 @@ export function BudgetsPage() {
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">

View File

@@ -14,6 +14,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ---------------------------------------------------------------------------
// Types & constants
@@ -29,7 +30,7 @@ interface Project {
fund_source: string;
funded_percentage: string;
planned_date: string;
target_year: number;
target_year: number | null;
target_month: number;
status: string;
priority: number;
@@ -37,6 +38,7 @@ interface Project {
}
const FUTURE_YEAR = 9999;
const UNSCHEDULED = -1; // sentinel for projects with no target_year
const statusColors: Record<string, string> = {
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) =>
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) => {
if (!d) return null;
@@ -73,6 +76,9 @@ interface KanbanCardProps {
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
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 (
<Card
@@ -104,6 +110,11 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
P{project.priority}
</Badge>
{isBeyondWindow && (
<Badge size="xs" variant="light" color="gray">
{project.target_year}
</Badge>
)}
</Group>
<Text size="xs" ff="monospace" fw={500} mb={4}>
@@ -144,19 +155,26 @@ function KanbanColumn({
isDragOver, onDragOverHandler, onDragLeave,
}: KanbanColumnProps) {
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 (
<Paper
withBorder
radius="md"
p="sm"
miw={280}
maw={320}
miw={useWideLayout ? 580 : 280}
maw={useWideLayout ? 640 : 320}
style={{
flexShrink: 0,
display: 'flex',
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,
transition: 'background-color 150ms ease, border 150ms ease',
}}
@@ -166,8 +184,13 @@ function KanbanColumn({
>
<Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title>
<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>
<Text size="xs" c="dimmed" mb="xs">
{projects.length} project{projects.length !== 1 ? 's' : ''}
@@ -178,6 +201,16 @@ function KanbanColumn({
<Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here
</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) => (
<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 printModeRef = useRef(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ---- Data fetching ----
@@ -287,10 +321,10 @@ export function CapitalProjectsPage() {
});
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 };
// 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;
} else {
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
@@ -329,7 +363,7 @@ export function CapitalProjectsPage() {
form.setValues({
status: p.status || 'planned',
priority: p.priority || 3,
target_year: p.target_year,
target_year: p.target_year ?? currentYear,
target_month: p.target_month || 6,
planned_date: p.planned_date || '',
notes: p.notes || '',
@@ -352,7 +386,7 @@ export function CapitalProjectsPage() {
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
e.dataTransfer.setData('application/json', JSON.stringify({
id: project.id,
source_year: project.target_year,
source_year: project.target_year ?? UNSCHEDULED,
target_month: project.target_month,
}));
e.dataTransfer.effectAllowed = 'move';
@@ -376,7 +410,7 @@ export function CapitalProjectsPage() {
if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({
id: payload.id,
target_year: targetYear,
target_year: targetYear === UNSCHEDULED ? null : targetYear,
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
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 hasUnscheduledProjects = projects.some((p) => p.target_year === null);
// 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 years = hasFutureProjects ? [...regularYears, FUTURE_YEAR] : regularYears;
const years = [
...regularYears,
...(hasFutureProjects ? [FUTURE_YEAR] : []),
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
];
// Kanban columns: always current..current+4 plus Future
const kanbanYears = [...baseYears, FUTURE_YEAR];
// Kanban columns: current..current+4 + Future + Unscheduled (rightmost)
const kanbanYears = [...baseYears, FUTURE_YEAR, UNSCHEDULED];
// ---- Loading state ----
@@ -417,12 +456,11 @@ export function CapitalProjectsPage() {
<Stack align="center" gap="md" maw={420}>
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
<Title order={3} c="dimmed" ta="center">
No projects in the capital plan
No projects yet
</Title>
<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
projects, then assign target years to see them here.
projects. They'll appear here for capital planning and scheduling.
</Text>
<Button
variant="light"
@@ -448,7 +486,9 @@ export function CapitalProjectsPage() {
</Text>
) : (
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;
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
@@ -479,7 +519,9 @@ export function CapitalProjectsPage() {
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.category || '-'}</Table.Td>
<Table.Td>
{p.target_year === FUTURE_YEAR
{p.target_year === null
? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
: p.target_year === FUTURE_YEAR
? 'Future'
: (
<>
@@ -511,9 +553,9 @@ export function CapitalProjectsPage() {
</Table.Td>
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
<Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</ActionIcon>}
</Table.Td>
</Table.Tr>
))}
@@ -528,11 +570,20 @@ export function CapitalProjectsPage() {
// ---- Render: Kanban view ----
const maxPlannedYear = currentYear + 4; // last year in the 5-year window
const renderKanbanView = () => (
<ScrollArea type="auto" offsetScrollbars>
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
{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 (
<KanbanColumn
key={year}

View File

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

View File

@@ -1,17 +1,296 @@
import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center,
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
Popover, List,
} from '@mantine/core';
import {
IconCash,
IconFileInvoice,
IconShieldCheck,
IconAlertTriangle,
IconBuildingBank,
IconTrendingUp,
IconTrendingDown,
IconMinus,
IconHeartbeat,
IconRefresh,
IconInfoCircle,
} 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 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 {
total_cash: string;
total_receivables: string;
@@ -20,10 +299,23 @@ interface DashboardData {
recent_transactions: {
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;
planned_capital_spend: string;
}
export function DashboardPage() {
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>({
queryKey: ['dashboard'],
@@ -31,15 +323,76 @@ export function DashboardPage() {
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) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const stats = [
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' },
{ 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 opInv = parseFloat(data?.operating_investments || '0');
const resInv = parseFloat(data?.reserve_investments || '0');
const entryTypeColors: Record<string, string> = {
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
@@ -47,13 +400,8 @@ export function DashboardPage() {
};
return (
<Stack>
<div>
<Stack data-tour="dashboard-content">
<Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
</Text>
</div>
{!currentOrg ? (
<Card withBorder p="xl" ta="center">
@@ -66,24 +414,80 @@ export function DashboardPage() {
<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 }}>
{stats.map((stat) => (
<Card key={stat.title} withBorder padding="lg" radius="md">
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{stat.title}
</Text>
<Text fw={700} size="xl">
{stat.value}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
</div>
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
<stat.icon size={28} />
<ThemeIcon color="green" variant="light" size={48} radius="md">
<IconCash 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}>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 cols={{ base: 1, md: 2 }}>
@@ -120,17 +524,31 @@ export function DashboardPage() {
<Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Cash Position</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
<Text size="sm" c="dimmed">Operating Cash</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
</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">Planned Capital Spend</Text>
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
</Group>
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" c="dimmed">Outstanding AR</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
</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">
<Text size="sm" c="dimmed">Delinquent Units</Text>
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>

View File

@@ -0,0 +1,763 @@
import { useState, useEffect, useCallback } from 'react';
import {
Title,
Text,
Stack,
Card,
SimpleGrid,
Group,
Button,
Table,
Badge,
Loader,
Center,
Alert,
ThemeIcon,
Divider,
Accordion,
Paper,
Tabs,
Collapse,
ActionIcon,
} from '@mantine/core';
import {
IconBulb,
IconCash,
IconBuildingBank,
IconChartAreaLine,
IconAlertTriangle,
IconSparkles,
IconRefresh,
IconCoin,
IconPigMoney,
IconChevronDown,
IconChevronUp,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
// ── Types ──
interface FinancialSummary {
operating_cash: number;
reserve_cash: number;
operating_investments: number;
reserve_investments: number;
total_operating: number;
total_reserve: number;
total_all: number;
}
interface FinancialSnapshot {
summary: FinancialSummary;
investment_accounts: Array<{
id: string;
name: string;
institution: string;
investment_type: string;
fund_type: string;
principal: string;
interest_rate: string;
maturity_date: string | null;
current_value: string;
}>;
}
interface MarketRate {
bank_name: string;
apy: string;
min_deposit: string | null;
term: string;
term_months: number | null;
rate_type: string;
fetched_at: string;
}
interface MarketRatesResponse {
cd: MarketRate[];
money_market: MarketRate[];
high_yield_savings: MarketRate[];
}
interface Recommendation {
type: string;
priority: 'high' | 'medium' | 'low';
title: string;
summary: string;
details: string;
fund_type: string;
suggested_amount?: number;
suggested_term?: string;
suggested_rate?: number;
bank_name?: string;
rationale: string;
}
interface AIResponse {
recommendations: Recommendation[];
overall_assessment: 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 ──
const fmt = (v: number) =>
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const priorityColors: Record<string, string> = {
high: 'red',
medium: 'yellow',
low: 'blue',
};
const typeIcons: Record<string, any> = {
cd_ladder: IconChartAreaLine,
new_investment: IconBuildingBank,
reallocation: IconRefresh,
maturity_action: IconCash,
liquidity_warning: IconAlertTriangle,
general: IconBulb,
};
const typeLabels: Record<string, string> = {
cd_ladder: 'CD Ladder',
new_investment: 'New Investment',
reallocation: 'Reallocation',
maturity_action: 'Maturity Action',
liquidity_warning: 'Liquidity',
general: 'General',
};
// ── 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,
}: {
aiResult: AIResponse;
lastUpdated?: string;
lastFailed?: boolean;
}) {
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>
</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() {
const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false);
// Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
queryKey: ['investment-planning-snapshot'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/snapshot');
return data;
},
});
// Load market rates (all types) on mount
const { data: marketRates, isLoading: ratesLoading } = useQuery<MarketRatesResponse>({
queryKey: ['investment-planning-market-rates'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/market-rates');
return data;
},
});
// Load saved recommendation — polls every 3s when processing
const { data: savedRec } = useQuery<SavedRecommendation | null>({
queryKey: ['investment-planning-saved-recommendation'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
},
refetchInterval: (query) => {
const rec = query.state.data;
// Poll every 3 seconds while processing
if (rec?.status === 'processing') return 3000;
// Also poll if we just triggered (status may not be 'processing' yet)
if (isTriggering) return 3000;
return false;
},
});
// 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]);
// 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({
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
color: 'green',
});
}
if (prevStatus === 'processing' && savedRec?.status === 'error') {
notifications.show({
message: savedRec.error_message || 'AI recommendation analysis failed',
color: 'red',
});
}
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) {
return (
<Center h={400}>
<Loader size="lg" />
</Center>
);
}
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 (
<Stack>
{/* Page Header */}
<Group justify="space-between" align="flex-start">
<div>
<Title order={2}>Investment Planning</Title>
<Text c="dimmed" size="sm">
Account overview, market rates, and AI-powered investment recommendations
</Text>
</div>
</Group>
{/* ── Section 1: Financial Snapshot Cards ── */}
{s && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="blue" size="sm">
<IconCash size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Operating Cash
</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(s.operating_cash)}
</Text>
<Text size="xs" c="dimmed">
Investments: {fmt(s.operating_investments)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="violet" size="sm">
<IconPigMoney size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Reserve Cash
</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(s.reserve_cash)}
</Text>
<Text size="xs" c="dimmed">
Investments: {fmt(s.reserve_investments)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="teal" size="sm">
<IconChartAreaLine size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Total All Funds
</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(s.total_all)}
</Text>
<Text size="xs" c="dimmed">
Operating: {fmt(s.total_operating)} | Reserve: {fmt(s.total_reserve)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="green" size="sm">
<IconCoin size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Total Invested
</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(s.operating_investments + s.reserve_investments)}
</Text>
<Text size="xs" c="dimmed">
Earning interest across all accounts
</Text>
</Card>
</SimpleGrid>
)}
{/* ── Section 2: Current Investments Table ── */}
{snapshot?.investment_accounts && snapshot.investment_accounts.length > 0 && (
<Card withBorder p="lg">
<Title order={4} mb="md">
Current Investments
</Title>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Institution</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>Maturity</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{snapshot.investment_accounts.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td fw={500}>{inv.name}</Table.Td>
<Table.Td>{inv.institution || '-'}</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">
{parseFloat(inv.interest_rate || '0').toFixed(2)}%
</Table.Td>
<Table.Td>
{inv.maturity_date
? new Date(inv.maturity_date).toLocaleDateString()
: '-'}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
{/* ── Section 3: Today's Market Rates (Collapsible with Tabs) ── */}
<Card withBorder p="lg">
<Group justify="space-between" mb={ratesExpanded ? 'md' : 0}>
<Group gap="xs">
<Title order={4}>Today&apos;s Market Rates</Title>
{totalRateCount > 0 && (
<Badge size="sm" variant="light" color="gray">
{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>
<Collapse in={ratesExpanded}>
{ratesLoading ? (
<Center py="lg">
<Loader />
</Center>
) : (
<Tabs defaultValue="cd">
<Tabs.List>
<Tabs.Tab value="cd">
CDs {(marketRates?.cd?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
)}
</Tabs.Tab>
<Tabs.Tab value="money_market">
Money Market {(marketRates?.money_market?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
)}
</Tabs.Tab>
<Tabs.Tab value="high_yield_savings">
High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
)}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="cd" pt="sm">
<RateTable rates={marketRates?.cd || []} showTerm={true} />
</Tabs.Panel>
<Tabs.Panel value="money_market" pt="sm">
<RateTable rates={marketRates?.money_market || []} showTerm={false} />
</Tabs.Panel>
<Tabs.Panel value="high_yield_savings" pt="sm">
<RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
</Tabs.Panel>
</Tabs>
)}
</Collapse>
</Card>
<Divider />
{/* ── Section 4: AI Investment Recommendations ── */}
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<Group gap="xs">
<ThemeIcon variant="light" color="grape" size="md">
<IconSparkles size={18} />
</ThemeIcon>
<div>
<Title order={4}>AI Investment Recommendations</Title>
<Text size="xs" c="dimmed">
Powered by AI analysis of your complete financial picture
</Text>
</div>
</Group>
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
</Group>
{/* Processing State */}
{isProcessing && (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="lg" type="dots" />
<Text c="dimmed" size="sm">
Analyzing your financial data and market rates...
</Text>
<Text c="dimmed" size="xs">
You can navigate away results will appear when ready
</Text>
</Stack>
</Center>
)}
{/* Error State (no cached data) */}
{hasError && !isProcessing && (
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
<Text size="sm">
{savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
</Text>
</Alert>
)}
{/* Results (with optional failure watermark) */}
{aiResult && !isProcessing && (
<RecommendationsDisplay
aiResult={aiResult}
lastUpdated={savedRec?.created_at || undefined}
lastFailed={lastFailed}
/>
)}
{/* Empty State */}
{!aiResult && !isProcessing && !hasError && (
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
<IconSparkles size={28} />
</ThemeIcon>
<Text fw={500} mb={4}>
AI-Powered Investment Analysis
</Text>
<Text c="dimmed" size="sm" maw={500} mx="auto">
Click &quot;Get AI Recommendations&quot; to analyze your accounts, cash flow,
budget, and capital projects against current market rates. The AI will
suggest specific investment moves to maximize interest income while
maintaining adequate liquidity.
</Text>
</Paper>
)}
</Card>
</Stack>
);
}

View File

@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Investment {
id: string; name: string; institution: string; account_number_last4: string;
@@ -25,6 +26,7 @@ export function InvestmentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Investment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: investments = [], isLoading } = useQuery<Investment[]>({
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 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 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) => {
if (days === null) return 'gray';
@@ -90,12 +97,13 @@ export function InvestmentsPage() {
<Stack>
<Group justify="space-between">
<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>
<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 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">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>
</SimpleGrid>
<Table striped highlightOnHover>
@@ -133,7 +141,7 @@ export function InvestmentsPage() {
) : '-'}
</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>
))}
{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 {
Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, Loader, Center, Card,
NumberInput, Select, Loader, Center, Card, Alert,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
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 api from '../../services/api';
@@ -15,15 +14,55 @@ interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
invoice_date: string; due_date: string; invoice_type: 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> = {
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() {
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
@@ -35,13 +74,36 @@ export function InvoicesPage() {
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({
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
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();
setPreview(null);
},
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>;
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>
<Group>
<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>
<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>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Date</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Type</Table.Th><Table.Th ta="right">Amount</Table.Th>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Owner</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.Tr>
</Table.Thead>
@@ -86,27 +148,104 @@ export function InvoicesPage() {
<Table.Tr key={i.id}>
<Table.Td fw={500}>{i.invoice_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.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_paid)}</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.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>
<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))}>
<Stack>
<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))} />
<NumberInput label="Year" {...bulkForm.getInputProps('year')} />
</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>
</form>
</Modal>

View File

@@ -9,6 +9,8 @@ import {
} 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';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine {
@@ -64,6 +66,12 @@ export function MonthlyActualsPage() {
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
const [savedJEId, setSavedJEId] = useState<string | null>(null);
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 y = new Date().getFullYear() - 2 + i;
@@ -176,16 +184,16 @@ export function MonthlyActualsPage() {
<Table.Tr key={line.account_id}>
<Table.Td
style={{
position: 'sticky', left: 0, background: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
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: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
@@ -204,6 +212,7 @@ export function MonthlyActualsPage() {
hideControls
decimalScale={2}
allowNegative
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</Table.Td>
@@ -229,6 +238,7 @@ export function MonthlyActualsPage() {
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
{!isReadOnly && (
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
@@ -237,6 +247,7 @@ export function MonthlyActualsPage() {
>
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
</Button>
)}
</Group>
</Group>
@@ -287,10 +298,10 @@ export function MonthlyActualsPage() {
<Table striped highlightOnHover style={{ minWidth: 700 }}>
<Table.Thead>
<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 #
</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
</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
@@ -299,8 +310,8 @@ export function MonthlyActualsPage() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
</Table.Tbody>
</Table>
</div>

View File

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

View File

@@ -1,25 +1,29 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, TextInput, Loader, Center,
NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
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 api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: 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() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Payment | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: payments = [], isLoading } = useQuery<Payment[]>({
queryKey: ['payments'],
@@ -37,10 +41,18 @@ export function PaymentsPage() {
const form = useForm({
initialValues: {
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({
mutationFn: (values: any) => {
const inv = invoices.find((i: any) => i.id === values.invoice_id);
@@ -51,22 +63,88 @@ export function PaymentsPage() {
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
invalidateAll();
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' }); },
});
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 invoiceOptions = invoices.map((i: any) => ({
const formatPeriod = (inv: any) => {
if (inv.period_start && inv.period_end) {
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'} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
}));
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>;
@@ -74,7 +152,7 @@ export function PaymentsPage() {
<Stack>
<Group justify="space-between">
<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>
<Table striped highlightOnHover>
<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 ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -94,18 +173,34 @@ export function PaymentsPage() {
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></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>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit payment">
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
{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>
<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>
{!editing && (
<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')} />
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
{...form.getInputProps('amount')} />
@@ -116,10 +211,60 @@ export function PaymentsPage() {
]} {...form.getInputProps('payment_method')} />
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
{...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>
</form>
</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>
);
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualLine {
account_id: string;
@@ -46,6 +47,9 @@ const monthFilterOptions = [
export function BudgetVsActualPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
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 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) => (
<>
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
</Table.Tr>
{sectionLines.map((line) => {

View File

@@ -6,7 +6,7 @@ import {
import { useQuery } from '@tanstack/react-query';
import {
IconCash, IconArrowUpRight, IconArrowDownRight,
IconWallet, IconReportMoney, IconSearch,
IconWallet, IconReportMoney, IconSearch, IconHeartRateMonitor,
} from '@tabler/icons-react';
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 = () => {
setQueryFrom(fromDate);
setQueryTo(toDate);
@@ -68,6 +78,10 @@ export function CashFlowPage() {
const totalOperating = parseFloat(data?.total_operating || '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 endingCash = parseFloat(data?.ending_cash || '0');
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
@@ -132,10 +146,14 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
</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>
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
{fmt(totalOperating)}
<Group justify="space-between" mb={4}>
<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>
</Card>
<Card withBorder p="md">
@@ -143,20 +161,31 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
<IconReportMoney size={14} />
</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>
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
{fmt(totalReserve)}
<Group justify="space-between" mb={4}>
<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>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="teal" size="sm">
<IconCash size={14} />
<ThemeIcon variant="light" color={aiRec?.overall_assessment ? 'teal' : 'gray'} size="sm">
<IconHeartRateMonitor size={14} />
</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>
<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>
</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>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid,
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid, SegmentedControl,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
@@ -52,6 +52,8 @@ export function SankeyPage() {
const containerRef = useRef<HTMLDivElement | null>(null);
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
const [year, setYear] = useState(new Date().getFullYear().toString());
const [source, setSource] = useState('actuals');
const [fundFilter, setFundFilter] = useState('all');
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
@@ -59,9 +61,12 @@ export function SankeyPage() {
});
const { data, isLoading, isError } = useQuery<CashFlowData>({
queryKey: ['sankey', year],
queryKey: ['sankey', year, source, fundFilter],
queryFn: async () => {
const { data } = await api.get(`/reports/cash-flow-sankey?year=${year}`);
const params = new URLSearchParams({ year });
if (source !== 'actuals') params.set('source', source);
if (fundFilter !== 'all') params.set('fundType', fundFilter);
const { data } = await api.get(`/reports/cash-flow-sankey?${params}`);
return data;
},
});
@@ -191,6 +196,31 @@ export function SankeyPage() {
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
</Group>
<Group>
<Text size="sm" fw={500}>Data source:</Text>
<SegmentedControl
size="sm"
value={source}
onChange={setSource}
data={[
{ label: 'Actuals', value: 'actuals' },
{ label: 'Budget', value: 'budget' },
{ label: 'Forecast', value: 'forecast' },
]}
/>
<Text size="sm" fw={500} ml="md">Fund:</Text>
<SegmentedControl
size="sm"
value={fundFilter}
onChange={setFundFilter}
data={[
{ label: 'All Funds', value: 'all' },
{ label: 'Operating', value: 'operating' },
{ label: 'Reserve', value: 'reserve' },
]}
/>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>

View File

@@ -11,6 +11,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface ReserveComponent {
id: string; name: string; category: string; description: string;
@@ -26,6 +27,7 @@ export function ReservesPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<ReserveComponent | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
queryKey: ['reserve-components'],
@@ -89,7 +91,7 @@ export function ReservesPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Reserve Components</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
@@ -139,7 +141,7 @@ export function ReservesPage() {
{c.condition_rating}/10
</Badge>
</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon></Table.Td>
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr>
);
})}

View File

@@ -38,10 +38,6 @@ export function SettingsPage() {
<Text size="sm" c="dimmed">Your Role</Text>
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Schema</Text>
<Text size="sm" ff="monospace" c="dimmed">{currentOrg?.schemaName || 'N/A'}</Text>
</Group>
</Stack>
</Card>
@@ -117,7 +113,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">0.2.0 MVP_P2</Badge>
<Badge variant="light">2026.03.10</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

@@ -12,6 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface JournalEntryLine {
id?: string;
@@ -48,6 +49,7 @@ export function TransactionsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [viewId, setViewId] = useState<string | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
queryKey: ['journal-entries'],
@@ -164,9 +166,11 @@ export function TransactionsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Journal Entries</Title>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={open}>
New Entry
</Button>
)}
</Group>
<Table striped highlightOnHover>
@@ -216,14 +220,14 @@ export function TransactionsPage() {
<IconEye size={16} />
</ActionIcon>
</Tooltip>
{!e.is_posted && !e.is_void && (
{!isReadOnly && !e.is_posted && !e.is_void && (
<Tooltip label="Post">
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
<IconCheck size={16} />
</ActionIcon>
</Tooltip>
)}
{e.is_posted && !e.is_void && (
{!isReadOnly && e.is_posted && !e.is_void && (
<Tooltip label="Void">
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
<IconX size={16} />

View File

@@ -10,6 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
import { useIsReadOnly } from '../../stores/authStore';
interface Unit {
id: string;
@@ -42,6 +43,7 @@ export function UnitsPage() {
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { data: units = [], isLoading } = useQuery<Unit[]>({
queryKey: ['units'],
@@ -163,6 +165,7 @@ export function UnitsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
Export CSV
</Button>
{!isReadOnly && (<>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}>
Import CSV
@@ -175,6 +178,7 @@ export function UnitsPage() {
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
</Tooltip>
)}
</>)}
</Group>
</Group>
@@ -224,6 +228,7 @@ export function UnitsPage() {
</Table.Td>
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
<Table.Td>
{!isReadOnly && (
<Group gap={4}>
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
<IconEdit size={16} />
@@ -234,6 +239,7 @@ export function UnitsPage() {
</ActionIcon>
</Tooltip>
</Group>
)}
</Table.Td>
</Table.Tr>
))}

View File

@@ -3,18 +3,21 @@ import {
Title, Table, Group, Button, Stack, TextInput, Modal,
Switch, Badge, ActionIcon, Text, Loader, Center,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch, 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 { parseCSV, downloadBlob } from '../../utils/csv';
interface Vendor {
id: string; name: string; contact_name: string; email: string; phone: string;
address_line1: string; city: string; state: string; zip_code: string;
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
last_negotiated: string | null;
}
export function VendorsPage() {
@@ -23,6 +26,7 @@ export function VendorsPage() {
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
queryKey: ['vendors'],
@@ -34,12 +38,19 @@ export function VendorsPage() {
name: '', contact_name: '', email: '', phone: '',
address_line1: '', city: '', state: '', zip_code: '',
tax_id: '', is_1099_eligible: false,
last_negotiated: null as Date | null,
},
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
});
const saveMutation = useMutation({
mutationFn: (values: any) => editing ? api.put(`/vendors/${editing.id}`, values) : api.post('/vendors', values),
mutationFn: (values: any) => {
const payload = {
...values,
last_negotiated: values.last_negotiated ? values.last_negotiated.toISOString().split('T')[0] : null,
};
return editing ? api.put(`/vendors/${editing.id}`, payload) : api.post('/vendors', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vendors'] });
notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' });
@@ -91,6 +102,7 @@ export function VendorsPage() {
phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '',
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
is_1099_eligible: v.is_1099_eligible,
last_negotiated: v.last_negotiated ? new Date(v.last_negotiated) : null,
});
open();
};
@@ -107,12 +119,14 @@ export function VendorsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
Export CSV
</Button>
{!isReadOnly && (<>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}>
Import CSV
</Button>
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
</>)}
</Group>
</Group>
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
@@ -122,6 +136,7 @@ export function VendorsPage() {
<Table.Tr>
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
<Table.Th>Phone</Table.Th><Table.Th>1099</Table.Th>
<Table.Th>Last Negotiated</Table.Th>
<Table.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@@ -133,11 +148,12 @@ export function VendorsPage() {
<Table.Td>{v.email}</Table.Td>
<Table.Td>{v.phone}</Table.Td>
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
<Table.Td>{v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'}</Table.Td>
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td>
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
@@ -157,6 +173,7 @@ export function VendorsPage() {
</Group>
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
<DateInput label="Last Negotiated" clearable placeholder="Select date" {...form.getInputProps('last_negotiated')} />
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>

View File

@@ -21,6 +21,16 @@ api.interceptors.response.use(
useAuthStore.getState().logout();
window.location.href = '/login';
}
// Handle org suspended/archived — redirect to org selection
if (
error.response?.status === 403 &&
typeof error.response?.data?.message === 'string' &&
error.response.data.message.includes('has been')
) {
const store = useAuthStore.getState();
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
window.location.href = '/select-org';
}
return Promise.reject(error);
},
);

View File

@@ -5,7 +5,8 @@ interface Organization {
id: string;
name: string;
role: string;
schemaName?: string;
status?: string;
settings?: Record<string, any>;
}
interface User {
@@ -14,6 +15,15 @@ interface User {
firstName: string;
lastName: string;
isSuperadmin?: boolean;
isPlatformOwner?: boolean;
hasSeenIntro?: boolean;
}
interface ImpersonationOriginal {
token: string;
user: User;
organizations: Organization[];
currentOrg: Organization | null;
}
interface AuthState {
@@ -21,18 +31,27 @@ interface AuthState {
user: User | null;
organizations: Organization[];
currentOrg: Organization | null;
impersonationOriginal: ImpersonationOriginal | null;
setAuth: (token: string, user: User, organizations: Organization[]) => void;
setCurrentOrg: (org: Organization, token?: string) => void;
setUserIntroSeen: () => void;
setOrgSettings: (settings: Record<string, any>) => void;
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
stopImpersonation: () => void;
logout: () => void;
}
/** Hook to check if the current user has read-only (viewer) access */
export const useIsReadOnly = () => useAuthStore((s) => s.currentOrg?.role === 'viewer');
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
(set, get) => ({
token: null,
user: null,
organizations: [],
currentOrg: null,
impersonationOriginal: null,
setAuth: (token, user, organizations) =>
set({
token,
@@ -46,22 +65,61 @@ export const useAuthStore = create<AuthState>()(
currentOrg: org,
token: token || state.token,
})),
setUserIntroSeen: () =>
set((state) => ({
user: state.user ? { ...state.user, hasSeenIntro: true } : null,
})),
setOrgSettings: (settings) =>
set((state) => ({
currentOrg: state.currentOrg
? { ...state.currentOrg, settings: { ...(state.currentOrg.settings || {}), ...settings } }
: null,
})),
startImpersonation: (token, user, organizations) => {
const state = get();
set({
impersonationOriginal: {
token: state.token!,
user: state.user!,
organizations: state.organizations,
currentOrg: state.currentOrg,
},
token,
user,
organizations,
currentOrg: null,
});
},
stopImpersonation: () => {
const { impersonationOriginal } = get();
if (impersonationOriginal) {
set({
token: impersonationOriginal.token,
user: impersonationOriginal.user,
organizations: impersonationOriginal.organizations,
currentOrg: impersonationOriginal.currentOrg,
impersonationOriginal: null,
});
}
},
logout: () =>
set({
token: null,
user: null,
organizations: [],
currentOrg: null,
impersonationOriginal: null,
}),
}),
{
name: 'ledgeriq-auth',
version: 3,
version: 5,
migrate: () => ({
token: null,
user: null,
organizations: [],
currentOrg: null,
impersonationOriginal: null,
}),
},
),

View File

@@ -0,0 +1,26 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type ColorScheme = 'light' | 'dark';
interface PreferencesState {
colorScheme: ColorScheme;
toggleColorScheme: () => void;
setColorScheme: (scheme: ColorScheme) => void;
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
colorScheme: 'light',
toggleColorScheme: () =>
set((state) => ({
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
})),
setColorScheme: (scheme) => set({ colorScheme: scheme }),
}),
{
name: 'ledgeriq-preferences',
},
),
);

View File

@@ -10,6 +10,7 @@ export default defineConfig({
},
},
server: {
allowedHosts: ['app.hoaledgeriq.com'],
host: '0.0.0.0',
port: 5173,
proxy: {

18
nginx/certbot-init.conf Normal file
View File

@@ -0,0 +1,18 @@
# Temporary nginx config — used ONLY during the initial certbot certificate
# request. Once the cert is obtained, switch to ssl.conf and restart nginx.
server {
listen 80;
server_name _;
# Certbot ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Return 503 for everything else so it's obvious this is not the real app
location / {
return 503 "SSL certificate is being provisioned. Try again in a minute.\n";
add_header Content-Type text/plain;
}
}

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