Compare commits
91 Commits
e0c956859b
...
feature/bo
| Author | SHA1 | Date | |
|---|---|---|---|
| 93eeacfe8f | |||
| 17bdebfb52 | |||
| 267d92933e | |||
| 159c59734e | |||
| 7ba5c414b1 | |||
| a98a7192bb | |||
| 1d1073cba1 | |||
| cf061c1505 | |||
| 5ebfc4f3aa | |||
| f20f54b128 | |||
| f2b0b57535 | |||
| e6fe2314de | |||
| c8d77aaa48 | |||
| b13fbfe8c7 | |||
| 280a5996f6 | |||
| 9a082d2950 | |||
| 82433955bd | |||
| 8e2456dcae | |||
| 1acd8c3bff | |||
| 2de0cde94c | |||
| 94c7c90b91 | |||
| f47fbfcf93 | |||
| 04771f370c | |||
| 208c1dd7bc | |||
| 61a4f27af4 | |||
| a047144922 | |||
| 508a86d16c | |||
| 16e1ada261 | |||
| 6bd080f8c4 | |||
| be3a5191c5 | |||
| b0282b7f8b | |||
| ac72905ecb | |||
| 7d4df25d16 | |||
| 538828b91a | |||
| 14160854b9 | |||
| 36d486d78c | |||
| 3bf6b8c6c9 | |||
| 4759374883 | |||
| cb6e34d5ce | |||
| 2b72951e66 | |||
| 69dad7cc74 | |||
| efa5aca35f | |||
| c429dcc033 | |||
| 9146118df1 | |||
| 07d15001ae | |||
| a0b366e94a | |||
| 3790a3bd9e | |||
| 0a07c61ca3 | |||
| 337b6061b2 | |||
| 467fdd2a6c | |||
| c12ad94b7f | |||
| 05e241c792 | |||
| 5ee4c71fc1 | |||
| 81908e48ea | |||
| 6230558b91 | |||
| 2c215353d4 | |||
| d526025926 | |||
| 411239bea4 | |||
| 7e6c4c16ce | |||
| ea0e3d6f29 | |||
| 8db89373e0 | |||
| e719f593de | |||
| 16adfd6f26 | |||
| 704f29362a | |||
| 42767e3119 | |||
| a550a8d0be | |||
| 063741adc7 | |||
| ad2f16d93b | |||
| b0b36df4e4 | |||
| aa7f2dab32 | |||
| d2d553eed6 | |||
| 2ca277b6e6 | |||
| bfcbe086f2 | |||
| c92eb1b57b | |||
| 07347a644f | |||
| f1e66966f3 | |||
| d1c40c633f | |||
| 0e82e238c1 | |||
| 2fed5d6ce1 | |||
| d9bb9363dd | |||
| e156cf7c87 | |||
| 76ab63a200 | |||
| a32d4cc179 | |||
| 0bd30a0eb8 | |||
| 0626b8d496 | |||
| 25663fc79e | |||
| fe4989bbcc | |||
| 36271585d9 | |||
| 18c7989983 | |||
| c28d7aeffc | |||
| f7e9c98bd9 |
12
.env.example
12
.env.example
@@ -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
5
.gitignore
vendored
@@ -24,6 +24,11 @@ postgres_data/
|
||||
redis_data/
|
||||
pgdata/
|
||||
|
||||
# Database backups
|
||||
backups/
|
||||
*.dump
|
||||
*.dump.gz
|
||||
|
||||
# SSL
|
||||
letsencrypt/
|
||||
|
||||
|
||||
136
PLAN.md
Normal file
136
PLAN.md
Normal 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
32
backend/Dockerfile
Normal 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"]
|
||||
@@ -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"]
|
||||
|
||||
7
backend/newrelic-preload.js
Normal file
7
backend/newrelic-preload.js
Normal 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
1453
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "0.2.0",
|
||||
"version": "2026.03.16",
|
||||
"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",
|
||||
|
||||
@@ -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,10 @@ 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 { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,8 +45,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 +78,18 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||
ProjectsModule,
|
||||
MonthlyActualsModule,
|
||||
AttachmentsModule,
|
||||
InvestmentPlanningModule,
|
||||
HealthScoresModule,
|
||||
BoardPlanningModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: WriteAccessGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ALLOW_VIEWER_KEY = 'allowViewer';
|
||||
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);
|
||||
35
backend/src/common/guards/write-access.guard.ts
Normal file
35
backend/src/common/guards/write-access.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -328,6 +366,99 @@ export class TenantSchemaService {
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Board Planning - Scenarios
|
||||
`CREATE TABLE "${s}".board_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN ('investment', 'assessment')),
|
||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'approved', 'archived')),
|
||||
projection_months INTEGER DEFAULT 36,
|
||||
projection_cache JSONB,
|
||||
projection_cached_at TIMESTAMPTZ,
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Board Planning - Scenario Investments
|
||||
`CREATE TABLE "${s}".scenario_investments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
|
||||
source_recommendation_id UUID,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
investment_type VARCHAR(50) CHECK (investment_type IN ('cd', 'money_market', 'treasury', 'savings', 'other')),
|
||||
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
|
||||
principal DECIMAL(15,2) NOT NULL,
|
||||
interest_rate DECIMAL(6,4),
|
||||
term_months INTEGER,
|
||||
institution VARCHAR(255),
|
||||
purchase_date DATE,
|
||||
maturity_date DATE,
|
||||
auto_renew BOOLEAN DEFAULT FALSE,
|
||||
executed_investment_id UUID,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Board Planning - Scenario Assessments
|
||||
`CREATE TABLE "${s}".scenario_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
|
||||
change_type VARCHAR(30) NOT NULL CHECK (change_type IN ('dues_increase', 'special_assessment', 'dues_decrease')),
|
||||
label VARCHAR(255) NOT NULL,
|
||||
target_fund VARCHAR(20) CHECK (target_fund IN ('operating', 'reserve', 'both')),
|
||||
percentage_change DECIMAL(6,3),
|
||||
flat_amount_change DECIMAL(10,2),
|
||||
special_total DECIMAL(15,2),
|
||||
special_per_unit DECIMAL(10,2),
|
||||
special_installments INTEGER DEFAULT 1,
|
||||
effective_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
applies_to_group_id UUID,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Budget Plans
|
||||
`CREATE TABLE "${s}".budget_plans (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
fiscal_year INTEGER NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'approved', 'ratified')),
|
||||
base_year INTEGER NOT NULL,
|
||||
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
|
||||
notes TEXT,
|
||||
created_by UUID,
|
||||
approved_by UUID,
|
||||
approved_at TIMESTAMPTZ,
|
||||
ratified_by UUID,
|
||||
ratified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(fiscal_year)
|
||||
)`,
|
||||
|
||||
// Budget Plan Lines
|
||||
`CREATE TABLE "${s}".budget_plan_lines (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
budget_plan_id UUID NOT NULL REFERENCES "${s}".budget_plans(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES "${s}".accounts(id),
|
||||
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
|
||||
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
|
||||
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
|
||||
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
|
||||
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
|
||||
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
|
||||
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
|
||||
is_manually_adjusted BOOLEAN DEFAULT FALSE,
|
||||
notes TEXT,
|
||||
UNIQUE(budget_plan_id, account_id, fund_type)
|
||||
)`,
|
||||
|
||||
// Indexes
|
||||
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
||||
@@ -340,6 +471,12 @@ export class TenantSchemaService {
|
||||
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
|
||||
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
|
||||
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
|
||||
`CREATE INDEX "idx_${s}_bs_type_status" ON "${s}".board_scenarios(scenario_type, status)`,
|
||||
`CREATE INDEX "idx_${s}_si_scenario" ON "${s}".scenario_investments(scenario_id)`,
|
||||
`CREATE INDEX "idx_${s}_sa_scenario" ON "${s}".scenario_assessments(scenario_id)`,
|
||||
`CREATE INDEX "idx_${s}_bp_year" ON "${s}".budget_plans(fiscal_year)`,
|
||||
`CREATE INDEX "idx_${s}_bp_status" ON "${s}".budget_plans(status)`,
|
||||
`CREATE INDEX "idx_${s}_bpl_plan" ON "${s}".budget_plan_lines(budget_plan_id)`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
app.use((req: any, _res: any, next: any) => {
|
||||
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
|
||||
next();
|
||||
});
|
||||
// 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,
|
||||
});
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('HOA LedgerIQ API')
|
||||
.setDescription('API for the HOA LedgerIQ')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
// 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('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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()');
|
||||
|
||||
325
backend/src/modules/auth/admin-analytics.service.ts
Normal file
325
backend/src/modules/auth/admin-analytics.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
@Injectable()
|
||||
export class BoardPlanningProjectionService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
/** Return cached projection if fresh, otherwise compute. */
|
||||
async getProjection(scenarioId: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||
const scenario = rows[0];
|
||||
|
||||
// Return cache if it exists and is less than 1 hour old
|
||||
if (scenario.projection_cache && scenario.projection_cached_at) {
|
||||
const age = Date.now() - new Date(scenario.projection_cached_at).getTime();
|
||||
if (age < 3600000) return scenario.projection_cache;
|
||||
}
|
||||
|
||||
return this.computeProjection(scenarioId);
|
||||
}
|
||||
|
||||
/** Compute full projection for a scenario. */
|
||||
async computeProjection(scenarioId: string) {
|
||||
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
||||
const scenario = scenarioRows[0];
|
||||
|
||||
const investments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
||||
);
|
||||
const assessments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY effective_date', [scenarioId],
|
||||
);
|
||||
|
||||
const months = scenario.projection_months || 36;
|
||||
const now = new Date();
|
||||
const startYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
|
||||
// ── 1. Baseline state (mirrors reports.service.ts getCashFlowForecast) ──
|
||||
const baseline = await this.getBaselineState(startYear, months);
|
||||
|
||||
// ── 2. Build month-by-month projection ──
|
||||
let { opCash, resCash, opInv, resInv } = baseline.openingBalances;
|
||||
const datapoints: any[] = [];
|
||||
let totalInterestEarned = 0;
|
||||
const interestByInvestment: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < months; i++) {
|
||||
const year = startYear + Math.floor(i / 12);
|
||||
const month = (i % 12) + 1;
|
||||
const key = `${year}-${month}`;
|
||||
const label = `${monthLabels[month - 1]} ${year}`;
|
||||
const isHistorical = year < startYear || (year === startYear && month < currentMonth);
|
||||
|
||||
// Baseline income/expenses from budget
|
||||
const budget = baseline.budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
const baseAssessment = this.getAssessmentIncome(baseline.assessmentGroups, month);
|
||||
const existingMaturity = baseline.maturityIndex[key] || { operating: 0, reserve: 0 };
|
||||
const project = baseline.projectIndex[key] || { operating: 0, reserve: 0 };
|
||||
|
||||
// Scenario investment deltas for this month
|
||||
const invDelta = this.computeInvestmentDelta(investments, year, month);
|
||||
totalInterestEarned += invDelta.interestEarned;
|
||||
for (const [invId, amt] of Object.entries(invDelta.interestByInvestment)) {
|
||||
interestByInvestment[invId] = (interestByInvestment[invId] || 0) + amt;
|
||||
}
|
||||
|
||||
// Scenario assessment deltas for this month
|
||||
const asmtDelta = this.computeAssessmentDelta(assessments, baseline.assessmentGroups, year, month);
|
||||
|
||||
if (isHistorical) {
|
||||
// Historical months: use actual changes + scenario deltas
|
||||
const opChange = baseline.histIndex[`${year}-${month}-operating`] || 0;
|
||||
const resChange = baseline.histIndex[`${year}-${month}-reserve`] || 0;
|
||||
opCash += opChange + invDelta.opCashFlow + asmtDelta.operating;
|
||||
resCash += resChange + invDelta.resCashFlow + asmtDelta.reserve;
|
||||
} else {
|
||||
// Forecast months: budget + assessments + scenario deltas
|
||||
const opIncomeMonth = (budget.opIncome > 0 ? budget.opIncome : baseAssessment.operating) + asmtDelta.operating;
|
||||
const resIncomeMonth = (budget.resIncome > 0 ? budget.resIncome : baseAssessment.reserve) + asmtDelta.reserve;
|
||||
|
||||
opCash += opIncomeMonth - budget.opExpense - project.operating + existingMaturity.operating + invDelta.opCashFlow;
|
||||
resCash += resIncomeMonth - budget.resExpense - project.reserve + existingMaturity.reserve + invDelta.resCashFlow;
|
||||
|
||||
// Existing maturities reduce investment balances
|
||||
if (existingMaturity.operating > 0) {
|
||||
opInv -= existingMaturity.operating * 0.96; // approximate principal
|
||||
if (opInv < 0) opInv = 0;
|
||||
}
|
||||
if (existingMaturity.reserve > 0) {
|
||||
resInv -= existingMaturity.reserve * 0.96;
|
||||
if (resInv < 0) resInv = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario investment balance changes
|
||||
opInv += invDelta.opInvChange;
|
||||
resInv += invDelta.resInvChange;
|
||||
if (opInv < 0) opInv = 0;
|
||||
if (resInv < 0) resInv = 0;
|
||||
|
||||
datapoints.push({
|
||||
month: label,
|
||||
year,
|
||||
monthNum: month,
|
||||
is_forecast: !isHistorical,
|
||||
operating_cash: round2(opCash),
|
||||
operating_investments: round2(opInv),
|
||||
reserve_cash: round2(resCash),
|
||||
reserve_investments: round2(resInv),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Summary metrics ──
|
||||
const summary = this.computeSummary(datapoints, baseline, assessments, investments, totalInterestEarned, interestByInvestment);
|
||||
|
||||
const result = { datapoints, summary };
|
||||
|
||||
// ── 4. Cache ──
|
||||
await this.tenant.query(
|
||||
`UPDATE board_scenarios SET projection_cache = $1, projection_cached_at = NOW() WHERE id = $2`,
|
||||
[JSON.stringify(result), scenarioId],
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compare multiple scenarios side-by-side. */
|
||||
async compareScenarios(scenarioIds: string[]) {
|
||||
if (!scenarioIds.length || scenarioIds.length > 4) {
|
||||
throw new NotFoundException('Provide 1 to 4 scenario IDs');
|
||||
}
|
||||
|
||||
const scenarios = await Promise.all(
|
||||
scenarioIds.map(async (id) => {
|
||||
const rows = await this.tenant.query('SELECT id, name, scenario_type, status FROM board_scenarios WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException(`Scenario ${id} not found`);
|
||||
const projection = await this.getProjection(id);
|
||||
return { ...rows[0], projection };
|
||||
}),
|
||||
);
|
||||
|
||||
return { scenarios };
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
private async getBaselineState(startYear: number, months: number) {
|
||||
// Current balances from asset accounts
|
||||
const opCashRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
const resCashRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
const opInvRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
|
||||
`);
|
||||
const resInvRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||
`);
|
||||
|
||||
// Opening balances at start of startYear
|
||||
const openingOp = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`, [`${startYear}-01-01`]);
|
||||
const openingRes = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`, [`${startYear}-01-01`]);
|
||||
|
||||
// Assessment groups
|
||||
const assessmentGroups = await this.tenant.query(
|
||||
`SELECT frequency, regular_assessment, special_assessment, unit_count FROM assessment_groups WHERE is_active = true`,
|
||||
);
|
||||
|
||||
// Budgets (official + planned budget fallback)
|
||||
const budgetsByYearMonth: Record<string, any> = {};
|
||||
const endYear = startYear + Math.ceil(months / 12) + 1;
|
||||
for (let yr = startYear; yr <= endYear; yr++) {
|
||||
let budgetRows: any[];
|
||||
try {
|
||||
budgetRows = await this.tenant.query(
|
||||
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
|
||||
SELECT b.account_id, b.fund_type, a.account_type,
|
||||
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt,
|
||||
1 as source_priority
|
||||
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
|
||||
UNION ALL
|
||||
SELECT bpl.account_id, bpl.fund_type, a.account_type,
|
||||
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt,
|
||||
2 as source_priority
|
||||
FROM budget_plan_lines bpl
|
||||
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
|
||||
JOIN accounts a ON a.id = bpl.account_id
|
||||
WHERE bp.fiscal_year = $1
|
||||
) combined
|
||||
ORDER BY account_id, fund_type, source_priority`, [yr],
|
||||
);
|
||||
} catch {
|
||||
// budget_plan_lines may not exist yet - fall back to official only
|
||||
budgetRows = await this.tenant.query(
|
||||
`SELECT b.fund_type, a.account_type, b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1`, [yr],
|
||||
);
|
||||
}
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const key = `${yr}-${m + 1}`;
|
||||
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
for (const row of budgetRows) {
|
||||
const amt = parseFloat(row[monthNames[m]]) || 0;
|
||||
if (amt === 0) continue;
|
||||
const isOp = row.fund_type === 'operating';
|
||||
if (row.account_type === 'income') {
|
||||
if (isOp) budgetsByYearMonth[key].opIncome += amt;
|
||||
else budgetsByYearMonth[key].resIncome += amt;
|
||||
} else if (row.account_type === 'expense') {
|
||||
if (isOp) budgetsByYearMonth[key].opExpense += amt;
|
||||
else budgetsByYearMonth[key].resExpense += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Historical cash changes
|
||||
const historicalCash = await this.tenant.query(`
|
||||
SELECT EXTRACT(YEAR FROM je.entry_date)::int as yr, EXTRACT(MONTH FROM je.entry_date)::int as mo,
|
||||
a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as net_change
|
||||
FROM journal_entry_lines jel
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
JOIN accounts a ON a.id = jel.account_id AND a.account_type = 'asset' AND a.is_active = true
|
||||
WHERE je.entry_date >= $1::date
|
||||
GROUP BY yr, mo, a.fund_type ORDER BY yr, mo
|
||||
`, [`${startYear}-01-01`]);
|
||||
|
||||
const histIndex: Record<string, number> = {};
|
||||
for (const row of historicalCash) {
|
||||
histIndex[`${row.yr}-${row.mo}-${row.fund_type}`] = parseFloat(row.net_change) || 0;
|
||||
}
|
||||
|
||||
// Investment maturities
|
||||
const maturities = await this.tenant.query(`
|
||||
SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date
|
||||
FROM investment_accounts WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
||||
`);
|
||||
const maturityIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const inv of maturities) {
|
||||
const d = new Date(inv.maturity_date);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
|
||||
const val = parseFloat(inv.current_value) || 0;
|
||||
const rate = parseFloat(inv.interest_rate) || 0;
|
||||
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||
const matDate = new Date(inv.maturity_date);
|
||||
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||
const interestEarned = val * (rate / 100) * (daysHeld / 365);
|
||||
const maturityTotal = val + interestEarned;
|
||||
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
|
||||
else maturityIndex[key].reserve += maturityTotal;
|
||||
}
|
||||
|
||||
// Capital project expenses (from unified projects table)
|
||||
const projectExpenses = await this.tenant.query(`
|
||||
SELECT estimated_cost, target_year, target_month, fund_source
|
||||
FROM projects WHERE is_active = true AND status IN ('planned', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
|
||||
`);
|
||||
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const p of projectExpenses) {
|
||||
const yr = parseInt(p.target_year);
|
||||
const mo = parseInt(p.target_month) || 6;
|
||||
const key = `${yr}-${mo}`;
|
||||
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||
const cost = parseFloat(p.estimated_cost) || 0;
|
||||
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||
else projectIndex[key].reserve += cost;
|
||||
}
|
||||
|
||||
// Also include capital_projects table (Capital Planning page)
|
||||
try {
|
||||
const capitalProjectExpenses = await this.tenant.query(`
|
||||
SELECT estimated_cost, target_year, target_month, fund_source
|
||||
FROM capital_projects WHERE status IN ('planned', 'approved', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
|
||||
`);
|
||||
for (const p of capitalProjectExpenses) {
|
||||
const yr = parseInt(p.target_year);
|
||||
const mo = parseInt(p.target_month) || 6;
|
||||
const key = `${yr}-${mo}`;
|
||||
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||
const cost = parseFloat(p.estimated_cost) || 0;
|
||||
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||
else projectIndex[key].reserve += cost;
|
||||
}
|
||||
} catch {
|
||||
// capital_projects table may not exist in all tenants
|
||||
}
|
||||
|
||||
return {
|
||||
openingBalances: {
|
||||
opCash: parseFloat(openingOp[0]?.total || '0'),
|
||||
resCash: parseFloat(openingRes[0]?.total || '0'),
|
||||
opInv: parseFloat(opInvRows[0]?.total || '0'),
|
||||
resInv: parseFloat(resInvRows[0]?.total || '0'),
|
||||
},
|
||||
assessmentGroups,
|
||||
budgetsByYearMonth,
|
||||
histIndex,
|
||||
maturityIndex,
|
||||
projectIndex,
|
||||
};
|
||||
}
|
||||
|
||||
private getAssessmentIncome(assessmentGroups: any[], month: number) {
|
||||
let operating = 0;
|
||||
let reserve = 0;
|
||||
for (const g of assessmentGroups) {
|
||||
const units = parseInt(g.unit_count) || 0;
|
||||
const regular = parseFloat(g.regular_assessment) || 0;
|
||||
const special = parseFloat(g.special_assessment) || 0;
|
||||
const freq = g.frequency || 'monthly';
|
||||
let applies = false;
|
||||
if (freq === 'monthly') applies = true;
|
||||
else if (freq === 'quarterly') applies = [1, 4, 7, 10].includes(month);
|
||||
else if (freq === 'annual') applies = month === 1;
|
||||
if (applies) {
|
||||
operating += regular * units;
|
||||
reserve += special * units;
|
||||
}
|
||||
}
|
||||
return { operating, reserve };
|
||||
}
|
||||
|
||||
/** Compute investment cash flow and balance deltas for a given month from scenario investments. */
|
||||
private computeInvestmentDelta(investments: any[], year: number, month: number) {
|
||||
let opCashFlow = 0;
|
||||
let resCashFlow = 0;
|
||||
let opInvChange = 0;
|
||||
let resInvChange = 0;
|
||||
let interestEarned = 0;
|
||||
const interestByInvestment: Record<string, number> = {};
|
||||
|
||||
for (const inv of investments) {
|
||||
if (inv.executed_investment_id) continue; // skip already-executed investments
|
||||
|
||||
const principal = parseFloat(inv.principal) || 0;
|
||||
const rate = parseFloat(inv.interest_rate) || 0;
|
||||
const isOp = inv.fund_type === 'operating';
|
||||
|
||||
// Purchase: cash leaves, investment balance increases
|
||||
if (inv.purchase_date) {
|
||||
const pd = new Date(inv.purchase_date);
|
||||
if (pd.getFullYear() === year && pd.getMonth() + 1 === month) {
|
||||
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
||||
else { resCashFlow -= principal; resInvChange += principal; }
|
||||
}
|
||||
}
|
||||
|
||||
// Maturity: investment returns to cash with interest
|
||||
if (inv.maturity_date) {
|
||||
const md = new Date(inv.maturity_date);
|
||||
if (md.getFullYear() === year && md.getMonth() + 1 === month) {
|
||||
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||
const daysHeld = Math.max((md.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||
const invInterest = principal * (rate / 100) * (daysHeld / 365);
|
||||
const maturityTotal = principal + invInterest;
|
||||
|
||||
interestEarned += invInterest;
|
||||
interestByInvestment[inv.id] = (interestByInvestment[inv.id] || 0) + invInterest;
|
||||
|
||||
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
||||
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
||||
|
||||
// Auto-renew: immediately reinvest
|
||||
if (inv.auto_renew) {
|
||||
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
||||
else { resCashFlow -= principal; resInvChange += principal; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { opCashFlow, resCashFlow, opInvChange, resInvChange, interestEarned, interestByInvestment };
|
||||
}
|
||||
|
||||
/** Compute assessment income delta for a given month from scenario assessment changes. */
|
||||
private computeAssessmentDelta(scenarioAssessments: any[], assessmentGroups: any[], year: number, month: number) {
|
||||
let operating = 0;
|
||||
let reserve = 0;
|
||||
|
||||
const monthDate = new Date(year, month - 1, 1);
|
||||
|
||||
// Get total units across all assessment groups
|
||||
let totalUnits = 0;
|
||||
for (const g of assessmentGroups) {
|
||||
totalUnits += parseInt(g.unit_count) || 0;
|
||||
}
|
||||
|
||||
for (const a of scenarioAssessments) {
|
||||
const effectiveDate = new Date(a.effective_date);
|
||||
const endDate = a.end_date ? new Date(a.end_date) : null;
|
||||
|
||||
// Only apply if within the active window
|
||||
if (monthDate < effectiveDate) continue;
|
||||
if (endDate && monthDate > endDate) continue;
|
||||
|
||||
if (a.change_type === 'dues_increase' || a.change_type === 'dues_decrease') {
|
||||
const baseIncome = this.getAssessmentIncome(assessmentGroups, month);
|
||||
const pctChange = parseFloat(a.percentage_change) || 0;
|
||||
const flatChange = parseFloat(a.flat_amount_change) || 0;
|
||||
const sign = a.change_type === 'dues_decrease' ? -1 : 1;
|
||||
|
||||
let delta = 0;
|
||||
if (pctChange > 0) {
|
||||
// Percentage change of base assessment income
|
||||
const target = a.target_fund || 'operating';
|
||||
if (target === 'operating' || target === 'both') {
|
||||
delta = baseIncome.operating * (pctChange / 100) * sign;
|
||||
operating += delta;
|
||||
}
|
||||
if (target === 'reserve' || target === 'both') {
|
||||
delta = baseIncome.reserve * (pctChange / 100) * sign;
|
||||
reserve += delta;
|
||||
}
|
||||
} else if (flatChange > 0) {
|
||||
// Flat per-unit change times total units
|
||||
const target = a.target_fund || 'operating';
|
||||
if (target === 'operating' || target === 'both') {
|
||||
operating += flatChange * totalUnits * sign;
|
||||
}
|
||||
if (target === 'reserve' || target === 'both') {
|
||||
reserve += flatChange * totalUnits * sign;
|
||||
}
|
||||
}
|
||||
} else if (a.change_type === 'special_assessment') {
|
||||
// Special assessment distributed across installments
|
||||
const perUnit = parseFloat(a.special_per_unit) || 0;
|
||||
const installments = parseInt(a.special_installments) || 1;
|
||||
const monthsFromStart = (year - effectiveDate.getFullYear()) * 12 + (month - (effectiveDate.getMonth() + 1));
|
||||
|
||||
if (monthsFromStart >= 0 && monthsFromStart < installments) {
|
||||
const monthlyIncome = (perUnit * totalUnits) / installments;
|
||||
const target = a.target_fund || 'reserve';
|
||||
if (target === 'operating' || target === 'both') operating += monthlyIncome;
|
||||
if (target === 'reserve' || target === 'both') reserve += monthlyIncome;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { operating, reserve };
|
||||
}
|
||||
|
||||
private computeSummary(
|
||||
datapoints: any[], baseline: any, scenarioAssessments: any[],
|
||||
investments?: any[], totalInterestEarned = 0, interestByInvestment: Record<string, number> = {},
|
||||
) {
|
||||
if (!datapoints.length) return {};
|
||||
|
||||
const last = datapoints[datapoints.length - 1];
|
||||
const first = datapoints[0];
|
||||
|
||||
const allLiquidity = datapoints.map(
|
||||
(d) => d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||
);
|
||||
const minLiquidity = Math.min(...allLiquidity);
|
||||
const endLiquidity = allLiquidity[allLiquidity.length - 1];
|
||||
|
||||
// Reserve coverage: reserve balance / avg monthly reserve expenditure from planned capital projects
|
||||
let totalReserveProjectCost = 0;
|
||||
const projectionYears = Math.max(1, Math.ceil(datapoints.length / 12));
|
||||
for (const key of Object.keys(baseline.projectIndex)) {
|
||||
totalReserveProjectCost += baseline.projectIndex[key].reserve || 0;
|
||||
}
|
||||
const avgMonthlyReserveExpenditure = totalReserveProjectCost > 0
|
||||
? totalReserveProjectCost / (projectionYears * 12)
|
||||
: 0;
|
||||
const reserveCoverageMonths = avgMonthlyReserveExpenditure > 0
|
||||
? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure
|
||||
: 0; // No planned projects = show 0 (N/A)
|
||||
|
||||
// Calculate total principal from scenario investments
|
||||
let totalPrincipal = 0;
|
||||
const investmentInterestDetails: Array<{ id: string; label: string; principal: number; interest: number }> = [];
|
||||
if (investments) {
|
||||
for (const inv of investments) {
|
||||
if (inv.executed_investment_id) continue;
|
||||
const principal = parseFloat(inv.principal) || 0;
|
||||
totalPrincipal += principal;
|
||||
const interest = interestByInvestment[inv.id] || 0;
|
||||
investmentInterestDetails.push({
|
||||
id: inv.id,
|
||||
label: inv.label,
|
||||
principal: round2(principal),
|
||||
interest: round2(interest),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
end_liquidity: round2(endLiquidity),
|
||||
min_liquidity: round2(minLiquidity),
|
||||
reserve_coverage_months: round2(reserveCoverageMonths),
|
||||
end_operating_cash: last.operating_cash,
|
||||
end_reserve_cash: last.reserve_cash,
|
||||
end_operating_investments: last.operating_investments,
|
||||
end_reserve_investments: last.reserve_investments,
|
||||
period_change: round2(endLiquidity - allLiquidity[0]),
|
||||
total_interest_earned: round2(totalInterestEarned),
|
||||
total_principal_invested: round2(totalPrincipal),
|
||||
roi_percentage: totalPrincipal > 0 ? round2((totalInterestEarned / totalPrincipal) * 100) : 0,
|
||||
investment_interest_details: investmentInterestDetails,
|
||||
};
|
||||
}
|
||||
}
|
||||
200
backend/src/modules/board-planning/board-planning.controller.ts
Normal file
200
backend/src/modules/board-planning/board-planning.controller.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
import { BoardPlanningService } from './board-planning.service';
|
||||
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||
import { BudgetPlanningService } from './budget-planning.service';
|
||||
|
||||
@ApiTags('board-planning')
|
||||
@Controller('board-planning')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BoardPlanningController {
|
||||
constructor(
|
||||
private service: BoardPlanningService,
|
||||
private projection: BoardPlanningProjectionService,
|
||||
private budgetPlanning: BudgetPlanningService,
|
||||
) {}
|
||||
|
||||
// ── Scenarios ──
|
||||
|
||||
@Get('scenarios')
|
||||
@AllowViewer()
|
||||
listScenarios(@Query('type') type?: string) {
|
||||
return this.service.listScenarios(type);
|
||||
}
|
||||
|
||||
@Get('scenarios/:id')
|
||||
@AllowViewer()
|
||||
getScenario(@Param('id') id: string) {
|
||||
return this.service.getScenario(id);
|
||||
}
|
||||
|
||||
@Post('scenarios')
|
||||
createScenario(@Body() dto: any, @Req() req: any) {
|
||||
return this.service.createScenario(dto, req.user.sub);
|
||||
}
|
||||
|
||||
@Put('scenarios/:id')
|
||||
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.updateScenario(id, dto);
|
||||
}
|
||||
|
||||
@Delete('scenarios/:id')
|
||||
deleteScenario(@Param('id') id: string) {
|
||||
return this.service.deleteScenario(id);
|
||||
}
|
||||
|
||||
// ── Scenario Investments ──
|
||||
|
||||
@Get('scenarios/:scenarioId/investments')
|
||||
@AllowViewer()
|
||||
listInvestments(@Param('scenarioId') scenarioId: string) {
|
||||
return this.service.listInvestments(scenarioId);
|
||||
}
|
||||
|
||||
@Post('scenarios/:scenarioId/investments')
|
||||
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||
return this.service.addInvestment(scenarioId, dto);
|
||||
}
|
||||
|
||||
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
||||
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
||||
}
|
||||
|
||||
@Put('investments/:id')
|
||||
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.updateInvestment(id, dto);
|
||||
}
|
||||
|
||||
@Delete('investments/:id')
|
||||
removeInvestment(@Param('id') id: string) {
|
||||
return this.service.removeInvestment(id);
|
||||
}
|
||||
|
||||
// ── Scenario Assessments ──
|
||||
|
||||
@Get('scenarios/:scenarioId/assessments')
|
||||
@AllowViewer()
|
||||
listAssessments(@Param('scenarioId') scenarioId: string) {
|
||||
return this.service.listAssessments(scenarioId);
|
||||
}
|
||||
|
||||
@Post('scenarios/:scenarioId/assessments')
|
||||
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||
return this.service.addAssessment(scenarioId, dto);
|
||||
}
|
||||
|
||||
@Put('assessments/:id')
|
||||
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.updateAssessment(id, dto);
|
||||
}
|
||||
|
||||
@Delete('assessments/:id')
|
||||
removeAssessment(@Param('id') id: string) {
|
||||
return this.service.removeAssessment(id);
|
||||
}
|
||||
|
||||
// ── Projections ──
|
||||
|
||||
@Get('scenarios/:id/projection')
|
||||
@AllowViewer()
|
||||
getProjection(@Param('id') id: string) {
|
||||
return this.projection.getProjection(id);
|
||||
}
|
||||
|
||||
@Post('scenarios/:id/projection/refresh')
|
||||
refreshProjection(@Param('id') id: string) {
|
||||
return this.projection.computeProjection(id);
|
||||
}
|
||||
|
||||
// ── Comparison ──
|
||||
|
||||
@Get('compare')
|
||||
@AllowViewer()
|
||||
compareScenarios(@Query('ids') ids: string) {
|
||||
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return this.projection.compareScenarios(scenarioIds);
|
||||
}
|
||||
|
||||
// ── Execute Investment ──
|
||||
|
||||
@Post('investments/:id/execute')
|
||||
executeInvestment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { executionDate: string },
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.service.executeInvestment(id, dto.executionDate, req.user.sub);
|
||||
}
|
||||
|
||||
// ── Budget Planning ──
|
||||
|
||||
@Get('budget-plans')
|
||||
@AllowViewer()
|
||||
listBudgetPlans() {
|
||||
return this.budgetPlanning.listPlans();
|
||||
}
|
||||
|
||||
@Get('budget-plans/available-years')
|
||||
@AllowViewer()
|
||||
getAvailableYears() {
|
||||
return this.budgetPlanning.getAvailableYears();
|
||||
}
|
||||
|
||||
@Get('budget-plans/:year')
|
||||
@AllowViewer()
|
||||
getBudgetPlan(@Param('year') year: string) {
|
||||
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
||||
}
|
||||
|
||||
@Post('budget-plans')
|
||||
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
||||
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
||||
}
|
||||
|
||||
@Put('budget-plans/:year/lines')
|
||||
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
||||
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
||||
}
|
||||
|
||||
@Put('budget-plans/:year/inflation')
|
||||
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
||||
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
||||
}
|
||||
|
||||
@Put('budget-plans/:year/status')
|
||||
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
||||
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
||||
}
|
||||
|
||||
@Post('budget-plans/:year/import')
|
||||
importBudgetPlanLines(
|
||||
@Param('year') year: string,
|
||||
@Body() lines: any[],
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.budgetPlanning.importLines(parseInt(year, 10), lines, req.user.sub);
|
||||
}
|
||||
|
||||
@Get('budget-plans/:year/template')
|
||||
async getBudgetPlanTemplate(
|
||||
@Param('year') year: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const csv = await this.budgetPlanning.getTemplate(parseInt(year, 10));
|
||||
res.set({
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': `attachment; filename="budget_template_${year}.csv"`,
|
||||
});
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
@Delete('budget-plans/:year')
|
||||
deleteBudgetPlan(@Param('year') year: string) {
|
||||
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
||||
}
|
||||
}
|
||||
12
backend/src/modules/board-planning/board-planning.module.ts
Normal file
12
backend/src/modules/board-planning/board-planning.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BoardPlanningController } from './board-planning.controller';
|
||||
import { BoardPlanningService } from './board-planning.service';
|
||||
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||
import { BudgetPlanningService } from './budget-planning.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BoardPlanningController],
|
||||
providers: [BoardPlanningService, BoardPlanningProjectionService, BudgetPlanningService],
|
||||
exports: [BoardPlanningService, BudgetPlanningService],
|
||||
})
|
||||
export class BoardPlanningModule {}
|
||||
383
backend/src/modules/board-planning/board-planning.service.ts
Normal file
383
backend/src/modules/board-planning/board-planning.service.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class BoardPlanningService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
// ── Scenarios ──
|
||||
|
||||
async listScenarios(type?: string) {
|
||||
let sql = `
|
||||
SELECT bs.*,
|
||||
(SELECT COUNT(*) FROM scenario_investments si WHERE si.scenario_id = bs.id) as investment_count,
|
||||
(SELECT COALESCE(SUM(si.principal), 0) FROM scenario_investments si WHERE si.scenario_id = bs.id) as total_principal,
|
||||
(SELECT COUNT(*) FROM scenario_assessments sa WHERE sa.scenario_id = bs.id) as assessment_count
|
||||
FROM board_scenarios bs
|
||||
WHERE bs.status != 'archived'
|
||||
`;
|
||||
const params: any[] = [];
|
||||
if (type) {
|
||||
params.push(type);
|
||||
sql += ` AND bs.scenario_type = $${params.length}`;
|
||||
}
|
||||
sql += ' ORDER BY bs.updated_at DESC';
|
||||
return this.tenant.query(sql, params);
|
||||
}
|
||||
|
||||
async getScenario(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||
const scenario = rows[0];
|
||||
|
||||
const investments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
|
||||
[id],
|
||||
);
|
||||
const assessments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
|
||||
[id],
|
||||
);
|
||||
|
||||
return { ...scenario, investments, assessments };
|
||||
}
|
||||
|
||||
async createScenario(dto: any, userId: string) {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO board_scenarios (name, description, scenario_type, projection_months, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[dto.name, dto.description || null, dto.scenarioType, dto.projectionMonths || 36, userId],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateScenario(id: string, dto: any) {
|
||||
await this.getScenarioRow(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE board_scenarios SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
status = COALESCE($4, status),
|
||||
projection_months = COALESCE($5, projection_months),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, dto.name, dto.description, dto.status, dto.projectionMonths],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async deleteScenario(id: string) {
|
||||
await this.getScenarioRow(id);
|
||||
await this.tenant.query(
|
||||
`UPDATE board_scenarios SET status = 'archived', updated_at = NOW() WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scenario Investments ──
|
||||
|
||||
async listInvestments(scenarioId: string) {
|
||||
return this.tenant.query(
|
||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
|
||||
[scenarioId],
|
||||
);
|
||||
}
|
||||
|
||||
async addInvestment(scenarioId: string, dto: any) {
|
||||
await this.getScenarioRow(scenarioId);
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO scenario_investments
|
||||
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||
principal, interest_rate, term_months, institution, purchase_date, maturity_date,
|
||||
auto_renew, notes, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
scenarioId, dto.sourceRecommendationId || null, dto.label,
|
||||
dto.investmentType || null, dto.fundType,
|
||||
dto.principal, dto.interestRate || null, dto.termMonths || null,
|
||||
dto.institution || null, dto.purchaseDate || null, dto.maturityDate || null,
|
||||
dto.autoRenew || false, dto.notes || null, dto.sortOrder || 0,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(scenarioId);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async addInvestmentFromRecommendation(scenarioId: string, dto: any) {
|
||||
await this.getScenarioRow(scenarioId);
|
||||
|
||||
// Helper: compute maturity date from purchase date + term months
|
||||
const computeMaturityDate = (purchaseDate: string | null, termMonths: number | null): string | null => {
|
||||
if (!purchaseDate || !termMonths) return null;
|
||||
const d = new Date(purchaseDate);
|
||||
d.setMonth(d.getMonth() + termMonths);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const startDate = dto.startDate || null; // ISO date string e.g. "2026-03-16"
|
||||
|
||||
// If the recommendation has components (e.g. CD ladder with multiple CDs), create one row per component
|
||||
const components = dto.components as any[] | undefined;
|
||||
if (components && Array.isArray(components) && components.length > 0) {
|
||||
const results: any[] = [];
|
||||
for (let i = 0; i < components.length; i++) {
|
||||
const comp = components[i];
|
||||
const termMonths = comp.term_months || null;
|
||||
const maturityDate = computeMaturityDate(startDate, termMonths);
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO scenario_investments
|
||||
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||
principal, interest_rate, term_months, institution, purchase_date, maturity_date,
|
||||
notes, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
scenarioId, dto.sourceRecommendationId || null,
|
||||
comp.label || `${dto.title || 'AI Recommendation'} - Part ${i + 1}`,
|
||||
comp.investment_type || dto.investmentType || null,
|
||||
dto.fundType || 'reserve',
|
||||
comp.amount || 0, comp.rate || null,
|
||||
termMonths, comp.bank_name || dto.bankName || null,
|
||||
startDate, maturityDate,
|
||||
dto.rationale || dto.notes || null,
|
||||
i,
|
||||
],
|
||||
);
|
||||
results.push(rows[0]);
|
||||
}
|
||||
await this.invalidateProjectionCache(scenarioId);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Single investment (no components)
|
||||
const termMonths = dto.termMonths || null;
|
||||
const maturityDate = computeMaturityDate(startDate, termMonths);
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO scenario_investments
|
||||
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||
principal, interest_rate, term_months, institution, purchase_date, maturity_date, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
scenarioId, dto.sourceRecommendationId || null,
|
||||
dto.title || dto.label || 'AI Recommendation',
|
||||
dto.investmentType || null, dto.fundType || 'reserve',
|
||||
dto.suggestedAmount || 0, dto.suggestedRate || null,
|
||||
termMonths, dto.bankName || null,
|
||||
startDate, maturityDate,
|
||||
dto.rationale || dto.notes || null,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(scenarioId);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateInvestment(id: string, dto: any) {
|
||||
const inv = await this.getInvestmentRow(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE scenario_investments SET
|
||||
label = COALESCE($2, label),
|
||||
investment_type = COALESCE($3, investment_type),
|
||||
fund_type = COALESCE($4, fund_type),
|
||||
principal = COALESCE($5, principal),
|
||||
interest_rate = COALESCE($6, interest_rate),
|
||||
term_months = COALESCE($7, term_months),
|
||||
institution = COALESCE($8, institution),
|
||||
purchase_date = COALESCE($9, purchase_date),
|
||||
maturity_date = COALESCE($10, maturity_date),
|
||||
auto_renew = COALESCE($11, auto_renew),
|
||||
notes = COALESCE($12, notes),
|
||||
sort_order = COALESCE($13, sort_order),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[
|
||||
id, dto.label, dto.investmentType, dto.fundType,
|
||||
dto.principal, dto.interestRate, dto.termMonths,
|
||||
dto.institution, dto.purchaseDate, dto.maturityDate,
|
||||
dto.autoRenew, dto.notes, dto.sortOrder,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(inv.scenario_id);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async removeInvestment(id: string) {
|
||||
const inv = await this.getInvestmentRow(id);
|
||||
await this.tenant.query('DELETE FROM scenario_investments WHERE id = $1', [id]);
|
||||
await this.invalidateProjectionCache(inv.scenario_id);
|
||||
}
|
||||
|
||||
// ── Scenario Assessments ──
|
||||
|
||||
async listAssessments(scenarioId: string) {
|
||||
return this.tenant.query(
|
||||
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
|
||||
[scenarioId],
|
||||
);
|
||||
}
|
||||
|
||||
async addAssessment(scenarioId: string, dto: any) {
|
||||
await this.getScenarioRow(scenarioId);
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO scenario_assessments
|
||||
(scenario_id, change_type, label, target_fund, percentage_change,
|
||||
flat_amount_change, special_total, special_per_unit, special_installments,
|
||||
effective_date, end_date, applies_to_group_id, notes, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
scenarioId, dto.changeType, dto.label, dto.targetFund || 'operating',
|
||||
dto.percentageChange || null, dto.flatAmountChange || null,
|
||||
dto.specialTotal || null, dto.specialPerUnit || null,
|
||||
dto.specialInstallments || 1, dto.effectiveDate,
|
||||
dto.endDate || null, dto.appliesToGroupId || null,
|
||||
dto.notes || null, dto.sortOrder || 0,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(scenarioId);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateAssessment(id: string, dto: any) {
|
||||
const asmt = await this.getAssessmentRow(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE scenario_assessments SET
|
||||
change_type = COALESCE($2, change_type),
|
||||
label = COALESCE($3, label),
|
||||
target_fund = COALESCE($4, target_fund),
|
||||
percentage_change = COALESCE($5, percentage_change),
|
||||
flat_amount_change = COALESCE($6, flat_amount_change),
|
||||
special_total = COALESCE($7, special_total),
|
||||
special_per_unit = COALESCE($8, special_per_unit),
|
||||
special_installments = COALESCE($9, special_installments),
|
||||
effective_date = COALESCE($10, effective_date),
|
||||
end_date = COALESCE($11, end_date),
|
||||
applies_to_group_id = COALESCE($12, applies_to_group_id),
|
||||
notes = COALESCE($13, notes),
|
||||
sort_order = COALESCE($14, sort_order),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[
|
||||
id, dto.changeType, dto.label, dto.targetFund,
|
||||
dto.percentageChange, dto.flatAmountChange,
|
||||
dto.specialTotal, dto.specialPerUnit, dto.specialInstallments,
|
||||
dto.effectiveDate, dto.endDate, dto.appliesToGroupId,
|
||||
dto.notes, dto.sortOrder,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(asmt.scenario_id);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async removeAssessment(id: string) {
|
||||
const asmt = await this.getAssessmentRow(id);
|
||||
await this.tenant.query('DELETE FROM scenario_assessments WHERE id = $1', [id]);
|
||||
await this.invalidateProjectionCache(asmt.scenario_id);
|
||||
}
|
||||
|
||||
// ── Execute Investment (Story 1D) ──
|
||||
|
||||
async executeInvestment(investmentId: string, executionDate: string, userId: string) {
|
||||
const inv = await this.getInvestmentRow(investmentId);
|
||||
if (inv.executed_investment_id) {
|
||||
throw new BadRequestException('This investment has already been executed');
|
||||
}
|
||||
|
||||
// 1. Create real investment_accounts record
|
||||
const invRows = await this.tenant.query(
|
||||
`INSERT INTO investment_accounts
|
||||
(name, institution, investment_type, fund_type, principal, interest_rate,
|
||||
maturity_date, purchase_date, current_value, notes, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
|
||||
RETURNING *`,
|
||||
[
|
||||
inv.label, inv.institution, inv.investment_type || 'cd',
|
||||
inv.fund_type, inv.principal, inv.interest_rate || 0,
|
||||
inv.maturity_date, executionDate, inv.principal,
|
||||
`Executed from scenario investment. ${inv.notes || ''}`.trim(),
|
||||
],
|
||||
);
|
||||
const realInvestment = invRows[0];
|
||||
|
||||
// 2. Create journal entry at the execution date
|
||||
const entryDate = new Date(executionDate);
|
||||
const year = entryDate.getFullYear();
|
||||
const month = entryDate.getMonth() + 1;
|
||||
|
||||
const periods = await this.tenant.query(
|
||||
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||
[year, month],
|
||||
);
|
||||
if (periods.length) {
|
||||
const primaryRows = await this.tenant.query(
|
||||
`SELECT id, name FROM accounts WHERE is_primary = true AND fund_type = $1 AND is_active = true LIMIT 1`,
|
||||
[inv.fund_type],
|
||||
);
|
||||
const equityAccountNumber = inv.fund_type === 'reserve' ? '3100' : '3000';
|
||||
const equityRows = await this.tenant.query(
|
||||
'SELECT id FROM accounts WHERE account_number = $1',
|
||||
[equityAccountNumber],
|
||||
);
|
||||
|
||||
if (primaryRows.length && equityRows.length) {
|
||||
const memo = `Transfer to investment: ${inv.label}`;
|
||||
const jeRows = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4)
|
||||
RETURNING *`,
|
||||
[executionDate, memo, periods[0].id, userId],
|
||||
);
|
||||
const je = jeRows[0];
|
||||
// Credit primary asset account (reduces cash)
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, 0, $3, $4)`,
|
||||
[je.id, primaryRows[0].id, inv.principal, memo],
|
||||
);
|
||||
// Debit equity offset account
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, $3, 0, $4)`,
|
||||
[je.id, equityRows[0].id, inv.principal, memo],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Link back to scenario investment
|
||||
await this.tenant.query(
|
||||
`UPDATE scenario_investments SET executed_investment_id = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[realInvestment.id, investmentId],
|
||||
);
|
||||
|
||||
await this.invalidateProjectionCache(inv.scenario_id);
|
||||
return realInvestment;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private async getScenarioRow(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async getInvestmentRow(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM scenario_investments WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario investment not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async getAssessmentRow(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM scenario_assessments WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario assessment not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async invalidateProjectionCache(scenarioId: string) {
|
||||
await this.tenant.query(
|
||||
`UPDATE board_scenarios SET projection_cache = NULL, projection_cached_at = NULL, updated_at = NOW() WHERE id = $1`,
|
||||
[scenarioId],
|
||||
);
|
||||
}
|
||||
}
|
||||
407
backend/src/modules/board-planning/budget-planning.service.ts
Normal file
407
backend/src/modules/board-planning/budget-planning.service.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
const monthCols = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||
|
||||
@Injectable()
|
||||
export class BudgetPlanningService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
// ── Plans CRUD ──
|
||||
|
||||
async listPlans() {
|
||||
return this.tenant.query(
|
||||
`SELECT bp.*,
|
||||
(SELECT COUNT(*) FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = bp.id) as line_count
|
||||
FROM budget_plans bp ORDER BY bp.fiscal_year`,
|
||||
);
|
||||
}
|
||||
|
||||
async getPlan(fiscalYear: number) {
|
||||
const plans = await this.tenant.query(
|
||||
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||
);
|
||||
if (!plans.length) return null;
|
||||
|
||||
const plan = plans[0];
|
||||
const lines = await this.tenant.query(
|
||||
`SELECT bpl.*, a.account_number, a.name as account_name, a.account_type, a.fund_type as account_fund_type
|
||||
FROM budget_plan_lines bpl
|
||||
JOIN accounts a ON a.id = bpl.account_id
|
||||
WHERE bpl.budget_plan_id = $1
|
||||
ORDER BY a.account_number`,
|
||||
[plan.id],
|
||||
);
|
||||
return { ...plan, lines };
|
||||
}
|
||||
|
||||
async getAvailableYears() {
|
||||
// Find the latest year that has official budgets
|
||||
const result = await this.tenant.query(
|
||||
'SELECT MAX(fiscal_year) as max_year FROM budgets',
|
||||
);
|
||||
const rawMaxYear = result[0]?.max_year;
|
||||
const latestBudgetYear = rawMaxYear || null; // null means no budgets exist at all
|
||||
const baseYear = rawMaxYear || new Date().getFullYear();
|
||||
|
||||
// Also find years that already have plans
|
||||
const existingPlans = await this.tenant.query(
|
||||
'SELECT fiscal_year, status FROM budget_plans ORDER BY fiscal_year',
|
||||
);
|
||||
const planYears = existingPlans.map((p: any) => ({
|
||||
year: p.fiscal_year,
|
||||
status: p.status,
|
||||
}));
|
||||
|
||||
// Return next 5 years (or current year + 4 if no budgets exist)
|
||||
const years = [];
|
||||
const startOffset = rawMaxYear ? 1 : 0; // include current year if no budgets exist
|
||||
for (let i = startOffset; i <= startOffset + 4; i++) {
|
||||
const yr = baseYear + i;
|
||||
const existing = planYears.find((p: any) => p.year === yr);
|
||||
years.push({
|
||||
year: yr,
|
||||
hasPlan: !!existing,
|
||||
status: existing?.status || null,
|
||||
});
|
||||
}
|
||||
return { latestBudgetYear, years, existingPlans: planYears };
|
||||
}
|
||||
|
||||
async createPlan(fiscalYear: number, baseYear: number, inflationRate: number, userId: string) {
|
||||
// Check no existing plan for this year
|
||||
const existing = await this.tenant.query(
|
||||
'SELECT id FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||
);
|
||||
if (existing.length) {
|
||||
throw new BadRequestException(`A budget plan already exists for ${fiscalYear}`);
|
||||
}
|
||||
|
||||
// Create the plan
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[fiscalYear, baseYear, inflationRate, userId],
|
||||
);
|
||||
const plan = rows[0];
|
||||
|
||||
// Generate inflated lines from base year
|
||||
await this.generateLines(plan.id, baseYear, inflationRate, fiscalYear);
|
||||
|
||||
return this.getPlan(fiscalYear);
|
||||
}
|
||||
|
||||
async generateLines(planId: string, baseYear: number, inflationRate: number, fiscalYear: number) {
|
||||
// Delete existing non-manually-adjusted lines (or all if fresh)
|
||||
await this.tenant.query(
|
||||
'DELETE FROM budget_plan_lines WHERE budget_plan_id = $1 AND is_manually_adjusted = false',
|
||||
[planId],
|
||||
);
|
||||
|
||||
// Try official budgets first, then fall back to budget_plan_lines for base year
|
||||
let baseLines = await this.tenant.query(
|
||||
`SELECT b.account_id, b.fund_type, ${monthCols.join(', ')}
|
||||
FROM budgets b WHERE b.fiscal_year = $1`,
|
||||
[baseYear],
|
||||
);
|
||||
|
||||
if (!baseLines.length) {
|
||||
// Fall back to budget_plan_lines for base year (for chained plans)
|
||||
baseLines = await this.tenant.query(
|
||||
`SELECT bpl.account_id, bpl.fund_type, ${monthCols.join(', ')}
|
||||
FROM budget_plan_lines bpl
|
||||
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
|
||||
WHERE bp.fiscal_year = $1`,
|
||||
[baseYear],
|
||||
);
|
||||
}
|
||||
|
||||
if (!baseLines.length) return;
|
||||
|
||||
// Compound inflation: (1 + rate/100)^yearsGap
|
||||
const yearsGap = Math.max(1, fiscalYear - baseYear);
|
||||
const multiplier = Math.pow(1 + inflationRate / 100, yearsGap);
|
||||
|
||||
// Get existing manually-adjusted lines to avoid duplicates
|
||||
const manualLines = await this.tenant.query(
|
||||
`SELECT account_id, fund_type FROM budget_plan_lines
|
||||
WHERE budget_plan_id = $1 AND is_manually_adjusted = true`,
|
||||
[planId],
|
||||
);
|
||||
const manualKeys = new Set(manualLines.map((l: any) => `${l.account_id}-${l.fund_type}`));
|
||||
|
||||
for (const line of baseLines) {
|
||||
const key = `${line.account_id}-${line.fund_type}`;
|
||||
if (manualKeys.has(key)) continue; // Don't overwrite manual edits
|
||||
|
||||
const inflated = monthCols.map((m) => {
|
||||
const val = parseFloat(line[m]) || 0;
|
||||
return Math.round(val * multiplier * 100) / 100;
|
||||
});
|
||||
|
||||
await this.tenant.query(
|
||||
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
|
||||
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
ON CONFLICT (budget_plan_id, account_id, fund_type)
|
||||
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
|
||||
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
|
||||
is_manually_adjusted=false`,
|
||||
[planId, line.account_id, line.fund_type, ...inflated],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async updateLines(planId: string, lines: any[]) {
|
||||
for (const line of lines) {
|
||||
const monthValues = monthCols.map((m) => {
|
||||
const key = m === 'dec_amt' ? 'dec' : m;
|
||||
return line[key] ?? line[m] ?? 0;
|
||||
});
|
||||
|
||||
await this.tenant.query(
|
||||
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
|
||||
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true)
|
||||
ON CONFLICT (budget_plan_id, account_id, fund_type)
|
||||
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
|
||||
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
|
||||
is_manually_adjusted=true`,
|
||||
[planId, line.accountId, line.fundType, ...monthValues],
|
||||
);
|
||||
}
|
||||
return { updated: lines.length };
|
||||
}
|
||||
|
||||
async updateInflation(fiscalYear: number, inflationRate: number) {
|
||||
const plans = await this.tenant.query(
|
||||
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||
);
|
||||
if (!plans.length) throw new NotFoundException('Budget plan not found');
|
||||
|
||||
const plan = plans[0];
|
||||
if (plan.status === 'ratified') {
|
||||
throw new BadRequestException('Cannot modify inflation on a ratified budget');
|
||||
}
|
||||
|
||||
await this.tenant.query(
|
||||
'UPDATE budget_plans SET inflation_rate = $1, updated_at = NOW() WHERE fiscal_year = $2',
|
||||
[inflationRate, fiscalYear],
|
||||
);
|
||||
|
||||
// Re-generate only non-manually-adjusted lines
|
||||
await this.generateLines(plan.id, plan.base_year, inflationRate, fiscalYear);
|
||||
|
||||
return this.getPlan(fiscalYear);
|
||||
}
|
||||
|
||||
async advanceStatus(fiscalYear: number, newStatus: string, userId: string) {
|
||||
const plans = await this.tenant.query(
|
||||
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||
);
|
||||
if (!plans.length) throw new NotFoundException('Budget plan not found');
|
||||
|
||||
const plan = plans[0];
|
||||
const validTransitions: Record<string, string[]> = {
|
||||
planning: ['approved'],
|
||||
approved: ['planning', 'ratified'],
|
||||
ratified: ['approved'],
|
||||
};
|
||||
|
||||
if (!validTransitions[plan.status]?.includes(newStatus)) {
|
||||
throw new BadRequestException(`Cannot transition from ${plan.status} to ${newStatus}`);
|
||||
}
|
||||
|
||||
// If reverting from ratified, remove official budget
|
||||
if (plan.status === 'ratified' && newStatus === 'approved') {
|
||||
await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]);
|
||||
}
|
||||
|
||||
const updates: string[] = ['status = $1', 'updated_at = NOW()'];
|
||||
const params: any[] = [newStatus];
|
||||
|
||||
if (newStatus === 'approved') {
|
||||
updates.push(`approved_by = $${params.length + 1}`, `approved_at = NOW()`);
|
||||
params.push(userId);
|
||||
} else if (newStatus === 'ratified') {
|
||||
updates.push(`ratified_by = $${params.length + 1}`, `ratified_at = NOW()`);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
params.push(fiscalYear);
|
||||
await this.tenant.query(
|
||||
`UPDATE budget_plans SET ${updates.join(', ')} WHERE fiscal_year = $${params.length}`,
|
||||
params,
|
||||
);
|
||||
|
||||
// If ratifying, copy to official budgets
|
||||
if (newStatus === 'ratified') {
|
||||
await this.ratifyToOfficial(plan.id, fiscalYear);
|
||||
}
|
||||
|
||||
return this.getPlan(fiscalYear);
|
||||
}
|
||||
|
||||
private async ratifyToOfficial(planId: string, fiscalYear: number) {
|
||||
// Clear existing official budgets for this year
|
||||
await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]);
|
||||
|
||||
// Copy plan lines to official budgets
|
||||
await this.tenant.query(
|
||||
`INSERT INTO budgets (fiscal_year, account_id, fund_type,
|
||||
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes)
|
||||
SELECT $1, bpl.account_id, bpl.fund_type,
|
||||
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun,
|
||||
bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt, bpl.notes
|
||||
FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = $2`,
|
||||
[fiscalYear, planId],
|
||||
);
|
||||
}
|
||||
|
||||
async importLines(fiscalYear: number, lines: any[], userId: string) {
|
||||
// Ensure plan exists (create if needed)
|
||||
let plans = await this.tenant.query(
|
||||
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||
);
|
||||
if (!plans.length) {
|
||||
await this.tenant.query(
|
||||
`INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by)
|
||||
VALUES ($1, $1, 0, $2) RETURNING *`,
|
||||
[fiscalYear, userId],
|
||||
);
|
||||
plans = await this.tenant.query(
|
||||
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||
);
|
||||
}
|
||||
const plan = plans[0];
|
||||
const errors: string[] = [];
|
||||
const created: string[] = [];
|
||||
let imported = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const accountNumber = String(line.accountNumber || line.account_number || '').trim();
|
||||
const accountName = String(line.accountName || line.account_name || '').trim();
|
||||
if (!accountNumber) {
|
||||
errors.push(`Row ${i + 1}: missing account_number`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let accounts = await this.tenant.query(
|
||||
`SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
||||
[accountNumber],
|
||||
);
|
||||
|
||||
// Auto-create account if not found
|
||||
if ((!accounts || accounts.length === 0) && accountName) {
|
||||
const accountType = this.inferAccountType(accountNumber, accountName);
|
||||
const fundType = this.inferFundType(accountNumber, accountName);
|
||||
await this.tenant.query(
|
||||
`INSERT INTO accounts (account_number, name, account_type, fund_type, is_system)
|
||||
VALUES ($1, $2, $3, $4, false)`,
|
||||
[accountNumber, accountName, accountType, fundType],
|
||||
);
|
||||
accounts = await this.tenant.query(
|
||||
`SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
||||
[accountNumber],
|
||||
);
|
||||
created.push(`${accountNumber} - ${accountName} (${accountType}/${fundType})`);
|
||||
}
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
errors.push(`Row ${i + 1}: account "${accountNumber}" not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const account = accounts[0];
|
||||
const fundType = line.fund_type || account.fund_type || 'operating';
|
||||
const monthValues = monthCols.map((m) => {
|
||||
const key = m === 'dec_amt' ? 'dec' : m;
|
||||
return this.parseCurrency(line[key] ?? line[m] ?? 0);
|
||||
});
|
||||
|
||||
await this.tenant.query(
|
||||
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
|
||||
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true)
|
||||
ON CONFLICT (budget_plan_id, account_id, fund_type)
|
||||
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
|
||||
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
|
||||
is_manually_adjusted=true`,
|
||||
[plan.id, account.id, fundType, ...monthValues],
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
|
||||
return { imported, errors, created, plan: await this.getPlan(fiscalYear) };
|
||||
}
|
||||
|
||||
async getTemplate(fiscalYear: number): Promise<string> {
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT a.account_number, a.name as account_name,
|
||||
COALESCE(b.jan, 0) as jan, COALESCE(b.feb, 0) as feb,
|
||||
COALESCE(b.mar, 0) as mar, COALESCE(b.apr, 0) as apr,
|
||||
COALESCE(b.may, 0) as may, COALESCE(b.jun, 0) as jun,
|
||||
COALESCE(b.jul, 0) as jul, COALESCE(b.aug, 0) as aug,
|
||||
COALESCE(b.sep, 0) as sep, COALESCE(b.oct, 0) as oct,
|
||||
COALESCE(b.nov, 0) as nov, COALESCE(b.dec_amt, 0) as dec
|
||||
FROM accounts a
|
||||
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||
WHERE a.is_active = true
|
||||
AND a.account_type IN ('income', 'expense')
|
||||
ORDER BY a.account_number`,
|
||||
[fiscalYear],
|
||||
);
|
||||
|
||||
const header = 'account_number,account_name,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec';
|
||||
const csvLines = rows.map((r: any) => {
|
||||
const name = String(r.account_name).includes(',') ? `"${r.account_name}"` : r.account_name;
|
||||
return [r.account_number, name, r.jan, r.feb, r.mar, r.apr, r.may, r.jun, r.jul, r.aug, r.sep, r.oct, r.nov, r.dec].join(',');
|
||||
});
|
||||
return [header, ...csvLines].join('\n');
|
||||
}
|
||||
|
||||
private parseCurrency(val: string | number | undefined | null): number {
|
||||
if (val === undefined || val === null) return 0;
|
||||
if (typeof val === 'number') return val;
|
||||
let s = String(val).trim();
|
||||
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
|
||||
const isNegative = s.includes('(') && s.includes(')');
|
||||
s = s.replace(/[$,\s()]/g, '');
|
||||
if (!s || s === '-') return 0;
|
||||
const num = parseFloat(s);
|
||||
if (isNaN(num)) return 0;
|
||||
return isNegative ? -num : num;
|
||||
}
|
||||
|
||||
private inferAccountType(accountNumber: string, accountName: string): string {
|
||||
const prefix = parseInt(accountNumber.split('-')[0].trim(), 10);
|
||||
if (isNaN(prefix)) return 'expense';
|
||||
const nameUpper = (accountName || '').toUpperCase();
|
||||
if (prefix >= 3000 && prefix < 4000) return 'income';
|
||||
if (nameUpper.includes('INCOME') || nameUpper.includes('REVENUE') || nameUpper.includes('ASSESSMENT')) return 'income';
|
||||
return 'expense';
|
||||
}
|
||||
|
||||
private inferFundType(accountNumber: string, accountName: string): string {
|
||||
const prefix = parseInt(accountNumber.split('-')[0].trim(), 10);
|
||||
const nameUpper = (accountName || '').toUpperCase();
|
||||
if (nameUpper.includes('RESERVE')) return 'reserve';
|
||||
if (prefix >= 7000 && prefix < 8000) return 'reserve';
|
||||
return 'operating';
|
||||
}
|
||||
|
||||
async deletePlan(fiscalYear: number) {
|
||||
const plans = await this.tenant.query(
|
||||
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||
);
|
||||
if (!plans.length) throw new NotFoundException('Budget plan not found');
|
||||
|
||||
if (plans[0].status !== 'planning') {
|
||||
throw new BadRequestException('Can only delete plans in planning status');
|
||||
}
|
||||
|
||||
await this.tenant.query('DELETE FROM budget_plans WHERE fiscal_year = $1', [fiscalYear]);
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/health-scores/health-scores.module.ts
Normal file
10
backend/src/modules/health-scores/health-scores.module.ts
Normal 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 {}
|
||||
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal file
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
1209
backend/src/modules/health-scores/health-scores.service.ts
Normal file
1209
backend/src/modules/health-scores/health-scores.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,50 +143,87 @@ 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 unit of units) {
|
||||
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
|
||||
|
||||
// Check if already generated
|
||||
const existing = await this.tenant.query(
|
||||
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
|
||||
);
|
||||
if (existing.length) continue;
|
||||
|
||||
// Create the invoice
|
||||
const inv = await this.tenant.query(
|
||||
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
|
||||
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
|
||||
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
|
||||
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
|
||||
unit.monthly_assessment],
|
||||
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],
|
||||
);
|
||||
|
||||
// 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 (!units.length) continue;
|
||||
|
||||
if (arAccount.length && incomeAccount.length) {
|
||||
const je = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
|
||||
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
|
||||
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
|
||||
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}`;
|
||||
|
||||
// Check if already generated
|
||||
const existing = await this.tenant.query(
|
||||
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
|
||||
);
|
||||
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],
|
||||
);
|
||||
await this.tenant.query(
|
||||
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
|
||||
if (existing.length) continue;
|
||||
|
||||
// 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, period_start, period_end, assessment_group_id)
|
||||
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) RETURNING id`,
|
||||
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
|
||||
period.description, unitAmount, period.start, period.end, group.id],
|
||||
);
|
||||
|
||||
// Create journal entry: DR Accounts Receivable, CR Assessment Income
|
||||
if (arAccount.length && incomeAccount.length) {
|
||||
const je = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
|
||||
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
|
||||
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
|
||||
);
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
|
||||
[je[0].id, arAccount[0].id, unitAmount, incomeAccount[0].id],
|
||||
);
|
||||
await this.tenant.query(
|
||||
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
|
||||
);
|
||||
}
|
||||
created++;
|
||||
groupCreated++;
|
||||
}
|
||||
created++;
|
||||
|
||||
groupResults.push({
|
||||
group_name: group.name,
|
||||
frequency,
|
||||
period: period.description,
|
||||
invoices_created: groupCreated,
|
||||
});
|
||||
}
|
||||
|
||||
return { created, month: dto.month, year: dto.year };
|
||||
return { created, month: dto.month, year: dto.year, groups: groupResults };
|
||||
}
|
||||
|
||||
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND je.entry_date <= $1
|
||||
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
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND je.entry_date BETWEEN $1 AND $2
|
||||
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(`
|
||||
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
WHERE a.account_type = 'income' AND a.is_active = true
|
||||
GROUP BY a.id, a.name
|
||||
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
||||
ORDER BY amount DESC
|
||||
`, [year]);
|
||||
async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
|
||||
let income: any[];
|
||||
let expenses: any[];
|
||||
|
||||
const 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
|
||||
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]);
|
||||
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${fundCondition}
|
||||
GROUP BY a.id, a.name
|
||||
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
||||
ORDER BY amount DESC
|
||||
`, fundParams);
|
||||
|
||||
expenses = await this.tenant.query(`
|
||||
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition}
|
||||
GROUP BY a.id, a.name, a.fund_type
|
||||
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
|
||||
ORDER BY amount DESC
|
||||
`, fundParams);
|
||||
}
|
||||
|
||||
if (!income.length && !expenses.length) {
|
||||
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
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND je.entry_date < $1
|
||||
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
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND je.entry_date <= $1
|
||||
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,67 @@ 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: actual interest income from journal entries for current year
|
||||
const currentYear = new Date().getFullYear();
|
||||
const interestEarned = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
WHERE a.account_type = 'income' AND a.is_active = true
|
||||
AND LOWER(a.name) LIKE '%interest%'
|
||||
`, [currentYear]);
|
||||
|
||||
// Interest earned last year (for YoY comparison)
|
||||
const interestLastYear = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
WHERE a.account_type = 'income' AND a.is_active = true
|
||||
AND LOWER(a.name) LIKE '%interest%'
|
||||
`, [currentYear - 1]);
|
||||
|
||||
// Projected interest for current year: YTD actual + remaining months using
|
||||
// the rate-based est_monthly_interest (same source as the dashboard KPI)
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const ytdInterest = parseFloat(interestEarned[0]?.total || '0');
|
||||
const projectedInterest = ytdInterest + (estMonthlyInterest * (12 - currentMonth));
|
||||
|
||||
// Planned capital spend for current year
|
||||
const capitalSpend = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
||||
`, [currentYear]);
|
||||
|
||||
// Recent transactions
|
||||
const recentTx = await this.tenant.query(`
|
||||
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
||||
@@ -504,9 +764,19 @@ 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: ytdInterest.toFixed(2),
|
||||
interest_last_year: parseFloat(interestLastYear[0]?.total || '0').toFixed(2),
|
||||
interest_projected: projectedInterest.toFixed(2),
|
||||
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -594,15 +864,37 @@ export class ReportsService {
|
||||
// We need budgets for startYear and startYear+1 to cover 24 months
|
||||
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||
|
||||
for (const yr of [startYear, startYear + 1, startYear + 2]) {
|
||||
const budgetRows = await this.tenant.query(
|
||||
`SELECT b.fund_type, a.account_type,
|
||||
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
|
||||
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1`, [yr],
|
||||
);
|
||||
const endYear = startYear + Math.ceil(months / 12) + 1;
|
||||
for (let yr = startYear; yr <= endYear; yr++) {
|
||||
let budgetRows: any[];
|
||||
try {
|
||||
budgetRows = await this.tenant.query(
|
||||
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
|
||||
SELECT b.account_id, b.fund_type, a.account_type,
|
||||
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt,
|
||||
1 as source_priority
|
||||
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
|
||||
UNION ALL
|
||||
SELECT bpl.account_id, bpl.fund_type, a.account_type,
|
||||
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt,
|
||||
2 as source_priority
|
||||
FROM budget_plan_lines bpl
|
||||
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
|
||||
JOIN accounts a ON a.id = bpl.account_id
|
||||
WHERE bp.fiscal_year = $1
|
||||
) combined
|
||||
ORDER BY account_id, fund_type, source_priority`, [yr],
|
||||
);
|
||||
} catch {
|
||||
budgetRows = await this.tenant.query(
|
||||
`SELECT b.fund_type, a.account_type,
|
||||
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
|
||||
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1`, [yr],
|
||||
);
|
||||
}
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const key = `${yr}-${m + 1}`;
|
||||
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
@@ -795,4 +1087,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
27
backend/src/modules/vendors/vendors.service.ts
vendored
27
backend/src/modules/vendors/vendors.service.ts
vendored
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
17
db/migrations/005-cd-rates.sql
Normal file
17
db/migrations/005-cd-rates.sql
Normal 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);
|
||||
52
db/migrations/006-admin-platform.sql
Normal file
52
db/migrations/006-admin-platform.sql
Normal 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;
|
||||
36
db/migrations/007-market-rates.sql
Normal file
36
db/migrations/007-market-rates.sql
Normal 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 $$;
|
||||
16
db/migrations/008-vendor-last-negotiated.sql
Normal file
16
db/migrations/008-vendor-last-negotiated.sql
Normal 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 $$;
|
||||
9
db/migrations/009-onboarding-flags.sql
Normal file
9
db/migrations/009-onboarding-flags.sql
Normal 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;
|
||||
34
db/migrations/010-health-scores.sql
Normal file
34
db/migrations/010-health-scores.sql
Normal 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 $$;
|
||||
57
db/migrations/011-invoice-billing-frequency.sql
Normal file
57
db/migrations/011-invoice-billing-frequency.sql
Normal 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 $$;
|
||||
33
db/migrations/012-invoice-status-pending.sql
Normal file
33
db/migrations/012-invoice-status-pending.sql
Normal 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 $$;
|
||||
83
db/migrations/013-board-planning.sql
Normal file
83
db/migrations/013-board-planning.sql
Normal file
@@ -0,0 +1,83 @@
|
||||
-- Migration 013: Board Planning tables (scenarios, investments, assessments)
|
||||
-- Applies to all existing tenant schemas
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_schema TEXT;
|
||||
BEGIN
|
||||
FOR tenant_schema IN
|
||||
SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name LIKE 'tenant_%'
|
||||
LOOP
|
||||
-- Board Scenarios
|
||||
EXECUTE format('
|
||||
CREATE TABLE IF NOT EXISTS %I.board_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN (''investment'', ''assessment'')),
|
||||
status VARCHAR(20) DEFAULT ''draft'' CHECK (status IN (''draft'', ''active'', ''approved'', ''archived'')),
|
||||
projection_months INTEGER DEFAULT 36,
|
||||
projection_cache JSONB,
|
||||
projection_cached_at TIMESTAMPTZ,
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', tenant_schema);
|
||||
|
||||
-- Scenario Investments
|
||||
EXECUTE format('
|
||||
CREATE TABLE IF NOT EXISTS %I.scenario_investments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scenario_id UUID NOT NULL REFERENCES %I.board_scenarios(id) ON DELETE CASCADE,
|
||||
source_recommendation_id UUID,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
investment_type VARCHAR(50) CHECK (investment_type IN (''cd'', ''money_market'', ''treasury'', ''savings'', ''other'')),
|
||||
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN (''operating'', ''reserve'')),
|
||||
principal DECIMAL(15,2) NOT NULL,
|
||||
interest_rate DECIMAL(6,4),
|
||||
term_months INTEGER,
|
||||
institution VARCHAR(255),
|
||||
purchase_date DATE,
|
||||
maturity_date DATE,
|
||||
auto_renew BOOLEAN DEFAULT FALSE,
|
||||
executed_investment_id UUID,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', tenant_schema, tenant_schema);
|
||||
|
||||
-- Scenario Assessments
|
||||
EXECUTE format('
|
||||
CREATE TABLE IF NOT EXISTS %I.scenario_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scenario_id UUID NOT NULL REFERENCES %I.board_scenarios(id) ON DELETE CASCADE,
|
||||
change_type VARCHAR(30) NOT NULL CHECK (change_type IN (''dues_increase'', ''special_assessment'', ''dues_decrease'')),
|
||||
label VARCHAR(255) NOT NULL,
|
||||
target_fund VARCHAR(20) CHECK (target_fund IN (''operating'', ''reserve'', ''both'')),
|
||||
percentage_change DECIMAL(6,3),
|
||||
flat_amount_change DECIMAL(10,2),
|
||||
special_total DECIMAL(15,2),
|
||||
special_per_unit DECIMAL(10,2),
|
||||
special_installments INTEGER DEFAULT 1,
|
||||
effective_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
applies_to_group_id UUID,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', tenant_schema, tenant_schema);
|
||||
|
||||
-- Indexes
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bs_type_status ON %I.board_scenarios(scenario_type, status)',
|
||||
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_si_scenario ON %I.scenario_investments(scenario_id)',
|
||||
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_sa_scenario ON %I.scenario_assessments(scenario_id)',
|
||||
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||
|
||||
RAISE NOTICE 'Board planning tables created for schema: %', tenant_schema;
|
||||
END LOOP;
|
||||
END $$;
|
||||
54
db/migrations/014-budget-planning.sql
Normal file
54
db/migrations/014-budget-planning.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- Migration: Add budget_plans and budget_plan_lines tables to all tenant schemas
|
||||
DO $migration$
|
||||
DECLARE
|
||||
s TEXT;
|
||||
BEGIN
|
||||
FOR s IN
|
||||
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'
|
||||
LOOP
|
||||
-- budget_plans
|
||||
EXECUTE format('
|
||||
CREATE TABLE IF NOT EXISTS %I.budget_plans (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
fiscal_year INTEGER NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT ''planning'' CHECK (status IN (''planning'', ''approved'', ''ratified'')),
|
||||
base_year INTEGER NOT NULL,
|
||||
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
|
||||
notes TEXT,
|
||||
created_by UUID,
|
||||
approved_by UUID,
|
||||
approved_at TIMESTAMPTZ,
|
||||
ratified_by UUID,
|
||||
ratified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(fiscal_year)
|
||||
)', s);
|
||||
|
||||
-- budget_plan_lines
|
||||
EXECUTE format('
|
||||
CREATE TABLE IF NOT EXISTS %I.budget_plan_lines (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
budget_plan_id UUID NOT NULL REFERENCES %I.budget_plans(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES %I.accounts(id),
|
||||
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN (''operating'', ''reserve'')),
|
||||
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
|
||||
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
|
||||
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
|
||||
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
|
||||
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
|
||||
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
|
||||
is_manually_adjusted BOOLEAN DEFAULT FALSE,
|
||||
notes TEXT,
|
||||
UNIQUE(budget_plan_id, account_id, fund_type)
|
||||
)', s, s, s);
|
||||
|
||||
-- Indexes
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bp_year ON %I.budget_plans(fiscal_year)', replace(s, 'tenant_', ''), s);
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bp_status ON %I.budget_plans(status)', replace(s, 'tenant_', ''), s);
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bpl_plan ON %I.budget_plan_lines(budget_plan_id)', replace(s, 'tenant_', ''), s);
|
||||
|
||||
RAISE NOTICE 'Migrated schema: %', s;
|
||||
END LOOP;
|
||||
END;
|
||||
$migration$;
|
||||
@@ -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
95
docker-compose.prod.yml
Normal 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
28
docker-compose.ssl.yml
Normal 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:
|
||||
@@ -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
545
docs/AI_FEATURE_AUDIT.md
Normal 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
22
frontend/Dockerfile
Normal 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;"]
|
||||
@@ -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
20
frontend/nginx.conf
Normal 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
3324
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "2026.03.16",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 @@ 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';
|
||||
import { InvestmentScenariosPage } from './pages/board-planning/InvestmentScenariosPage';
|
||||
import { InvestmentScenarioDetailPage } from './pages/board-planning/InvestmentScenarioDetailPage';
|
||||
import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenariosPage';
|
||||
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
|
||||
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
||||
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
@@ -54,8 +62,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 +131,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 +142,13 @@ 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="board-planning/budgets" element={<BudgetPlanningPage />} />
|
||||
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
||||
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
||||
<Route path="board-planning/assessments" element={<AssessmentScenariosPage />} />
|
||||
<Route path="board-planning/assessments/:id" element={<AssessmentScenarioDetailPage />} />
|
||||
<Route path="board-planning/compare" element={<ScenarioComparisonPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||
<Route path="org-members" element={<OrgMembersPage />} />
|
||||
|
||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -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,46 +8,134 @@ 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 logoSrc from '../../assets/logo.svg';
|
||||
import { AppTour } from '../onboarding/AppTour';
|
||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||
import logoSrc from '../../assets/logo.png';
|
||||
|
||||
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 }} />
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="HOA LedgerIQ"
|
||||
style={{
|
||||
height: 40,
|
||||
...(colorScheme === 'dark' ? {
|
||||
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||
} : {}),
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
{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 +144,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 +205,10 @@ export function AppLayout() {
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
|
||||
{/* ── Onboarding Components ── */}
|
||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
IconCategory,
|
||||
IconChartAreaLine,
|
||||
IconClipboardCheck,
|
||||
IconSparkles,
|
||||
IconHeartRateMonitor,
|
||||
IconCalculator,
|
||||
IconGitCompare,
|
||||
IconScale,
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
@@ -28,32 +33,52 @@ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Planning',
|
||||
label: 'Board Planning',
|
||||
items: [
|
||||
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
|
||||
{
|
||||
label: 'Projects', icon: IconShieldCheck, path: '/projects',
|
||||
children: [
|
||||
{ label: 'Capital Planning', path: '/capital-projects' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
||||
},
|
||||
{
|
||||
label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning',
|
||||
children: [
|
||||
{ label: 'Investment Scenarios', path: '/board-planning/investments' },
|
||||
],
|
||||
},
|
||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Board Reference',
|
||||
items: [
|
||||
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
||||
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||
],
|
||||
},
|
||||
@@ -63,6 +88,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 +97,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 +112,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">
|
||||
<ScrollArea p="sm" data-tour="sidebar-nav">
|
||||
{navSections.map((section, sIdx) => (
|
||||
<div key={sIdx}>
|
||||
{section.label && (
|
||||
@@ -104,7 +163,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
</>
|
||||
)}
|
||||
{section.items.map((item: any) =>
|
||||
item.children ? (
|
||||
item.children && !item.path ? (
|
||||
// Collapsible group without a parent route (e.g. Reports)
|
||||
<NavLink
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
@@ -112,6 +172,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
|
||||
@@ -122,6 +183,29 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
) : item.children && item.path ? (
|
||||
// Parent with its own route + nested children (e.g. Projects > Capital Planning)
|
||||
<NavLink
|
||||
key={item.path}
|
||||
label={item.label}
|
||||
leftSection={<item.icon size={18} />}
|
||||
defaultOpened={
|
||||
location.pathname === item.path ||
|
||||
item.children.some((c: any) => location.pathname.startsWith(c.path))
|
||||
}
|
||||
data-tour={item.tourId || undefined}
|
||||
active={location.pathname === item.path}
|
||||
onClick={() => go(item.path!)}
|
||||
>
|
||||
{item.children.map((child: any) => (
|
||||
<NavLink
|
||||
key={child.path}
|
||||
label={child.label}
|
||||
active={location.pathname === child.path}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); go(child.path); }}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
) : (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
@@ -129,6 +213,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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
93
frontend/src/components/onboarding/AppTour.tsx
Normal file
93
frontend/src/components/onboarding/AppTour.tsx
Normal 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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal 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'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'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'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'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>
|
||||
);
|
||||
}
|
||||
68
frontend/src/config/tourSteps.ts
Normal file
68
frontend/src/config/tourSteps.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
Add Account
|
||||
</Button>
|
||||
{!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>
|
||||
</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">Reserve Fund</Text>
|
||||
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
|
||||
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
|
||||
</Card>
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Total All Funds</Text>
|
||||
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
||||
<Text 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,42 +1035,44 @@ function AccountTable({
|
||||
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="yellow"
|
||||
onClick={() => onSetPrimary(a.id)}
|
||||
>
|
||||
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||
{!isReadOnly && (
|
||||
<Group gap={4}>
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="yellow"
|
||||
onClick={() => onSetPrimary(a.id)}
|
||||
>
|
||||
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!a.is_system && (
|
||||
<Tooltip label="Adjust Balance">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||
<IconAdjustments size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Edit account">
|
||||
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!a.is_system && (
|
||||
<Tooltip label="Adjust Balance">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||
<IconAdjustments size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Edit account">
|
||||
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={a.is_active ? 'gray' : 'green'}
|
||||
onClick={() => onArchive(a)}
|
||||
>
|
||||
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={a.is_active ? 'gray' : 'green'}
|
||||
onClick={() => onArchive(a)}
|
||||
>
|
||||
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.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
@@ -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>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
Add Group
|
||||
</Button>
|
||||
{!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,28 +337,30 @@ export function AssessmentGroupsPage() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
|
||||
{!isReadOnly && (
|
||||
<Group gap={4}>
|
||||
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={g.is_default ? 'yellow' : 'gray'}
|
||||
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
|
||||
disabled={g.is_default}
|
||||
>
|
||||
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={g.is_default ? 'yellow' : 'gray'}
|
||||
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
|
||||
disabled={g.is_default}
|
||||
color={g.is_active ? 'gray' : 'green'}
|
||||
onClick={() => archiveMutation.mutate(g)}
|
||||
>
|
||||
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||
<IconArchive size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={g.is_active ? 'gray' : 'green'}
|
||||
onClick={() => archiveMutation.mutate(g)}
|
||||
>
|
||||
<IconArchive size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</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>
|
||||
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
|
||||
<Group grow>
|
||||
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
|
||||
<NumberInput
|
||||
label="Due Day of Month"
|
||||
description="Day invoices are due (1-28)"
|
||||
min={1}
|
||||
max={28}
|
||||
{...form.getInputProps('dueDay')}
|
||||
/>
|
||||
</Group>
|
||||
<Button type="submit" loading={saveMutation.isPending}>
|
||||
{editing ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
|
||||
@@ -16,13 +16,15 @@ import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import logoSrc from '../../assets/logo.svg';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
import logoSrc from '../../assets/logo.png';
|
||||
|
||||
export function LoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||
|
||||
const form = useForm({
|
||||
initialValues: { email: '', password: '' },
|
||||
@@ -38,8 +40,11 @@ export function LoginPage() {
|
||||
try {
|
||||
const { data } = await api.post('/auth/login', values);
|
||||
setAuth(data.accessToken, data.user, data.organizations);
|
||||
// Always go through org selection to ensure correct JWT with orgSchema
|
||||
if (data.organizations.length >= 1) {
|
||||
// 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
|
||||
navigate('/select-org');
|
||||
} else {
|
||||
navigate('/');
|
||||
@@ -54,7 +59,16 @@ export function LoginPage() {
|
||||
return (
|
||||
<Container size={420} my={80}>
|
||||
<Center>
|
||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 60 }} />
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="HOA LedgerIQ"
|
||||
style={{
|
||||
height: 60,
|
||||
...(isDark ? {
|
||||
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||
} : {}),
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Don't have an account?{' '}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||
Loader, Center, Select, SimpleGrid, Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus, IconArrowLeft, IconTrash, IconEdit,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
import { AssessmentChangeForm } from './components/AssessmentChangeForm';
|
||||
import { ProjectionChart } from './components/ProjectionChart';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
|
||||
};
|
||||
|
||||
const changeTypeLabels: Record<string, string> = {
|
||||
dues_increase: 'Dues Increase',
|
||||
dues_decrease: 'Dues Decrease',
|
||||
special_assessment: 'Special Assessment',
|
||||
};
|
||||
|
||||
const changeTypeColors: Record<string, string> = {
|
||||
dues_increase: 'green',
|
||||
dues_decrease: 'orange',
|
||||
special_assessment: 'violet',
|
||||
};
|
||||
|
||||
export function AssessmentScenarioDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editAsmt, setEditAsmt] = useState<any>(null);
|
||||
|
||||
const { data: scenario, isLoading } = useQuery({
|
||||
queryKey: ['board-planning-scenario', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/board-planning/scenarios/${id}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: projection, isLoading: projLoading } = useQuery({
|
||||
queryKey: ['board-planning-projection', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/assessments`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setAddOpen(false);
|
||||
notifications.show({ message: 'Assessment change added', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ asmtId, ...dto }: any) => api.put(`/board-planning/assessments/${asmtId}`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setEditAsmt(null);
|
||||
notifications.show({ message: 'Assessment change updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (asmtId: string) => api.delete(`/board-planning/assessments/${asmtId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
notifications.show({ message: 'Assessment change removed', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => api.put(`/board-planning/scenarios/${id}`, { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||
|
||||
const assessments = scenario.assessments || [];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/assessments')}>
|
||||
<IconArrowLeft size={20} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Title order={2}>{scenario.name}</Title>
|
||||
<Badge color={statusColors[scenario.status]}>{scenario.status}</Badge>
|
||||
</Group>
|
||||
{scenario.description && <Text c="dimmed" size="sm">{scenario.description}</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
<Group>
|
||||
<Select
|
||||
size="xs"
|
||||
value={scenario.status}
|
||||
onChange={(v) => v && statusMutation.mutate(v)}
|
||||
data={[
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
]}
|
||||
/>
|
||||
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
||||
Add Change
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{projection?.summary && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(projection.summary.end_liquidity || 0)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c={projection.summary.period_change >= 0 ? 'green' : 'red'}>
|
||||
{projection.summary.period_change >= 0 ? '+' : ''}{fmt(projection.summary.period_change || 0)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Min Liquidity</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c={projection.summary.min_liquidity < 0 ? 'red' : undefined}>
|
||||
{fmt(projection.summary.min_liquidity || 0)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Coverage</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{projection.summary.reserve_coverage_months > 0
|
||||
? `${projection.summary.reserve_coverage_months.toFixed(1)} mo`
|
||||
: 'N/A'}
|
||||
</Text>
|
||||
{projection.summary.reserve_coverage_months <= 0 && (
|
||||
<Text size="xs" c="dimmed">No planned capital projects</Text>
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Assessment Changes Table */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Assessment Changes ({assessments.length})</Title>
|
||||
{assessments.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Label</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Target Fund</Table.Th>
|
||||
<Table.Th ta="right">Change</Table.Th>
|
||||
<Table.Th>Effective</Table.Th>
|
||||
<Table.Th>End</Table.Th>
|
||||
<Table.Th w={80}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{assessments.map((a: any) => (
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td fw={500}>{a.label}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={changeTypeColors[a.change_type] || 'gray'}>
|
||||
{changeTypeLabels[a.change_type] || a.change_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{a.target_fund}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{a.change_type === 'special_assessment'
|
||||
? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit${(() => {
|
||||
const inst = parseInt(a.special_installments) || 1;
|
||||
if (inst === 1) return ', one-time';
|
||||
if (inst === 3) return ', quarterly';
|
||||
if (inst === 12) return ', annual';
|
||||
return `, ${inst} mo`;
|
||||
})()}`
|
||||
: a.percentage_change
|
||||
? `${parseFloat(a.percentage_change).toFixed(1)}%`
|
||||
: a.flat_amount_change
|
||||
? `${fmt(parseFloat(a.flat_amount_change))}/unit/mo`
|
||||
: '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>{a.effective_date ? new Date(a.effective_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td>{a.end_date ? new Date(a.end_date).toLocaleDateString() : 'Ongoing'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditAsmt(a)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(a.id)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No assessment changes added yet. Click "Add Change" to model a dues increase or special assessment.
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Projection Chart */}
|
||||
{projection && (
|
||||
<ProjectionChart
|
||||
datapoints={projection.datapoints || []}
|
||||
title="Assessment Impact Projection"
|
||||
summary={projection.summary}
|
||||
/>
|
||||
)}
|
||||
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<AssessmentChangeForm
|
||||
opened={addOpen || !!editAsmt}
|
||||
onClose={() => { setAddOpen(false); setEditAsmt(null); }}
|
||||
onSubmit={(data) => {
|
||||
if (editAsmt) {
|
||||
updateMutation.mutate({ asmtId: editAsmt.id, ...data });
|
||||
} else {
|
||||
addMutation.mutate(data);
|
||||
}
|
||||
}}
|
||||
initialData={editAsmt}
|
||||
loading={addMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
128
frontend/src/pages/board-planning/AssessmentScenariosPage.tsx
Normal file
128
frontend/src/pages/board-planning/AssessmentScenariosPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { Title, Text, Stack, Group, Button, SimpleGrid, Modal, TextInput, Textarea, Loader, Center } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
import { ScenarioCard } from './components/ScenarioCard';
|
||||
|
||||
export function AssessmentScenariosPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editScenario, setEditScenario] = useState<any>(null);
|
||||
const [form, setForm] = useState({ name: '', description: '' });
|
||||
|
||||
const { data: scenarios, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios', 'assessment'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios?type=assessment');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (dto: any) => api.post('/board-planning/scenarios', dto),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
setCreateOpen(false);
|
||||
setForm({ name: '', description: '' });
|
||||
notifications.show({ message: 'Scenario created', color: 'green' });
|
||||
navigate(`/board-planning/assessments/${res.data.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...dto }: any) => api.put(`/board-planning/scenarios/${id}`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
setEditScenario(null);
|
||||
notifications.show({ message: 'Scenario updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/board-planning/scenarios/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
notifications.show({ message: 'Scenario archived', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={2}>Assessment Scenarios</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Model dues increases, special assessments, and their impact on cash flow and reserves
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||
New Scenario
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{scenarios && scenarios.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{scenarios.map((s) => (
|
||||
<ScenarioCard
|
||||
key={s.id}
|
||||
scenario={s}
|
||||
onClick={() => navigate(`/board-planning/assessments/${s.id}`)}
|
||||
onEdit={() => { setEditScenario(s); setForm({ name: s.name, description: s.description || '' }); }}
|
||||
onDelete={() => deleteMutation.mutate(s.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Text c="dimmed">No assessment scenarios yet</Text>
|
||||
<Text size="sm" c="dimmed" maw={400} ta="center">
|
||||
Create a scenario to model dues increases, special assessments, and multi-year assessment planning.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title="New Assessment Scenario">
|
||||
<Stack>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. 5% Annual Increase" />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe this assessment strategy..." />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ name: form.name, description: form.description, scenarioType: 'assessment' })}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!form.name}
|
||||
>
|
||||
Create Scenario
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal opened={!!editScenario} onClose={() => setEditScenario(null)} title="Edit Scenario">
|
||||
<Stack>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setEditScenario(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate({ id: editScenario.id, name: form.name, description: form.description })}
|
||||
loading={updateMutation.isPending}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
776
frontend/src/pages/board-planning/BudgetPlanningPage.tsx
Normal file
776
frontend/src/pages/board-planning/BudgetPlanningPage.tsx
Normal file
@@ -0,0 +1,776 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||
Select, Loader, Center, Badge, Card, Alert, Modal,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
|
||||
IconCheck, IconArrowBack, IconTrash, IconRefresh,
|
||||
IconUpload, IconDownload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
|
||||
interface PlanLine {
|
||||
id: string;
|
||||
account_id: string;
|
||||
account_number: string;
|
||||
account_name: string;
|
||||
account_type: string;
|
||||
fund_type: string;
|
||||
is_manually_adjusted: boolean;
|
||||
jan: number; feb: number; mar: number; apr: number;
|
||||
may: number; jun: number; jul: number; aug: number;
|
||||
sep: number; oct: number; nov: number; dec_amt: number;
|
||||
annual_total: number;
|
||||
}
|
||||
|
||||
const monthKeys = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||
|
||||
function hydrateLine(row: any): PlanLine {
|
||||
const line: any = { ...row };
|
||||
for (const m of monthKeys) {
|
||||
line[m] = Number(line[m]) || 0;
|
||||
}
|
||||
line.annual_total = monthKeys.reduce((sum, m) => sum + (line[m] || 0), 0);
|
||||
return line as PlanLine;
|
||||
}
|
||||
|
||||
function parseCurrencyValue(val: string): number {
|
||||
if (!val) return 0;
|
||||
let s = val.trim();
|
||||
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
|
||||
const isNegative = s.includes('(') && s.includes(')');
|
||||
s = s.replace(/[$,\s()]/g, '');
|
||||
if (!s || s === '-') return 0;
|
||||
const num = parseFloat(s);
|
||||
if (isNaN(num)) return 0;
|
||||
return isNegative ? -num : num;
|
||||
}
|
||||
|
||||
function parseCSV(text: string): Record<string, string>[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||
const rows: Record<string, string>[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const ch = line[j];
|
||||
if (ch === '"') { inQuotes = !inQuotes; }
|
||||
else if (ch === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
|
||||
else { current += ch; }
|
||||
}
|
||||
values.push(current.trim());
|
||||
const row: Record<string, string> = {};
|
||||
headers.forEach((h, idx) => { row[h] = values[idx] || ''; });
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
planning: 'blue',
|
||||
approved: 'yellow',
|
||||
ratified: 'green',
|
||||
};
|
||||
|
||||
export function BudgetPlanningPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||
|
||||
const [selectedYear, setSelectedYear] = useState<string | null>(null);
|
||||
const [lineData, setLineData] = useState<PlanLine[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [inflationInput, setInflationInput] = useState<number>(2.5);
|
||||
const [confirmModal, setConfirmModal] = useState<{ action: string; title: string; message: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Available years
|
||||
const { data: availableYears } = useQuery<any>({
|
||||
queryKey: ['budget-plan-available-years'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/budget-plans/available-years');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Set default year when available
|
||||
useEffect(() => {
|
||||
if (availableYears?.years?.length && !selectedYear) {
|
||||
setSelectedYear(String(availableYears.years[0].year));
|
||||
}
|
||||
}, [availableYears, selectedYear]);
|
||||
|
||||
// Plan data for selected year
|
||||
const { data: plan, isLoading } = useQuery<any>({
|
||||
queryKey: ['budget-plan', selectedYear],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/board-planning/budget-plans/${selectedYear}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!selectedYear,
|
||||
});
|
||||
|
||||
// Hydrate lines when plan changes
|
||||
useEffect(() => {
|
||||
if (plan?.lines) {
|
||||
setLineData(plan.lines.map(hydrateLine));
|
||||
setInflationInput(parseFloat(plan.inflation_rate) || 2.5);
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
setLineData([]);
|
||||
}
|
||||
}, [plan]);
|
||||
|
||||
const hasBaseBudget = !!availableYears?.latestBudgetYear;
|
||||
|
||||
const yearOptions = (availableYears?.years || []).map((y: any) => ({
|
||||
value: String(y.year),
|
||||
label: `${y.year}${y.hasPlan ? ` (${y.status})` : ''}`,
|
||||
}));
|
||||
|
||||
// If no base budget at all, also offer the current year as an option
|
||||
const currentYear = new Date().getFullYear();
|
||||
const allYearOptions = !hasBaseBudget && !yearOptions.find((y: any) => y.value === String(currentYear))
|
||||
? [{ value: String(currentYear), label: String(currentYear) }, ...yearOptions]
|
||||
: yearOptions;
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const fiscalYear = parseInt(selectedYear!, 10);
|
||||
const baseYear = availableYears?.latestBudgetYear || new Date().getFullYear();
|
||||
const { data } = await api.post('/board-planning/budget-plans', {
|
||||
fiscalYear,
|
||||
baseYear,
|
||||
inflationRate: inflationInput,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
|
||||
notifications.show({ message: 'Budget plan created', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Create failed', color: 'red' });
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = lineData.map((l) => ({
|
||||
accountId: l.account_id,
|
||||
fundType: l.fund_type,
|
||||
jan: l.jan, feb: l.feb, mar: l.mar, apr: l.apr,
|
||||
may: l.may, jun: l.jun, jul: l.jul, aug: l.aug,
|
||||
sep: l.sep, oct: l.oct, nov: l.nov, dec: l.dec_amt,
|
||||
}));
|
||||
return api.put(`/board-planning/budget-plans/${selectedYear}/lines`, {
|
||||
planId: plan.id,
|
||||
lines: payload,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||
setIsEditing(false);
|
||||
notifications.show({ message: 'Budget plan saved', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Save failed', color: 'red' });
|
||||
},
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (lines: Record<string, string>[]) => {
|
||||
const parsed = lines.map((row) => ({
|
||||
account_number: row.account_number || row.accountnumber || '',
|
||||
account_name: row.account_name || row.accountname || '',
|
||||
jan: parseCurrencyValue(row.jan),
|
||||
feb: parseCurrencyValue(row.feb),
|
||||
mar: parseCurrencyValue(row.mar),
|
||||
apr: parseCurrencyValue(row.apr),
|
||||
may: parseCurrencyValue(row.may),
|
||||
jun: parseCurrencyValue(row.jun),
|
||||
jul: parseCurrencyValue(row.jul),
|
||||
aug: parseCurrencyValue(row.aug),
|
||||
sep: parseCurrencyValue(row.sep),
|
||||
oct: parseCurrencyValue(row.oct),
|
||||
nov: parseCurrencyValue(row.nov),
|
||||
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
|
||||
}));
|
||||
const { data } = await api.post(`/board-planning/budget-plans/${selectedYear}/import`, parsed);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
let msg = `Imported ${data.imported} budget line(s)`;
|
||||
if (data.created?.length) msg += `. Created ${data.created.length} new account(s)`;
|
||||
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
|
||||
notifications.show({
|
||||
message: msg,
|
||||
color: data.errors?.length ? 'yellow' : 'green',
|
||||
autoClose: 10000,
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
|
||||
},
|
||||
});
|
||||
|
||||
const inflationMutation = useMutation({
|
||||
mutationFn: () => api.put(`/board-planning/budget-plans/${selectedYear}/inflation`, {
|
||||
inflationRate: inflationInput,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||
notifications.show({ message: 'Inflation rate applied', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => api.put(`/board-planning/budget-plans/${selectedYear}/status`, { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||
setConfirmModal(null);
|
||||
notifications.show({ message: 'Status updated', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Status update failed', color: 'red' });
|
||||
setConfirmModal(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/board-planning/budget-plans/${selectedYear}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
|
||||
setConfirmModal(null);
|
||||
notifications.show({ message: 'Budget plan deleted', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
const updateCell = (idx: number, month: string, value: number) => {
|
||||
const updated = [...lineData];
|
||||
(updated[idx] as any)[month] = value || 0;
|
||||
updated[idx].annual_total = monthKeys.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
|
||||
setLineData(updated);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const yr = selectedYear || currentYear;
|
||||
const response = await api.get(`/board-planning/budget-plans/${yr}/template`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `budget_template_${yr}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
notifications.show({ message: 'Failed to download template', color: 'red' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCSV = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
|
||||
const rows = parseCSV(text);
|
||||
if (rows.length === 0) { notifications.show({ message: 'No data rows found in CSV', color: 'red' }); return; }
|
||||
importMutation.mutate(rows);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const hasPlan = !!plan?.id;
|
||||
const status = plan?.status || 'planning';
|
||||
const cellsEditable = !isReadOnly && isEditing && status !== 'ratified';
|
||||
|
||||
const incomeLines = lineData.filter((b) => b.account_type === 'income');
|
||||
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
|
||||
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
|
||||
const expenseLines = lineData.filter((b) => b.account_type === 'expense');
|
||||
const totalOperatingIncome = operatingIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
|
||||
const totalReserveIncome = reserveIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
|
||||
const totalExpense = expenseLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group align="center">
|
||||
<Title order={2}>Budget Planning</Title>
|
||||
{hasPlan && (
|
||||
<Badge size="lg" color={statusColors[status]}>
|
||||
{status}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
<Select
|
||||
data={allYearOptions}
|
||||
value={selectedYear}
|
||||
onChange={setSelectedYear}
|
||||
w={180}
|
||||
placeholder="Select year"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={handleDownloadTemplate}
|
||||
size="sm"
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Hidden file input for CSV import */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept=".csv,.txt"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{isLoading && <Center h={300}><Loader /></Center>}
|
||||
|
||||
{/* Empty state - no base budget exists at all */}
|
||||
{!isLoading && !hasPlan && selectedYear && !hasBaseBudget && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="orange" variant="light">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>No budget data found in the system</Text>
|
||||
<Text size="sm">
|
||||
To get started with budget planning, you need to load an initial budget.
|
||||
You can either create a new budget from scratch or import an existing budget from a CSV file.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Use <Text span fw={600}>Download Template</Text> above to get a CSV with your chart of accounts pre-populated,
|
||||
fill in the monthly amounts, then import it below.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={handleImportCSV}
|
||||
loading={importMutation.isPending}
|
||||
>
|
||||
Import Budget from CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => createMutation.mutate()}
|
||||
loading={createMutation.isPending}
|
||||
>
|
||||
Create Empty Budget Plan
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Empty state - base budget exists but no plan for this year */}
|
||||
{!isLoading && !hasPlan && selectedYear && hasBaseBudget && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
<Stack gap="sm">
|
||||
<Text>No budget plan exists for {selectedYear}. Create one based on the {availableYears?.latestBudgetYear} budget with an inflation adjustment, or import a CSV directly.</Text>
|
||||
<Group>
|
||||
<NumberInput
|
||||
label="Inflation Rate (%)"
|
||||
value={inflationInput}
|
||||
onChange={(v) => setInflationInput(Number(v) || 0)}
|
||||
min={0}
|
||||
max={50}
|
||||
step={0.5}
|
||||
decimalScale={2}
|
||||
w={160}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
mt={24}
|
||||
onClick={() => createMutation.mutate()}
|
||||
loading={createMutation.isPending}
|
||||
>
|
||||
Create Budget Plan for {selectedYear}
|
||||
</Button>
|
||||
<Text mt={24} c="dimmed">or</Text>
|
||||
<Button
|
||||
mt={24}
|
||||
variant="outline"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={handleImportCSV}
|
||||
loading={importMutation.isPending}
|
||||
>
|
||||
Import from CSV
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Base year: {availableYears?.latestBudgetYear}. Each monthly amount will be compounded annually by the specified inflation rate.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Plan controls */}
|
||||
{hasPlan && (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<NumberInput
|
||||
label="Inflation Rate (%)"
|
||||
value={inflationInput}
|
||||
onChange={(v) => setInflationInput(Number(v) || 0)}
|
||||
min={0}
|
||||
max={50}
|
||||
step={0.5}
|
||||
decimalScale={2}
|
||||
w={140}
|
||||
size="xs"
|
||||
disabled={status === 'ratified' || isReadOnly}
|
||||
/>
|
||||
<Button
|
||||
mt={24}
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => {
|
||||
setConfirmModal({
|
||||
action: 'inflation',
|
||||
title: 'Apply Inflation Rate',
|
||||
message: `This will recalculate all non-manually-adjusted lines using ${inflationInput}% inflation compounded annually from the base year (${plan.base_year}). Manually adjusted lines will be preserved.`,
|
||||
});
|
||||
}}
|
||||
disabled={status === 'ratified' || isReadOnly}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed" mt={24}>Base year: {plan.base_year}</Text>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
{/* Import CSV into existing plan */}
|
||||
{status !== 'ratified' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={handleImportCSV}
|
||||
loading={importMutation.isPending}
|
||||
>
|
||||
Import CSV
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Status actions */}
|
||||
{status === 'planning' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
leftSection={<IconCheck size={16} />}
|
||||
onClick={() => setConfirmModal({
|
||||
action: 'approved',
|
||||
title: 'Approve Budget Plan',
|
||||
message: `Mark the ${selectedYear} budget plan as approved? This indicates the board has reviewed and accepted the plan.`,
|
||||
})}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => setConfirmModal({
|
||||
action: 'delete',
|
||||
title: 'Delete Budget Plan',
|
||||
message: `Permanently delete the ${selectedYear} budget plan? This cannot be undone.`,
|
||||
})}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === 'approved' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconArrowBack size={16} />}
|
||||
onClick={() => setConfirmModal({
|
||||
action: 'planning',
|
||||
title: 'Revert to Planning',
|
||||
message: `Revert the ${selectedYear} budget plan back to planning status?`,
|
||||
})}
|
||||
>
|
||||
Revert to Planning
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="green"
|
||||
leftSection={<IconCheck size={16} />}
|
||||
onClick={() => setConfirmModal({
|
||||
action: 'ratified',
|
||||
title: 'Ratify Budget',
|
||||
message: `Ratify the ${selectedYear} budget? This will create the official budget for ${selectedYear} in Financials, overwriting any existing budget data for that year.`,
|
||||
})}
|
||||
>
|
||||
Ratify Budget
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === 'ratified' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconArrowBack size={16} />}
|
||||
onClick={() => setConfirmModal({
|
||||
action: 'approved',
|
||||
title: 'Revert from Ratified',
|
||||
message: `Revert the ${selectedYear} budget from ratified to approved? This will remove the official budget for ${selectedYear} from Financials.`,
|
||||
})}
|
||||
>
|
||||
Revert to Approved
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Edit/Save */}
|
||||
{status !== 'ratified' && (
|
||||
<>
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="gray"
|
||||
leftSection={<IconX size={16} />}
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
loading={saveMutation.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary cards */}
|
||||
<Group>
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Operating Income</Text>
|
||||
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
|
||||
</Card>
|
||||
{totalReserveIncome > 0 && (
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Reserve Income</Text>
|
||||
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
|
||||
</Card>
|
||||
)}
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Total Expenses</Text>
|
||||
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Net (Operating)</Text>
|
||||
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
||||
{fmt(totalOperatingIncome - totalExpense)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Group>
|
||||
|
||||
{/* Data table */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
||||
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
||||
{monthLabels.map((m) => (
|
||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||
))}
|
||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Annual</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{lineData.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={15}>
|
||||
<Text ta="center" c="dimmed" py="lg">No budget plan lines.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{['income', 'expense'].map((type) => {
|
||||
const lines = lineData.filter((b) => b.account_type === type);
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
|
||||
const sectionTotal = lines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
|
||||
|
||||
return [
|
||||
<Table.Tr key={`header-${type}`} style={{ background: sectionBg }}>
|
||||
<Table.Td
|
||||
colSpan={2}
|
||||
fw={700}
|
||||
tt="capitalize"
|
||||
style={{ position: 'sticky', left: 0, background: sectionBg, zIndex: 2 }}
|
||||
>
|
||||
{type}
|
||||
</Table.Td>
|
||||
{monthLabels.map((m) => <Table.Td key={m} />)}
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(sectionTotal)}</Table.Td>
|
||||
</Table.Tr>,
|
||||
...lines.map((line) => {
|
||||
const idx = lineData.indexOf(line);
|
||||
return (
|
||||
<Table.Tr key={line.id || `${line.account_id}-${line.fund_type}`}>
|
||||
<Table.Td
|
||||
style={{
|
||||
position: 'sticky', left: 0, background: stickyBg,
|
||||
zIndex: 1, borderRight: `1px solid ${stickyBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td
|
||||
style={{
|
||||
position: 'sticky', left: 120, background: stickyBg,
|
||||
zIndex: 1, borderRight: `1px solid ${stickyBorder}`,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>{line.account_name}</Text>
|
||||
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
|
||||
{line.is_manually_adjusted && <Badge size="xs" color="orange" variant="dot">edited</Badge>}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
{monthKeys.map((m) => (
|
||||
<Table.Td key={m} p={2}>
|
||||
{cellsEditable ? (
|
||||
<NumberInput
|
||||
value={(line as any)[m] || 0}
|
||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||
size="xs"
|
||||
hideControls
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" ta="right" ff="monospace">
|
||||
{fmt((line as any)[m] || 0)}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
))}
|
||||
<Table.Td ta="right" fw={500} ff="monospace">
|
||||
{fmt(line.annual_total || 0)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}),
|
||||
];
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirmation modal */}
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={confirmModal?.title || ''}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">{confirmModal?.message}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
color={confirmModal?.action === 'delete' ? 'red' : undefined}
|
||||
loading={statusMutation.isPending || deleteMutation.isPending || inflationMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!confirmModal) return;
|
||||
if (confirmModal.action === 'delete') {
|
||||
deleteMutation.mutate();
|
||||
} else if (confirmModal.action === 'inflation') {
|
||||
inflationMutation.mutate();
|
||||
setConfirmModal(null);
|
||||
} else {
|
||||
statusMutation.mutate(confirmModal.action);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import {
|
||||
IconPlus, IconArrowLeft, IconTrash, IconEdit,
|
||||
IconPlayerPlay, IconCoin, IconTrendingUp,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
import { InvestmentForm } from './components/InvestmentForm';
|
||||
import { ProjectionChart } from './components/ProjectionChart';
|
||||
import { InvestmentTimeline } from './components/InvestmentTimeline';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
const fmtDec = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
|
||||
};
|
||||
|
||||
export function InvestmentScenarioDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editInv, setEditInv] = useState<any>(null);
|
||||
const [executeInv, setExecuteInv] = useState<any>(null);
|
||||
const [executionDate, setExecutionDate] = useState<Date | null>(new Date());
|
||||
|
||||
const { data: scenario, isLoading } = useQuery({
|
||||
queryKey: ['board-planning-scenario', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/board-planning/scenarios/${id}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: projection, isLoading: projLoading } = useQuery({
|
||||
queryKey: ['board-planning-projection', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setAddOpen(false);
|
||||
notifications.show({ message: 'Investment added', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ invId, ...dto }: any) => api.put(`/board-planning/investments/${invId}`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setEditInv(null);
|
||||
notifications.show({ message: 'Investment updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (invId: string) => api.delete(`/board-planning/investments/${invId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
notifications.show({ message: 'Investment removed', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: ({ invId, executionDate }: { invId: string; executionDate: string }) =>
|
||||
api.post(`/board-planning/investments/${invId}/execute`, { executionDate }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setExecuteInv(null);
|
||||
notifications.show({ message: 'Investment executed and recorded', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Execution failed', color: 'red' });
|
||||
},
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => api.put(`/board-planning/scenarios/${id}`, { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||
|
||||
const investments = scenario.investments || [];
|
||||
const summary = projection?.summary;
|
||||
|
||||
// Build a lookup of per-investment interest from the projection
|
||||
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
||||
if (summary?.investment_interest_details) {
|
||||
for (const d of summary.investment_interest_details) {
|
||||
interestDetailMap[d.id] = { interest: d.interest, principal: d.principal };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/investments')}>
|
||||
<IconArrowLeft size={20} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Title order={2}>{scenario.name}</Title>
|
||||
<Badge color={statusColors[scenario.status]}>{scenario.status}</Badge>
|
||||
</Group>
|
||||
{scenario.description && <Text c="dimmed" size="sm">{scenario.description}</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
<Group>
|
||||
<Select
|
||||
size="xs"
|
||||
value={scenario.status}
|
||||
onChange={(v) => v && statusMutation.mutate(v)}
|
||||
data={[
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
]}
|
||||
/>
|
||||
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
||||
Add Investment
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Principal</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(summary.total_principal_invested || 0)}</Text>
|
||||
<Text size="xs" c="dimmed">{investments.filter((i: any) => !i.executed_investment_id).length} planned investments</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Projected Interest Earned</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c="green">
|
||||
{summary.total_interest_earned > 0 ? `+${fmtDec(summary.total_interest_earned)}` : '$0.00'}
|
||||
</Text>
|
||||
{summary.total_interest_earned > 0 && (
|
||||
<Text size="xs" c="dimmed">Over projection period</Text>
|
||||
)}
|
||||
{summary.total_interest_earned === 0 && investments.length > 0 && (
|
||||
<Text size="xs" c="orange">Set purchase & maturity dates to calculate</Text>
|
||||
)}
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Return on Investment</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c={summary.roi_percentage > 0 ? 'green' : undefined}>
|
||||
{summary.roi_percentage > 0 ? `${summary.roi_percentage.toFixed(2)}%` : '-'}
|
||||
</Text>
|
||||
{summary.roi_percentage > 0 && (
|
||||
<Text size="xs" c="dimmed">Interest / Principal</Text>
|
||||
)}
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(summary.end_liquidity || 0)}</Text>
|
||||
<Text size="xs" c={summary.period_change >= 0 ? 'green' : 'red'}>
|
||||
{summary.period_change >= 0 ? '+' : ''}{fmt(summary.period_change || 0)} over period
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Investments Table */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Planned Investments ({investments.length})</Title>
|
||||
{investments.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Label</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Principal</Table.Th>
|
||||
<Table.Th ta="right">Rate</Table.Th>
|
||||
<Table.Th ta="right">Est. Interest</Table.Th>
|
||||
<Table.Th>Purchase</Table.Th>
|
||||
<Table.Th>Maturity</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={100}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{investments.map((inv: any) => {
|
||||
const detail = interestDetailMap[inv.id];
|
||||
return (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td fw={500}>{inv.label}</Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light">{inv.investment_type || '-'}</Badge></Table.Td>
|
||||
<Table.Td><Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}>{inv.fund_type}</Badge></Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(parseFloat(inv.principal))}</Table.Td>
|
||||
<Table.Td ta="right">{inv.interest_rate ? `${parseFloat(inv.interest_rate).toFixed(2)}%` : '-'}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={detail?.interest ? 'green' : 'dimmed'}>
|
||||
{detail?.interest ? `+${fmtDec(detail.interest)}` : '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>{inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : <Text size="sm" c="orange">-</Text>}</Table.Td>
|
||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : <Text size="sm" c="orange">-</Text>}</Table.Td>
|
||||
<Table.Td>
|
||||
{inv.executed_investment_id
|
||||
? <Badge size="sm" color="green">Executed</Badge>
|
||||
: <Badge size="sm" color="gray">Planned</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditInv(inv)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{!inv.executed_investment_id && (
|
||||
<Tooltip label="Execute">
|
||||
<ActionIcon variant="subtle" color="green" size="sm" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
|
||||
<IconPlayerPlay size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(inv.id)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No investments added yet. Click "Add Investment" to model an investment allocation.
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Investment Timeline */}
|
||||
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
|
||||
|
||||
{/* Projection Chart */}
|
||||
{projection && (
|
||||
<ProjectionChart
|
||||
datapoints={projection.datapoints || []}
|
||||
title="Scenario Projection"
|
||||
summary={projection.summary}
|
||||
/>
|
||||
)}
|
||||
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||
|
||||
{/* Add/Edit Investment Modal */}
|
||||
<InvestmentForm
|
||||
opened={addOpen || !!editInv}
|
||||
onClose={() => { setAddOpen(false); setEditInv(null); }}
|
||||
onSubmit={(data) => {
|
||||
if (editInv) {
|
||||
updateMutation.mutate({ invId: editInv.id, ...data });
|
||||
} else {
|
||||
addMutation.mutate(data);
|
||||
}
|
||||
}}
|
||||
initialData={editInv}
|
||||
loading={addMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Execute Confirmation Modal */}
|
||||
<Modal opened={!!executeInv} onClose={() => setExecuteInv(null)} title="Execute Investment">
|
||||
<Stack>
|
||||
<Alert color="blue" variant="light">
|
||||
This will create a real investment account record and post a journal entry transferring funds.
|
||||
</Alert>
|
||||
{executeInv && (
|
||||
<>
|
||||
<Text size="sm"><strong>Investment:</strong> {executeInv.label}</Text>
|
||||
<Text size="sm"><strong>Amount:</strong> {fmt(parseFloat(executeInv.principal))}</Text>
|
||||
<DateInput
|
||||
label="Execution Date"
|
||||
required
|
||||
value={executionDate}
|
||||
onChange={setExecutionDate}
|
||||
description="The date the investment is actually purchased"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setExecuteInv(null)}>Cancel</Button>
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={() => {
|
||||
if (executeInv && executionDate) {
|
||||
executeMutation.mutate({
|
||||
invId: executeInv.id,
|
||||
executionDate: executionDate.toISOString().split('T')[0],
|
||||
});
|
||||
}
|
||||
}}
|
||||
loading={executeMutation.isPending}
|
||||
>
|
||||
Execute Investment
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
128
frontend/src/pages/board-planning/InvestmentScenariosPage.tsx
Normal file
128
frontend/src/pages/board-planning/InvestmentScenariosPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { Title, Text, Stack, Group, Button, SimpleGrid, Modal, TextInput, Textarea, Loader, Center } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
import { ScenarioCard } from './components/ScenarioCard';
|
||||
|
||||
export function InvestmentScenariosPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editScenario, setEditScenario] = useState<any>(null);
|
||||
const [form, setForm] = useState({ name: '', description: '' });
|
||||
|
||||
const { data: scenarios, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios', 'investment'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios?type=investment');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (dto: any) => api.post('/board-planning/scenarios', dto),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
setCreateOpen(false);
|
||||
setForm({ name: '', description: '' });
|
||||
notifications.show({ message: 'Scenario created', color: 'green' });
|
||||
navigate(`/board-planning/investments/${res.data.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...dto }: any) => api.put(`/board-planning/scenarios/${id}`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
setEditScenario(null);
|
||||
notifications.show({ message: 'Scenario updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/board-planning/scenarios/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
notifications.show({ message: 'Scenario archived', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={2}>Investment Scenarios</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Model different investment strategies and compare their impact on liquidity and income
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||
New Scenario
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{scenarios && scenarios.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{scenarios.map((s) => (
|
||||
<ScenarioCard
|
||||
key={s.id}
|
||||
scenario={s}
|
||||
onClick={() => navigate(`/board-planning/investments/${s.id}`)}
|
||||
onEdit={() => { setEditScenario(s); setForm({ name: s.name, description: s.description || '' }); }}
|
||||
onDelete={() => deleteMutation.mutate(s.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Text c="dimmed">No investment scenarios yet</Text>
|
||||
<Text size="sm" c="dimmed" maw={400} ta="center">
|
||||
Create a scenario to model investment allocations, timing, and their impact on reserves and liquidity.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title="New Investment Scenario">
|
||||
<Stack>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Conservative CD Ladder" />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe this investment strategy..." />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ name: form.name, description: form.description, scenarioType: 'investment' })}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!form.name}
|
||||
>
|
||||
Create Scenario
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal opened={!!editScenario} onClose={() => setEditScenario(null)} title="Edit Scenario">
|
||||
<Stack>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setEditScenario(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate({ id: editScenario.id, name: form.name, description: form.description })}
|
||||
loading={updateMutation.isPending}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
210
frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
Normal file
210
frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Card, MultiSelect, Loader, Center, Badge,
|
||||
SimpleGrid, Table,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import api from '../../services/api';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const COLORS = ['#228be6', '#40c057', '#7950f2', '#fd7e14'];
|
||||
|
||||
export function ScenarioComparisonPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
// Load all scenarios for the selector
|
||||
const { data: allScenarios } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios-all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Load comparison data when scenarios are selected
|
||||
const { data: comparison, isLoading: compLoading } = useQuery({
|
||||
queryKey: ['board-planning-compare', selectedIds],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/board-planning/compare?ids=${selectedIds.join(',')}`);
|
||||
return data;
|
||||
},
|
||||
enabled: selectedIds.length >= 1,
|
||||
});
|
||||
|
||||
const selectorData = (allScenarios || []).map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.scenario_type})`,
|
||||
}));
|
||||
|
||||
// Build merged chart data with all scenarios
|
||||
const chartData = (() => {
|
||||
if (!comparison?.scenarios?.length) return [];
|
||||
const firstScenario = comparison.scenarios[0];
|
||||
if (!firstScenario?.projection?.datapoints) return [];
|
||||
|
||||
return firstScenario.projection.datapoints.map((_: any, idx: number) => {
|
||||
const point: any = { month: firstScenario.projection.datapoints[idx].month };
|
||||
comparison.scenarios.forEach((s: any, sIdx: number) => {
|
||||
const dp = s.projection?.datapoints?.[idx];
|
||||
if (dp) {
|
||||
point[`total_${sIdx}`] =
|
||||
dp.operating_cash + dp.operating_investments + dp.reserve_cash + dp.reserve_investments;
|
||||
}
|
||||
});
|
||||
return point;
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
||||
<Title order={2}>Compare Scenarios</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Select up to 4 scenarios to compare their projected financial impact side-by-side
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<MultiSelect
|
||||
label="Select Scenarios"
|
||||
placeholder="Choose scenarios to compare..."
|
||||
data={selectorData}
|
||||
value={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
maxValues={4}
|
||||
searchable
|
||||
/>
|
||||
|
||||
{compLoading && <Center py="xl"><Loader size="lg" /></Center>}
|
||||
|
||||
{comparison?.scenarios?.length > 0 && (
|
||||
<>
|
||||
{/* Overlaid Line Chart */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Total Liquidity Projection</Title>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||
<Tooltip
|
||||
formatter={(v: number) => fmt(v)}
|
||||
labelStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Legend />
|
||||
{comparison.scenarios.map((s: any, idx: number) => (
|
||||
<Line
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={`total_${idx}`}
|
||||
name={s.name}
|
||||
stroke={COLORS[idx]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* Summary Metrics Comparison */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Summary Comparison</Title>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Metric</Table.Th>
|
||||
{comparison.scenarios.map((s: any, idx: number) => (
|
||||
<Table.Th key={s.id} ta="right">
|
||||
<Group gap={4} justify="flex-end">
|
||||
<div style={{ width: 10, height: 10, borderRadius: 2, background: COLORS[idx] }} />
|
||||
<Text size="sm" fw={600}>{s.name}</Text>
|
||||
</Group>
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>End Liquidity</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
|
||||
{fmt(s.projection?.summary?.end_liquidity || 0)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>Minimum Liquidity</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}
|
||||
c={(s.projection?.summary?.min_liquidity || 0) < 0 ? 'red' : undefined}
|
||||
>
|
||||
{fmt(s.projection?.summary?.min_liquidity || 0)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>Period Change</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => {
|
||||
const change = s.projection?.summary?.period_change || 0;
|
||||
return (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace" fw={600} c={change >= 0 ? 'green' : 'red'}>
|
||||
{change >= 0 ? '+' : ''}{fmt(change)}
|
||||
</Table.Td>
|
||||
);
|
||||
})}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>Reserve Coverage</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
|
||||
{(s.projection?.summary?.reserve_coverage_months || 0).toFixed(1)} months
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>End Operating Cash</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace">
|
||||
{fmt(s.projection?.summary?.end_operating_cash || 0)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>End Reserve Cash</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace">
|
||||
{fmt(s.projection?.summary?.end_reserve_cash || 0)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Risk Flags */}
|
||||
{comparison.scenarios.some((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0) && (
|
||||
<Card withBorder p="lg" bg="red.0">
|
||||
<Title order={4} c="red" mb="sm">Liquidity Warnings</Title>
|
||||
{comparison.scenarios.filter((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0).map((s: any) => (
|
||||
<Text key={s.id} size="sm" c="red">
|
||||
{s.name}: projected negative liquidity of {fmt(s.projection.summary.min_liquidity)}
|
||||
</Text>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedIds.length === 0 && (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed">Select one or more scenarios above to compare their financial projections</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Text } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: any;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function AssessmentChangeForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
changeType: 'dues_increase' as string,
|
||||
label: '',
|
||||
targetFund: 'operating',
|
||||
percentageChange: 0,
|
||||
flatAmountChange: 0,
|
||||
specialTotal: 0,
|
||||
specialPerUnit: 0,
|
||||
specialInstallments: 1,
|
||||
effectiveDate: null as Date | null,
|
||||
endDate: null as Date | null,
|
||||
notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setForm({
|
||||
changeType: initialData.change_type || initialData.changeType || 'dues_increase',
|
||||
label: initialData.label || '',
|
||||
targetFund: initialData.target_fund || initialData.targetFund || 'operating',
|
||||
percentageChange: parseFloat(initialData.percentage_change || initialData.percentageChange) || 0,
|
||||
flatAmountChange: parseFloat(initialData.flat_amount_change || initialData.flatAmountChange) || 0,
|
||||
specialTotal: parseFloat(initialData.special_total || initialData.specialTotal) || 0,
|
||||
specialPerUnit: parseFloat(initialData.special_per_unit || initialData.specialPerUnit) || 0,
|
||||
specialInstallments: initialData.special_installments || initialData.specialInstallments || 1,
|
||||
effectiveDate: initialData.effective_date ? new Date(initialData.effective_date) : null,
|
||||
endDate: initialData.end_date ? new Date(initialData.end_date) : null,
|
||||
notes: initialData.notes || '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
changeType: 'dues_increase', label: '', targetFund: 'operating',
|
||||
percentageChange: 0, flatAmountChange: 0, specialTotal: 0, specialPerUnit: 0,
|
||||
specialInstallments: 1, effectiveDate: null, endDate: null, notes: '',
|
||||
});
|
||||
}
|
||||
}, [initialData, opened]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
...form,
|
||||
effectiveDate: form.effectiveDate?.toISOString().split('T')[0] || null,
|
||||
endDate: form.endDate?.toISOString().split('T')[0] || null,
|
||||
});
|
||||
};
|
||||
|
||||
const isSpecial = form.changeType === 'special_assessment';
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Assessment Change' : 'Add Assessment Change'} size="lg">
|
||||
<Stack>
|
||||
<Select
|
||||
label="Change Type"
|
||||
value={form.changeType}
|
||||
onChange={(v) => setForm({ ...form, changeType: v || 'dues_increase' })}
|
||||
data={[
|
||||
{ value: 'dues_increase', label: 'Dues Increase' },
|
||||
{ value: 'dues_decrease', label: 'Dues Decrease' },
|
||||
{ value: 'special_assessment', label: 'Special Assessment' },
|
||||
]}
|
||||
/>
|
||||
<TextInput
|
||||
label="Label"
|
||||
required
|
||||
value={form.label}
|
||||
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||
placeholder={isSpecial ? 'e.g. Roof Replacement Assessment' : 'e.g. 5% Annual Increase'}
|
||||
/>
|
||||
<Select
|
||||
label="Target Fund"
|
||||
value={form.targetFund}
|
||||
onChange={(v) => setForm({ ...form, targetFund: v || 'operating' })}
|
||||
data={[
|
||||
{ value: 'operating', label: 'Operating' },
|
||||
{ value: 'reserve', label: 'Reserve' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{!isSpecial && (
|
||||
<>
|
||||
<Text size="sm" fw={500} c="dimmed">Set either a percentage or flat amount (not both):</Text>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Percentage Change (%)"
|
||||
value={form.percentageChange}
|
||||
onChange={(v) => setForm({ ...form, percentageChange: Number(v) || 0, flatAmountChange: 0 })}
|
||||
min={0}
|
||||
max={100}
|
||||
decimalScale={2}
|
||||
suffix="%"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Flat Amount Change ($/unit/mo)"
|
||||
value={form.flatAmountChange}
|
||||
onChange={(v) => setForm({ ...form, flatAmountChange: Number(v) || 0, percentageChange: 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
prefix="$"
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSpecial && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Per Unit Amount"
|
||||
description="Total amount each unit will be assessed"
|
||||
value={form.specialPerUnit}
|
||||
onChange={(v) => setForm({ ...form, specialPerUnit: Number(v) || 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
prefix="$"
|
||||
/>
|
||||
<Select
|
||||
label="Duration"
|
||||
description="How the assessment is collected"
|
||||
value={String(form.specialInstallments)}
|
||||
onChange={(v) => setForm({ ...form, specialInstallments: Number(v) || 1 })}
|
||||
data={[
|
||||
{ value: '1', label: 'One-time (lump sum)' },
|
||||
{ value: '3', label: 'Quarterly (3 monthly payments)' },
|
||||
{ value: '6', label: '6 months' },
|
||||
{ value: '12', label: 'Annual (12 monthly payments)' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group grow>
|
||||
<DateInput label="Effective Date" required value={form.effectiveDate} onChange={(v) => setForm({ ...form, effectiveDate: v })} />
|
||||
<DateInput label="End Date (optional)" value={form.endDate} onChange={(v) => setForm({ ...form, endDate: v })} clearable />
|
||||
</Group>
|
||||
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.effectiveDate}>
|
||||
{initialData ? 'Update' : 'Add Change'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/board-planning/components/InvestmentForm.tsx
Normal file
110
frontend/src/pages/board-planning/components/InvestmentForm.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Switch } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: any;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function InvestmentForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
label: '',
|
||||
investmentType: 'cd',
|
||||
fundType: 'reserve',
|
||||
principal: 0,
|
||||
interestRate: 0,
|
||||
termMonths: 12,
|
||||
institution: '',
|
||||
purchaseDate: null as Date | null,
|
||||
maturityDate: null as Date | null,
|
||||
autoRenew: false,
|
||||
notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setForm({
|
||||
label: initialData.label || '',
|
||||
investmentType: initialData.investment_type || initialData.investmentType || 'cd',
|
||||
fundType: initialData.fund_type || initialData.fundType || 'reserve',
|
||||
principal: parseFloat(initialData.principal) || 0,
|
||||
interestRate: parseFloat(initialData.interest_rate || initialData.interestRate) || 0,
|
||||
termMonths: initialData.term_months || initialData.termMonths || 12,
|
||||
institution: initialData.institution || '',
|
||||
purchaseDate: initialData.purchase_date ? new Date(initialData.purchase_date) : null,
|
||||
maturityDate: initialData.maturity_date ? new Date(initialData.maturity_date) : null,
|
||||
autoRenew: initialData.auto_renew || initialData.autoRenew || false,
|
||||
notes: initialData.notes || '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
label: '', investmentType: 'cd', fundType: 'reserve', principal: 0,
|
||||
interestRate: 0, termMonths: 12, institution: '', purchaseDate: null,
|
||||
maturityDate: null, autoRenew: false, notes: '',
|
||||
});
|
||||
}
|
||||
}, [initialData, opened]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
...form,
|
||||
purchaseDate: form.purchaseDate?.toISOString().split('T')[0] || null,
|
||||
maturityDate: form.maturityDate?.toISOString().split('T')[0] || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Investment' : 'Add Investment'} size="lg">
|
||||
<Stack>
|
||||
<TextInput label="Label" required value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="e.g. 6-Month Treasury" />
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Type"
|
||||
value={form.investmentType}
|
||||
onChange={(v) => setForm({ ...form, investmentType: v || 'cd' })}
|
||||
data={[
|
||||
{ value: 'cd', label: 'CD' },
|
||||
{ value: 'money_market', label: 'Money Market' },
|
||||
{ value: 'treasury', label: 'Treasury' },
|
||||
{ value: 'savings', label: 'Savings' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Fund"
|
||||
value={form.fundType}
|
||||
onChange={(v) => setForm({ ...form, fundType: v || 'reserve' })}
|
||||
data={[
|
||||
{ value: 'operating', label: 'Operating' },
|
||||
{ value: 'reserve', label: 'Reserve' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Principal ($)" required value={form.principal} onChange={(v) => setForm({ ...form, principal: Number(v) || 0 })} min={0} decimalScale={2} thousandSeparator="," prefix="$" />
|
||||
<NumberInput label="Interest Rate (%)" value={form.interestRate} onChange={(v) => setForm({ ...form, interestRate: Number(v) || 0 })} min={0} max={20} decimalScale={3} suffix="%" />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Term (months)" value={form.termMonths} onChange={(v) => setForm({ ...form, termMonths: Number(v) || 0 })} min={1} max={120} />
|
||||
<TextInput label="Institution" value={form.institution} onChange={(e) => setForm({ ...form, institution: e.target.value })} placeholder="e.g. First National Bank" />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<DateInput label="Purchase Date" value={form.purchaseDate} onChange={(v) => setForm({ ...form, purchaseDate: v })} clearable />
|
||||
<DateInput label="Maturity Date" value={form.maturityDate} onChange={(v) => setForm({ ...form, maturityDate: v })} clearable />
|
||||
</Group>
|
||||
<Switch label="Auto-renew at maturity" checked={form.autoRenew} onChange={(e) => setForm({ ...form, autoRenew: e.currentTarget.checked })} />
|
||||
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.principal}>
|
||||
{initialData ? 'Update' : 'Add Investment'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Card, Title, Text, Group, Badge, Tooltip } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
cd: '#228be6',
|
||||
money_market: '#40c057',
|
||||
treasury: '#7950f2',
|
||||
savings: '#fd7e14',
|
||||
other: '#868e96',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
investments: any[];
|
||||
}
|
||||
|
||||
export function InvestmentTimeline({ investments }: Props) {
|
||||
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const items = investments
|
||||
.filter((inv: any) => inv.purchase_date || inv.maturity_date)
|
||||
.map((inv: any) => ({
|
||||
...inv,
|
||||
start: inv.purchase_date ? new Date(inv.purchase_date) : now,
|
||||
end: inv.maturity_date ? new Date(inv.maturity_date) : null,
|
||||
}));
|
||||
|
||||
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
||||
|
||||
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
||||
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||
const totalMonths = Math.max(
|
||||
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
return { items, startDate, endDate, totalMonths };
|
||||
}, [investments]);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
const getPercent = (date: Date) => {
|
||||
const months = (date.getFullYear() - startDate.getFullYear()) * 12 + (date.getMonth() - startDate.getMonth());
|
||||
return Math.max(0, Math.min(100, (months / totalMonths) * 100));
|
||||
};
|
||||
|
||||
// Generate year labels
|
||||
const yearLabels: { year: number; percent: number }[] = [];
|
||||
for (let y = startDate.getFullYear(); y <= endDate.getFullYear(); y++) {
|
||||
const janDate = new Date(y, 0, 1);
|
||||
if (janDate >= startDate && janDate <= endDate) {
|
||||
yearLabels.push({ year: y, percent: getPercent(janDate) });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Investment Timeline</Title>
|
||||
|
||||
{/* Year markers */}
|
||||
<div style={{ position: 'relative', height: 20, marginBottom: 8 }}>
|
||||
{yearLabels.map((yl) => (
|
||||
<Text
|
||||
key={yl.year}
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={700}
|
||||
style={{ position: 'absolute', left: `${yl.percent}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
{yl.year}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline bars */}
|
||||
<div style={{ position: 'relative', minHeight: items.length * 40 + 10 }}>
|
||||
{/* Background grid */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderLeft: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRight: '1px solid var(--mantine-color-gray-3)',
|
||||
}}>
|
||||
{yearLabels.map((yl) => (
|
||||
<div
|
||||
key={yl.year}
|
||||
style={{
|
||||
position: 'absolute', left: `${yl.percent}%`, top: 0, bottom: 0,
|
||||
borderLeft: '1px dashed var(--mantine-color-gray-3)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.map((inv: any, idx: number) => {
|
||||
const leftPct = getPercent(inv.start);
|
||||
const rightPct = inv.end ? getPercent(inv.end) : leftPct + 2;
|
||||
const widthPct = Math.max(rightPct - leftPct, 1);
|
||||
const color = typeColors[inv.investment_type] || '#868e96';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={inv.id}
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={600}>{inv.label}</Text>
|
||||
<Text size="xs">{fmt(parseFloat(inv.principal))} @ {parseFloat(inv.interest_rate || 0).toFixed(2)}%</Text>
|
||||
{inv.purchase_date && <Text size="xs">Start: {new Date(inv.purchase_date).toLocaleDateString()}</Text>}
|
||||
{inv.maturity_date && <Text size="xs">Maturity: {new Date(inv.maturity_date).toLocaleDateString()}</Text>}
|
||||
</div>
|
||||
}
|
||||
position="top"
|
||||
multiline
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${leftPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
top: idx * 40 + 4,
|
||||
height: 28,
|
||||
borderRadius: 4,
|
||||
background: color,
|
||||
opacity: inv.executed_investment_id ? 0.5 : 0.85,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
cursor: 'pointer',
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="white" fw={600} truncate style={{ lineHeight: 1 }}>
|
||||
{inv.label} — {fmt(parseFloat(inv.principal))}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<Group gap="md" mt="md">
|
||||
{Object.entries(typeColors).map(([type, color]) => (
|
||||
<Group key={type} gap={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: color }} />
|
||||
<Text size="xs" c="dimmed">{type.replace('_', ' ')}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
124
frontend/src/pages/board-planning/components/ProjectionChart.tsx
Normal file
124
frontend/src/pages/board-planning/components/ProjectionChart.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card, Title, Text, Group, Badge, SegmentedControl, Stack } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from 'recharts';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
interface Datapoint {
|
||||
month: string;
|
||||
year: number;
|
||||
monthNum: number;
|
||||
is_forecast: boolean;
|
||||
operating_cash: number;
|
||||
operating_investments: number;
|
||||
reserve_cash: number;
|
||||
reserve_investments: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
datapoints: Datapoint[];
|
||||
title?: string;
|
||||
summary?: any;
|
||||
}
|
||||
|
||||
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
|
||||
const [fundFilter, setFundFilter] = useState('all');
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return datapoints.map((d) => ({
|
||||
...d,
|
||||
label: `${d.month}`,
|
||||
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||
}));
|
||||
}, [datapoints]);
|
||||
|
||||
// Find first forecast month for reference line
|
||||
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<Card shadow="sm" p="xs" withBorder style={{ background: 'var(--mantine-color-body)' }}>
|
||||
<Text fw={600} size="sm" mb={4}>{label}</Text>
|
||||
{payload.map((p: any) => (
|
||||
<Group key={p.name} justify="space-between" gap="xl">
|
||||
<Text size="xs" c={p.color}>{p.name}</Text>
|
||||
<Text size="xs" fw={600} ff="monospace">{fmt(p.value)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const showOp = fundFilter === 'all' || fundFilter === 'operating';
|
||||
const showRes = fundFilter === 'all' || fundFilter === 'reserve';
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>{title}</Title>
|
||||
{summary && (
|
||||
<Group gap="md" mt={4}>
|
||||
<Badge variant="light" color="teal">End Liquidity: {fmt(summary.end_liquidity || 0)}</Badge>
|
||||
<Badge variant="light" color="orange">Min Liquidity: {fmt(summary.min_liquidity || 0)}</Badge>
|
||||
{summary.reserve_coverage_months != null && (
|
||||
<Badge variant="light" color="violet">
|
||||
Reserve Coverage: {summary.reserve_coverage_months.toFixed(1)} mo
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fundFilter}
|
||||
onChange={setFundFilter}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Operating', value: 'operating' },
|
||||
{ label: 'Reserve', value: 'reserve' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
{forecastStart > 0 && (
|
||||
<ReferenceLine x={chartData[forecastStart]?.month} stroke="#aaa" strokeDasharray="5 5" label="Forecast" />
|
||||
)}
|
||||
{showOp && <Area type="monotone" dataKey="operating_cash" name="Operating Cash" stroke="#228be6" fill="url(#opCash)" stackId="1" />}
|
||||
{showOp && <Area type="monotone" dataKey="operating_investments" name="Operating Investments" stroke="#74c0fc" fill="url(#opInv)" stackId="1" />}
|
||||
{showRes && <Area type="monotone" dataKey="reserve_cash" name="Reserve Cash" stroke="#7950f2" fill="url(#resCash)" stackId="1" />}
|
||||
{showRes && <Area type="monotone" dataKey="reserve_investments" name="Reserve Investments" stroke="#b197fc" fill="url(#resInv)" stackId="1" />}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Card, Group, Text, Badge, ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDots, IconTrash, IconEdit, IconPlayerPlay } from '@tabler/icons-react';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
active: 'blue',
|
||||
approved: 'green',
|
||||
archived: 'red',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scenario: any;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ScenarioCard({ scenario, onClick, onEdit, onDelete }: Props) {
|
||||
return (
|
||||
<Card withBorder p="lg" style={{ cursor: 'pointer' }} onClick={onClick}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{scenario.name}</Text>
|
||||
<Badge size="xs" color={statusColors[scenario.status] || 'gray'}>
|
||||
{scenario.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={(e: any) => e.stopPropagation()}>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={(e: any) => { e.stopPropagation(); onEdit(); }}>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={(e: any) => { e.stopPropagation(); onDelete(); }}>
|
||||
Archive
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
{scenario.description && (
|
||||
<Text size="sm" c="dimmed" mb="sm" lineClamp={2}>{scenario.description}</Text>
|
||||
)}
|
||||
<Group gap="lg">
|
||||
{scenario.scenario_type === 'investment' && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Investments</Text>
|
||||
<Text fw={600}>{scenario.investment_count || 0}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Total Principal</Text>
|
||||
<Text fw={600} ff="monospace">{fmt(parseFloat(scenario.total_principal) || 0)}</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{scenario.scenario_type === 'assessment' && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Changes</Text>
|
||||
<Text fw={600}>{scenario.assessment_count || 0}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
Updated {new Date(scenario.updated_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||
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, IconInfoCircle, IconPencil, IconX, IconArrowRight } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
|
||||
interface BudgetLine {
|
||||
account_id: string;
|
||||
@@ -23,27 +26,6 @@ interface BudgetLine {
|
||||
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
/**
|
||||
* Parse a currency-formatted value: "$48,065.21", "$(13,000.00)", " $- "
|
||||
*/
|
||||
function parseCurrencyValue(val: string): number {
|
||||
if (!val) return 0;
|
||||
let s = val.trim();
|
||||
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
|
||||
|
||||
const isNegative = s.includes('(') && s.includes(')');
|
||||
s = s.replace(/[$,\s()]/g, '');
|
||||
if (!s || s === '-') return 0;
|
||||
|
||||
const num = parseFloat(s);
|
||||
if (isNaN(num)) return 0;
|
||||
return isNegative ? -num : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all monthly values are numbers (PostgreSQL can return strings for NUMERIC columns)
|
||||
* and compute annual_total as the sum of all monthly values.
|
||||
*/
|
||||
function hydrateBudgetLine(row: any): BudgetLine {
|
||||
const line: any = { ...row };
|
||||
for (const m of months) {
|
||||
@@ -53,76 +35,64 @@ function hydrateBudgetLine(row: any): BudgetLine {
|
||||
return line as BudgetLine;
|
||||
}
|
||||
|
||||
function parseCSV(text: string): Record<string, string>[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||
const rows: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Handle quoted fields containing commas
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const ch = line[j];
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
values.push(current.trim());
|
||||
|
||||
const row: Record<string, string> = {};
|
||||
headers.forEach((h, idx) => {
|
||||
row[h] = values[idx] || '';
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function BudgetsPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||
|
||||
const { isLoading } = useQuery<BudgetLine[]>({
|
||||
// Query is the single source of truth for budget data
|
||||
const { data: queryData, isLoading, isFetching } = useQuery<BudgetLine[]>({
|
||||
queryKey: ['budgets', year],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/budgets/${year}`);
|
||||
// Hydrate each line: ensure numbers and compute annual_total
|
||||
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
||||
setBudgetData(hydrated);
|
||||
return hydrated;
|
||||
return (data as any[]).map(hydrateBudgetLine);
|
||||
},
|
||||
});
|
||||
|
||||
// Use edit data when editing, otherwise use query data
|
||||
const isEditing = editData !== null;
|
||||
const budgetData = isEditing ? editData : (queryData || []);
|
||||
const hasBudget = budgetData.length > 0;
|
||||
const cellsEditable = !isReadOnly && isEditing;
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditData(queryData ? [...queryData] : []);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditData(null);
|
||||
};
|
||||
|
||||
const handleYearChange = (v: string | null) => {
|
||||
if (v) {
|
||||
setYear(v);
|
||||
setEditData(null); // Cancel any in-progress edit when switching years
|
||||
}
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
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] });
|
||||
setEditData(null);
|
||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -130,114 +100,30 @@ export function BudgetsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (lines: Record<string, string>[]) => {
|
||||
const parsed = lines.map((row) => ({
|
||||
account_number: row.account_number || row.accountnumber || '',
|
||||
account_name: row.account_name || row.accountname || '',
|
||||
jan: parseCurrencyValue(row.jan),
|
||||
feb: parseCurrencyValue(row.feb),
|
||||
mar: parseCurrencyValue(row.mar),
|
||||
apr: parseCurrencyValue(row.apr),
|
||||
may: parseCurrencyValue(row.may),
|
||||
jun: parseCurrencyValue(row.jun),
|
||||
jul: parseCurrencyValue(row.jul),
|
||||
aug: parseCurrencyValue(row.aug),
|
||||
sep: parseCurrencyValue(row.sep),
|
||||
oct: parseCurrencyValue(row.oct),
|
||||
nov: parseCurrencyValue(row.nov),
|
||||
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
|
||||
}));
|
||||
const { data } = await api.post(`/budgets/${year}/import`, parsed);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
let msg = `Imported ${data.imported} budget line(s)`;
|
||||
if (data.created?.length) {
|
||||
msg += `. Created ${data.created.length} new account(s)`;
|
||||
}
|
||||
if (data.errors?.length) {
|
||||
msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
|
||||
}
|
||||
notifications.show({
|
||||
message: msg,
|
||||
color: data.errors?.length ? 'yellow' : 'green',
|
||||
autoClose: 10000,
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const response = await api.get(`/budgets/${year}/template`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `budget_template_${year}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
notifications.show({ message: 'Failed to download template', color: 'red' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCSV = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!text) {
|
||||
notifications.show({ message: 'Could not read file', color: 'red' });
|
||||
return;
|
||||
}
|
||||
const rows = parseCSV(text);
|
||||
if (rows.length === 0) {
|
||||
notifications.show({ message: 'No data rows found in CSV', color: 'red' });
|
||||
return;
|
||||
}
|
||||
importMutation.mutate(rows);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// Reset input so the same file can be re-selected
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const updateCell = (idx: number, month: string, value: number) => {
|
||||
const updated = [...budgetData];
|
||||
if (!editData) return;
|
||||
const updated = [...editData];
|
||||
(updated[idx] as any)[month] = value || 0;
|
||||
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
|
||||
setBudgetData(updated);
|
||||
setEditData(updated);
|
||||
};
|
||||
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||
const yearOptions = useMemo(() => Array.from({ length: 5 }, (_, i) => {
|
||||
const y = new Date().getFullYear() - 1 + i;
|
||||
return { value: String(y), label: String(y) };
|
||||
});
|
||||
}), []);
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||
|
||||
// Show loader on initial load or when switching years with no cached data
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
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 totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
|
||||
return (
|
||||
@@ -245,56 +131,81 @@ export function BudgetsPage() {
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Budget Manager</Title>
|
||||
<Group>
|
||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={handleImportCSV}
|
||||
loading={importMutation.isPending}
|
||||
>
|
||||
Import CSV
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept=".csv,.txt"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||
Save Budget
|
||||
</Button>
|
||||
<Select data={yearOptions} value={year} onChange={handleYearChange} w={120} />
|
||||
{isFetching && !isLoading && <Loader size="xs" />}
|
||||
{!isReadOnly && hasBudget && (
|
||||
<>
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
Edit Budget
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{budgetData.length === 0 && !isLoading && (
|
||||
{!hasBudget && !isLoading && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
No budget data for {year}. Import a CSV to get started. Your CSV should have columns:{' '}
|
||||
<Text span ff="monospace" size="xs">account_number, account_name, jan, feb, ..., dec</Text>.
|
||||
Accounts will be auto-created if they don't exist yet.
|
||||
<Stack gap="sm">
|
||||
<Text>No budget data for {year}.</Text>
|
||||
<Text size="sm">
|
||||
To create or import a budget, use the <Text span fw={600}>Budget Planner</Text> to build,
|
||||
review, and ratify a budget for this year. Once ratified, it will appear here.
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconArrowRight size={16} />}
|
||||
w="fit-content"
|
||||
onClick={() => navigate('/board-planning/budgets')}
|
||||
>
|
||||
Go to Budget Planner
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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 +214,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>
|
||||
))}
|
||||
@@ -315,7 +226,7 @@ export function BudgetsPage() {
|
||||
{budgetData.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={15}>
|
||||
<Text ta="center" c="dimmed" py="lg">No budget data. Import a CSV or add income/expense accounts to get started.</Text>
|
||||
<Text ta="center" c="dimmed" py="lg">No budget data for this year.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -323,7 +234,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 +265,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 +276,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,15 +288,21 @@ export function BudgetsPage() {
|
||||
</Table.Td>
|
||||
{months.map((m) => (
|
||||
<Table.Td key={m} p={2}>
|
||||
<NumberInput
|
||||
value={(line as any)[m] || 0}
|
||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||
size="xs"
|
||||
hideControls
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
{cellsEditable ? (
|
||||
<NumberInput
|
||||
value={(line as any)[m] || 0}
|
||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||
size="xs"
|
||||
hideControls
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" ta="right" ff="monospace">
|
||||
{fmt((line as any)[m] || 0)}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
))}
|
||||
<Table.Td ta="right" fw={500} ff="monospace">
|
||||
|
||||
@@ -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,7 +184,12 @@ function KanbanColumn({
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={5}>{yearLabel(year)}</Title>
|
||||
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
|
||||
<Group gap={6}>
|
||||
{isUnscheduled && projects.length > 0 && (
|
||||
<Badge size="xs" variant="light" color="orange">needs scheduling</Badge>
|
||||
)}
|
||||
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
@@ -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,16 +519,18 @@ export function CapitalProjectsPage() {
|
||||
<Table.Td fw={500}>{p.name}</Table.Td>
|
||||
<Table.Td>{p.category || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
{p.target_year === FUTURE_YEAR
|
||||
? 'Future'
|
||||
: (
|
||||
<>
|
||||
{p.target_month
|
||||
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
|
||||
: ''}{' '}
|
||||
{p.target_year}
|
||||
</>
|
||||
)
|
||||
{p.target_year === null
|
||||
? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
|
||||
: p.target_year === FUTURE_YEAR
|
||||
? 'Future'
|
||||
: (
|
||||
<>
|
||||
{p.target_month
|
||||
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
|
||||
: ''}{' '}
|
||||
{p.target_year}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,25 @@ 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;
|
||||
interest_last_year: string;
|
||||
interest_projected: 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 +325,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 +402,8 @@ export function DashboardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
||||
<Title order={2}>Dashboard</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
|
||||
</Text>
|
||||
</div>
|
||||
<Stack data-tour="dashboard-content">
|
||||
<Title order={2}>Dashboard</Title>
|
||||
|
||||
{!currentOrg ? (
|
||||
<Card withBorder p="xl" ta="center">
|
||||
@@ -66,24 +416,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">
|
||||
<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>
|
||||
</div>
|
||||
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
|
||||
<stat.icon 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}>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="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 +526,54 @@ 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">Interest Earned YoY</Text>
|
||||
<Group gap={6}>
|
||||
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_projected || '0')}</Text>
|
||||
<Text size="xs" c="dimmed">proj</Text>
|
||||
<Text size="xs" c="dimmed">vs</Text>
|
||||
<Text size="sm" fw={500} c="gray">{fmt(data?.interest_last_year || '0')}</Text>
|
||||
<Text size="xs" c="dimmed">prev</Text>
|
||||
{(() => {
|
||||
const proj = parseFloat(data?.interest_projected || '0');
|
||||
const prev = parseFloat(data?.interest_last_year || '0');
|
||||
const diff = proj - prev;
|
||||
if (prev === 0 && proj === 0) return null;
|
||||
return (
|
||||
<Badge size="xs" color={diff >= 0 ? 'green' : 'red'} variant="light">
|
||||
{diff >= 0 ? '+' : ''}{prev > 0 ? ((diff / prev) * 100).toFixed(0) : '—'}%
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider my={4} />
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Capital Projects</Text>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Planned Capital Spend {new Date().getFullYear()}</Text>
|
||||
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
||||
</Group>
|
||||
<Divider my={4} />
|
||||
<Group justify="space-between">
|
||||
<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'}>
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Stack,
|
||||
Card,
|
||||
SimpleGrid,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
Divider,
|
||||
Accordion,
|
||||
Paper,
|
||||
Tabs,
|
||||
Collapse,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Select,
|
||||
TextInput,
|
||||
Progress,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBulb,
|
||||
IconCash,
|
||||
IconBuildingBank,
|
||||
IconChartAreaLine,
|
||||
IconAlertTriangle,
|
||||
IconSparkles,
|
||||
IconRefresh,
|
||||
IconCoin,
|
||||
IconPigMoney,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconPlaylistAdd,
|
||||
} from '@tabler/icons-react';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 RecommendationComponent {
|
||||
label: string;
|
||||
amount: number;
|
||||
term_months: number;
|
||||
rate: number;
|
||||
bank_name?: string;
|
||||
investment_type?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
components?: RecommendationComponent[];
|
||||
}
|
||||
|
||||
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,
|
||||
onAddToPlan,
|
||||
}: {
|
||||
aiResult: AIResponse;
|
||||
lastUpdated?: string;
|
||||
lastFailed?: boolean;
|
||||
onAddToPlan?: (rec: Recommendation) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack>
|
||||
{/* Last Updated timestamp + failure message */}
|
||||
{lastUpdated && (
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||
</Text>
|
||||
{lastFailed && (
|
||||
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
||||
last analysis failed — showing cached data
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Overall Assessment */}
|
||||
<Alert color="blue" variant="light" title="Overall Assessment">
|
||||
<Text size="sm">{aiResult.overall_assessment}</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Risk Notes */}
|
||||
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
title="Risk Notes"
|
||||
icon={<IconAlertTriangle />}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{aiResult.risk_notes.map((note, i) => (
|
||||
<Text key={i} size="sm">
|
||||
{note}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Recommendation Cards */}
|
||||
{aiResult.recommendations.length > 0 ? (
|
||||
<Accordion variant="separated">
|
||||
{aiResult.recommendations.map((rec, i) => {
|
||||
const Icon = typeIcons[rec.type] || IconBulb;
|
||||
return (
|
||||
<Accordion.Item key={i} value={`rec-${i}`}>
|
||||
<Accordion.Control>
|
||||
<Group>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={priorityColors[rec.priority] || 'gray'}
|
||||
size="md"
|
||||
>
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{rec.title}</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={priorityColors[rec.priority]}
|
||||
>
|
||||
{rec.priority}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light">
|
||||
{typeLabels[rec.type] || rec.type}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={
|
||||
rec.fund_type === 'reserve'
|
||||
? 'violet'
|
||||
: rec.fund_type === 'operating'
|
||||
? 'blue'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
{rec.fund_type}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{rec.summary}
|
||||
</Text>
|
||||
</div>
|
||||
{rec.suggested_amount != null && (
|
||||
<Text fw={700} ff="monospace" c="green" size="lg">
|
||||
{fmt(rec.suggested_amount)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{rec.details}</Text>
|
||||
|
||||
{(rec.suggested_term ||
|
||||
rec.suggested_rate != null ||
|
||||
rec.bank_name) && (
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
{rec.suggested_term && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Suggested Term
|
||||
</Text>
|
||||
<Text fw={600}>{rec.suggested_term}</Text>
|
||||
</div>
|
||||
)}
|
||||
{rec.suggested_rate != null && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Target Rate
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{rec.suggested_rate}% APY
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{rec.bank_name && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Bank
|
||||
</Text>
|
||||
<Text fw={600}>{rec.bank_name}</Text>
|
||||
</div>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Alert variant="light" color="gray" title="Rationale">
|
||||
<Text size="sm">{rec.rationale}</Text>
|
||||
</Alert>
|
||||
|
||||
{onAddToPlan && rec.type !== 'liquidity_warning' && rec.type !== 'general' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconPlaylistAdd size={16} />}
|
||||
onClick={() => onAddToPlan(rec)}
|
||||
>
|
||||
Add to Investment Plan
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No specific recommendations at this time.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function InvestmentPlanningPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||
const [isTriggering, setIsTriggering] = useState(false);
|
||||
const [planModalOpen, setPlanModalOpen] = useState(false);
|
||||
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
|
||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||
const [newScenarioName, setNewScenarioName] = useState('');
|
||||
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
||||
|
||||
// Load investment scenarios for the "Add to Plan" modal
|
||||
const { data: investmentScenarios } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios', 'investment'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios?type=investment');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const addToPlanMutation = useMutation({
|
||||
mutationFn: async ({ scenarioId, rec }: { scenarioId: string; rec: Recommendation }) => {
|
||||
await api.post(`/board-planning/scenarios/${scenarioId}/investments/from-recommendation`, {
|
||||
title: rec.title,
|
||||
investmentType: rec.type === 'cd_ladder' ? 'cd' : rec.type === 'new_investment' ? undefined : undefined,
|
||||
fundType: rec.fund_type || 'reserve',
|
||||
suggestedAmount: rec.suggested_amount,
|
||||
suggestedRate: rec.suggested_rate,
|
||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||
bankName: rec.bank_name,
|
||||
rationale: rec.rationale,
|
||||
components: rec.components || undefined,
|
||||
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
|
||||
});
|
||||
return scenarioId;
|
||||
},
|
||||
onSuccess: (scenarioId) => {
|
||||
setPlanModalOpen(false);
|
||||
setSelectedRec(null);
|
||||
setTargetScenarioId(null);
|
||||
notifications.show({
|
||||
message: 'Recommendation added to investment scenario',
|
||||
color: 'green',
|
||||
autoClose: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createAndAddMutation = useMutation({
|
||||
mutationFn: async ({ name, rec }: { name: string; rec: Recommendation }) => {
|
||||
const { data: scenario } = await api.post('/board-planning/scenarios', {
|
||||
name, scenarioType: 'investment',
|
||||
});
|
||||
await api.post(`/board-planning/scenarios/${scenario.id}/investments/from-recommendation`, {
|
||||
title: rec.title,
|
||||
investmentType: rec.type === 'cd_ladder' ? 'cd' : undefined,
|
||||
fundType: rec.fund_type || 'reserve',
|
||||
suggestedAmount: rec.suggested_amount,
|
||||
suggestedRate: rec.suggested_rate,
|
||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||
bankName: rec.bank_name,
|
||||
rationale: rec.rationale,
|
||||
components: rec.components || undefined,
|
||||
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
|
||||
});
|
||||
return scenario.id;
|
||||
},
|
||||
onSuccess: (scenarioId) => {
|
||||
setPlanModalOpen(false);
|
||||
setSelectedRec(null);
|
||||
setNewScenarioName('');
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
notifications.show({
|
||||
message: 'New scenario created with recommendation',
|
||||
color: 'green',
|
||||
autoClose: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddToPlan = (rec: Recommendation) => {
|
||||
setSelectedRec(rec);
|
||||
setTargetScenarioId(null);
|
||||
setNewScenarioName('');
|
||||
setInvestmentStartDate(new Date());
|
||||
setPlanModalOpen(true);
|
||||
};
|
||||
|
||||
// Load financial snapshot on mount
|
||||
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]);
|
||||
|
||||
// Ref for scrolling to AI section on completion
|
||||
const aiSectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Show notification when processing completes (transition from processing)
|
||||
const prevStatusRef = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const [prevStatus, setPrevStatus] = prevStatusRef;
|
||||
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
|
||||
notifications.show({
|
||||
title: 'AI Analysis Complete',
|
||||
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
|
||||
color: 'green',
|
||||
autoClose: 8000,
|
||||
});
|
||||
// Scroll the AI section into view so user sees the new results
|
||||
setTimeout(() => {
|
||||
aiSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 300);
|
||||
}
|
||||
if (prevStatus === 'processing' && savedRec?.status === 'error') {
|
||||
notifications.show({
|
||||
title: 'AI Analysis Failed',
|
||||
message: savedRec.error_message || 'AI recommendation analysis failed',
|
||||
color: 'red',
|
||||
autoClose: 8000,
|
||||
});
|
||||
}
|
||||
setPrevStatus(savedRec?.status || null);
|
||||
}, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Trigger AI recommendations (async — returns immediately)
|
||||
const handleTriggerAI = useCallback(async () => {
|
||||
setIsTriggering(true);
|
||||
try {
|
||||
await api.post('/investment-planning/recommendations');
|
||||
} catch (err: any) {
|
||||
setIsTriggering(false);
|
||||
notifications.show({
|
||||
message: err.response?.data?.message || 'Failed to start AI analysis',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Build AI result from saved recommendation for display
|
||||
const aiResult: AIResponse | null = hasResults
|
||||
? {
|
||||
recommendations: savedRec!.recommendations,
|
||||
overall_assessment: savedRec!.overall_assessment,
|
||||
risk_notes: savedRec!.risk_notes,
|
||||
}
|
||||
: (lastFailed && savedRec?.recommendations?.length)
|
||||
? {
|
||||
recommendations: savedRec!.recommendations,
|
||||
overall_assessment: savedRec!.overall_assessment,
|
||||
risk_notes: savedRec!.risk_notes,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (snapshotLoading) {
|
||||
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'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" ref={aiSectionRef}>
|
||||
<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 - shown as banner when refreshing with existing results */}
|
||||
{isProcessing && (
|
||||
<Alert variant="light" color="grape" mb="md" styles={{ root: { overflow: 'visible' } }}>
|
||||
<Group gap="sm">
|
||||
<Loader size="sm" color="grape" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{aiResult ? 'Refreshing AI analysis...' : 'Running AI analysis...'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Analyzing your financial data, accounts, budgets, and current market rates
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Progress value={100} animated color="grape" size="xs" mt="xs" />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 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 - keep visible even while refreshing (with optional failure watermark) */}
|
||||
{aiResult && (
|
||||
<div style={isProcessing ? { opacity: 0.5, pointerEvents: 'none' } : undefined}>
|
||||
<RecommendationsDisplay
|
||||
aiResult={aiResult}
|
||||
lastUpdated={savedRec?.created_at || undefined}
|
||||
lastFailed={lastFailed}
|
||||
onAddToPlan={handleAddToPlan}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State - only when no results and not processing */}
|
||||
{!aiResult && !isProcessing && !hasError && (
|
||||
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||
<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 "Get AI Recommendations" 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>
|
||||
|
||||
{/* Add to Investment Plan Modal */}
|
||||
<Modal opened={planModalOpen} onClose={() => setPlanModalOpen(false)} title="Add to Investment Plan">
|
||||
<Stack>
|
||||
{selectedRec && (
|
||||
<Alert variant="light" color="blue">
|
||||
<Text size="sm" fw={600}>{selectedRec.title}</Text>
|
||||
{selectedRec.suggested_amount != null && (
|
||||
<Text size="sm">Amount: {fmt(selectedRec.suggested_amount)}</Text>
|
||||
)}
|
||||
{selectedRec.components && selectedRec.components.length > 0 && (
|
||||
<Stack gap={2} mt={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>{selectedRec.components.length} investments will be created:</Text>
|
||||
{selectedRec.components.map((c, i) => (
|
||||
<Text key={i} size="xs" c="dimmed">
|
||||
{c.label}: {fmt(c.amount)} @ {c.rate}% ({c.term_months} mo)
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DateInput
|
||||
label="Start Date"
|
||||
description="Purchase date for the investment(s). Maturity dates are calculated automatically from term length."
|
||||
value={investmentStartDate}
|
||||
onChange={setInvestmentStartDate}
|
||||
/>
|
||||
|
||||
{investmentScenarios && investmentScenarios.length > 0 && (
|
||||
<Select
|
||||
label="Add to existing scenario"
|
||||
placeholder="Select a scenario..."
|
||||
data={investmentScenarios.map((s: any) => ({ value: s.id, label: s.name }))}
|
||||
value={targetScenarioId}
|
||||
onChange={setTargetScenarioId}
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider label="or" labelPosition="center" />
|
||||
|
||||
<TextInput
|
||||
label="Create new scenario"
|
||||
placeholder="e.g. Conservative Strategy"
|
||||
value={newScenarioName}
|
||||
onChange={(e) => { setNewScenarioName(e.target.value); setTargetScenarioId(null); }}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
|
||||
{targetScenarioId && selectedRec && (
|
||||
<Button
|
||||
onClick={() => addToPlanMutation.mutate({ scenarioId: targetScenarioId, rec: selectedRec })}
|
||||
loading={addToPlanMutation.isPending}
|
||||
>
|
||||
Add to Scenario
|
||||
</Button>
|
||||
)}
|
||||
{newScenarioName && !targetScenarioId && selectedRec && (
|
||||
<Button
|
||||
onClick={() => createAndAddMutation.mutate({ name: newScenarioName, rec: selectedRec })}
|
||||
loading={createAndAddMutation.isPending}
|
||||
>
|
||||
Create & Add
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user