Phase 8: AI-driven operating and reserve fund health scores
Add daily AI health score calculation (0-100) for both operating and reserve funds. Scores include trajectory tracking, factor analysis, recommendations, and data readiness checks. Dashboard displays graphical RingProgress gauges with color-coded scores, trend indicators, and expandable detail popovers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
46
backend/package-lock.json
generated
46
backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -1592,6 +1593,19 @@
|
|||||||
"@nestjs/core": "^10.0.0"
|
"@nestjs/core": "^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/schedule": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron": "4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "10.2.3",
|
"version": "10.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||||
@@ -2027,6 +2041,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
|
||||||
|
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/multer": {
|
"node_modules/@types/multer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||||
@@ -3432,6 +3452,23 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cron": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/luxon": "~3.7.0",
|
||||||
|
"luxon": "~3.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.x"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "ko-fi",
|
||||||
|
"url": "https://ko-fi.com/intcreator"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -5916,6 +5953,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.8",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { ProjectsModule } from './modules/projects/projects.module';
|
|||||||
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
||||||
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||||
|
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -64,6 +66,8 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
|
|||||||
MonthlyActualsModule,
|
MonthlyActualsModule,
|
||||||
AttachmentsModule,
|
AttachmentsModule,
|
||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
|
HealthScoresModule,
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -328,6 +328,25 @@ export class TenantSchemaService {
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
// Health Scores (AI-derived operating / reserve fund health)
|
||||||
|
`CREATE TABLE "${s}".health_scores (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')),
|
||||||
|
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||||
|
previous_score INTEGER,
|
||||||
|
trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')),
|
||||||
|
label VARCHAR(30),
|
||||||
|
summary TEXT,
|
||||||
|
factors JSONB,
|
||||||
|
recommendations JSONB,
|
||||||
|
missing_data JSONB,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')),
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`,
|
||||||
|
|
||||||
// Attachments (file storage for receipts/invoices)
|
// Attachments (file storage for receipts/invoices)
|
||||||
`CREATE TABLE "${s}".attachments (
|
`CREATE TABLE "${s}".attachments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 { HealthScoresService } from './health-scores.service';
|
||||||
|
|
||||||
|
@ApiTags('health-scores')
|
||||||
|
@Controller('health-scores')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class HealthScoresController {
|
||||||
|
constructor(private service: HealthScoresService) {}
|
||||||
|
|
||||||
|
@Get('latest')
|
||||||
|
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||||
|
getLatest(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
return this.service.getLatestScores(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
@ApiOperation({ summary: 'Trigger health score recalculation for current tenant' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculate(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
const [operating, reserve] = await Promise.all([
|
||||||
|
this.service.calculateScore(schema, 'operating'),
|
||||||
|
this.service.calculateScore(schema, 'reserve'),
|
||||||
|
]);
|
||||||
|
return { operating, reserve };
|
||||||
|
}
|
||||||
|
}
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
788
backend/src/modules/health-scores/health-scores.service.ts
Normal file
788
backend/src/modules/health-scores/health-scores.service.ts
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
export interface HealthScore {
|
||||||
|
id: string;
|
||||||
|
score_type: string;
|
||||||
|
score: number;
|
||||||
|
previous_score: number | null;
|
||||||
|
trajectory: string | null;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
factors: any;
|
||||||
|
recommendations: any;
|
||||||
|
missing_data: any;
|
||||||
|
status: string;
|
||||||
|
response_time_ms: number | null;
|
||||||
|
calculated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIHealthResponse {
|
||||||
|
score: number;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>;
|
||||||
|
recommendations: Array<{ priority: 'high' | 'medium' | 'low'; text: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthScoresService {
|
||||||
|
private readonly logger = new Logger(HealthScoresService.name);
|
||||||
|
private debugEnabled: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
this.debugEnabled = this.configService.get<string>('AI_DEBUG') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
private debug(label: string, data: any) {
|
||||||
|
if (!this.debugEnabled) return;
|
||||||
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||||
|
const truncated = text.length > 5000 ? text.slice(0, 5000) + `\n... [truncated]` : text;
|
||||||
|
this.logger.log(`[AI_DEBUG] ${label}:\n${truncated}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──
|
||||||
|
|
||||||
|
async getLatestScores(schema: string): Promise<{ operating: HealthScore | null; reserve: HealthScore | null }> {
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await qr.connect();
|
||||||
|
await qr.query(`SET search_path TO "${schema}"`);
|
||||||
|
|
||||||
|
const operating = await qr.query(
|
||||||
|
`SELECT * FROM health_scores WHERE score_type = 'operating' ORDER BY calculated_at DESC LIMIT 1`,
|
||||||
|
);
|
||||||
|
const reserve = await qr.query(
|
||||||
|
`SELECT * FROM health_scores WHERE score_type = 'reserve' ORDER BY calculated_at DESC LIMIT 1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operating: operating[0] || null,
|
||||||
|
reserve: reserve[0] || null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateScore(schema: string, scoreType: 'operating' | 'reserve'): Promise<HealthScore> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await qr.connect();
|
||||||
|
await qr.query(`SET search_path TO "${schema}"`);
|
||||||
|
|
||||||
|
// Check data readiness
|
||||||
|
const missingData = await this.checkDataReadiness(qr, scoreType);
|
||||||
|
if (missingData.length > 0) {
|
||||||
|
return this.savePendingScore(qr, scoreType, missingData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather financial data
|
||||||
|
const data = scoreType === 'operating'
|
||||||
|
? await this.gatherOperatingData(qr)
|
||||||
|
: await this.gatherReserveData(qr);
|
||||||
|
|
||||||
|
// Build prompt and call AI
|
||||||
|
const messages = scoreType === 'operating'
|
||||||
|
? this.buildOperatingPrompt(data)
|
||||||
|
: this.buildReservePrompt(data);
|
||||||
|
|
||||||
|
const aiResponse = await this.callAI(messages);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Get previous score for trajectory
|
||||||
|
const prevRows = await qr.query(
|
||||||
|
`SELECT score FROM health_scores WHERE score_type = $1 ORDER BY calculated_at DESC LIMIT 1`,
|
||||||
|
[scoreType],
|
||||||
|
);
|
||||||
|
const previousScore = prevRows[0]?.score ?? null;
|
||||||
|
|
||||||
|
let trajectory: string | null = null;
|
||||||
|
if (previousScore !== null) {
|
||||||
|
const diff = aiResponse.score - previousScore;
|
||||||
|
if (diff >= 3) trajectory = 'improving';
|
||||||
|
else if (diff <= -3) trajectory = 'declining';
|
||||||
|
else trajectory = 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save and return
|
||||||
|
const rows = await qr.query(
|
||||||
|
`INSERT INTO health_scores
|
||||||
|
(score_type, score, previous_score, trajectory, label, summary, factors, recommendations, status, response_time_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'complete', $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
scoreType,
|
||||||
|
aiResponse.score,
|
||||||
|
previousScore,
|
||||||
|
trajectory,
|
||||||
|
aiResponse.label,
|
||||||
|
aiResponse.summary,
|
||||||
|
JSON.stringify(aiResponse.factors),
|
||||||
|
JSON.stringify(aiResponse.recommendations),
|
||||||
|
elapsed,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Health score calculated: ${schema}.${scoreType} = ${aiResponse.score} (${elapsed}ms)`);
|
||||||
|
return rows[0];
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Health score calculation failed for ${schema}.${scoreType}: ${err.message}`);
|
||||||
|
// Save error status
|
||||||
|
try {
|
||||||
|
const rows = await qr.query(
|
||||||
|
`INSERT INTO health_scores
|
||||||
|
(score_type, score, status, summary, response_time_ms)
|
||||||
|
VALUES ($1, 0, 'error', $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[scoreType, `Calculation error: ${err.message}`, Date.now() - startTime],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
} catch {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data Readiness Checks ──
|
||||||
|
|
||||||
|
private async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
if (scoreType === 'operating') {
|
||||||
|
// Must have at least one operating asset account
|
||||||
|
const opAccounts = await qr.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM accounts WHERE fund_type = 'operating' AND account_type = 'asset' AND is_active = true`,
|
||||||
|
);
|
||||||
|
if (parseInt(opAccounts[0].cnt) === 0) {
|
||||||
|
missing.push('No operating fund accounts found. Create at least one operating asset account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have an annual budget for the current year
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const budgets = await qr.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM budgets WHERE fiscal_year = $1`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
if (parseInt(budgets[0].cnt) === 0) {
|
||||||
|
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reserve: must have at least one reserve asset account
|
||||||
|
const resAccounts = await qr.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM accounts WHERE fund_type = 'reserve' AND account_type = 'asset' AND is_active = true`,
|
||||||
|
);
|
||||||
|
if (parseInt(resAccounts[0].cnt) === 0) {
|
||||||
|
missing.push('No reserve fund accounts found. Create at least one reserve asset account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have a budget
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const budgets = await qr.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM budgets WHERE fiscal_year = $1`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
if (parseInt(budgets[0].cnt) === 0) {
|
||||||
|
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have capital projects (warn but don't block)
|
||||||
|
const projects = await qr.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true`,
|
||||||
|
);
|
||||||
|
if (parseInt(projects[0].cnt) === 0) {
|
||||||
|
missing.push('No capital projects found. Add planned capital projects for a more accurate reserve health assessment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async savePendingScore(qr: any, scoreType: string, missingData: string[]): Promise<HealthScore> {
|
||||||
|
const rows = await qr.query(
|
||||||
|
`INSERT INTO health_scores
|
||||||
|
(score_type, score, status, summary, missing_data)
|
||||||
|
VALUES ($1, 0, 'pending', $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
scoreType,
|
||||||
|
'Unable to calculate health score — required data is missing.',
|
||||||
|
JSON.stringify(missingData),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data Gathering ──
|
||||||
|
|
||||||
|
private async gatherOperatingData(qr: any) {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const [accounts, budgets, assessments, cashFlow, recentTransactions] = await Promise.all([
|
||||||
|
// Operating accounts with balances
|
||||||
|
qr.query(`
|
||||||
|
SELECT a.name, a.account_number, a.account_type, a.fund_type,
|
||||||
|
CASE
|
||||||
|
WHEN a.account_type IN ('asset', 'expense')
|
||||||
|
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||||
|
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
|
||||||
|
WHERE a.is_active = true AND a.fund_type = 'operating'
|
||||||
|
GROUP BY a.id, a.name, a.account_number, a.account_type, a.fund_type
|
||||||
|
ORDER BY a.account_number
|
||||||
|
`),
|
||||||
|
// Budget for current year (operating fund)
|
||||||
|
qr.query(
|
||||||
|
`SELECT a.name, a.account_number, 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) as annual_total,
|
||||||
|
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 AND b.fund_type = 'operating'
|
||||||
|
ORDER BY a.account_type, a.account_number`,
|
||||||
|
[year],
|
||||||
|
),
|
||||||
|
// Assessment groups
|
||||||
|
qr.query(`
|
||||||
|
SELECT ag.name, ag.frequency, ag.regular_assessment,
|
||||||
|
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as unit_count
|
||||||
|
FROM assessment_groups ag WHERE ag.is_active = true
|
||||||
|
`),
|
||||||
|
// YTD income and expenses
|
||||||
|
qr.query(`
|
||||||
|
SELECT a.account_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 ytd_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
|
||||||
|
WHERE a.is_active = true AND a.fund_type = 'operating'
|
||||||
|
AND a.account_type IN ('income', 'expense')
|
||||||
|
GROUP BY a.account_type
|
||||||
|
`, [`${year}-01-01`]),
|
||||||
|
// Delinquent receivables (invoices past due)
|
||||||
|
qr.query(`
|
||||||
|
SELECT COUNT(*) as count, COALESCE(SUM(amount_due - amount_paid), 0) as total_overdue
|
||||||
|
FROM invoices
|
||||||
|
WHERE status IN ('sent', 'overdue') AND due_date < CURRENT_DATE
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate month-by-month budget actuals progress
|
||||||
|
const currentMonth = new Date().getMonth(); // 0-indexed
|
||||||
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||||
|
let budgetedIncomeYTD = 0;
|
||||||
|
let budgetedExpenseYTD = 0;
|
||||||
|
for (const b of budgets) {
|
||||||
|
for (let m = 0; m <= currentMonth; m++) {
|
||||||
|
const amt = parseFloat(b[monthNames[m]]) || 0;
|
||||||
|
if (b.account_type === 'income') budgetedIncomeYTD += amt;
|
||||||
|
else if (b.account_type === 'expense') budgetedExpenseYTD += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operatingCash = accounts
|
||||||
|
.filter((a: any) => a.account_type === 'asset')
|
||||||
|
.reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
||||||
|
|
||||||
|
const budgetedIncomeAnnual = budgets
|
||||||
|
.filter((b: any) => b.account_type === 'income')
|
||||||
|
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
||||||
|
|
||||||
|
const budgetedExpenseAnnual = budgets
|
||||||
|
.filter((b: any) => b.account_type === 'expense')
|
||||||
|
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
||||||
|
|
||||||
|
const ytdIncome = parseFloat(cashFlow.find((c: any) => c.account_type === 'income')?.ytd_amount || '0');
|
||||||
|
const ytdExpense = parseFloat(cashFlow.find((c: any) => c.account_type === 'expense')?.ytd_amount || '0');
|
||||||
|
|
||||||
|
const monthlyAssessmentIncome = assessments.reduce((s: number, ag: any) => {
|
||||||
|
const regular = parseFloat(ag.regular_assessment) || 0;
|
||||||
|
const units = parseInt(ag.unit_count) || 0;
|
||||||
|
return s + (regular * units);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operatingCash,
|
||||||
|
accounts,
|
||||||
|
budgets,
|
||||||
|
assessments,
|
||||||
|
budgetedIncomeAnnual,
|
||||||
|
budgetedExpenseAnnual,
|
||||||
|
budgetedIncomeYTD,
|
||||||
|
budgetedExpenseYTD,
|
||||||
|
ytdIncome,
|
||||||
|
ytdExpense,
|
||||||
|
monthlyAssessmentIncome,
|
||||||
|
delinquentCount: parseInt(recentTransactions[0]?.count || '0'),
|
||||||
|
delinquentAmount: parseFloat(recentTransactions[0]?.total_overdue || '0'),
|
||||||
|
monthsOfExpenses: budgetedExpenseAnnual > 0 ? (operatingCash / (budgetedExpenseAnnual / 12)) : 0,
|
||||||
|
year,
|
||||||
|
currentMonth: currentMonth + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async gatherReserveData(qr: any) {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const [accounts, investments, reserveComponents, projects, budgets] = await Promise.all([
|
||||||
|
// Reserve accounts with balances
|
||||||
|
qr.query(`
|
||||||
|
SELECT a.name, a.account_number, a.account_type, a.fund_type,
|
||||||
|
CASE
|
||||||
|
WHEN a.account_type IN ('asset', 'expense')
|
||||||
|
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||||
|
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
|
||||||
|
WHERE a.is_active = true AND a.fund_type = 'reserve'
|
||||||
|
GROUP BY a.id, a.name, a.account_number, a.account_type, a.fund_type
|
||||||
|
ORDER BY a.account_number
|
||||||
|
`),
|
||||||
|
// Investment accounts (reserve fund)
|
||||||
|
qr.query(`
|
||||||
|
SELECT name, institution, investment_type, fund_type,
|
||||||
|
principal, interest_rate, maturity_date, current_value
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true AND fund_type = 'reserve'
|
||||||
|
ORDER BY maturity_date NULLS LAST
|
||||||
|
`),
|
||||||
|
// Reserve components
|
||||||
|
qr.query(`
|
||||||
|
SELECT name, category, useful_life_years, remaining_life_years,
|
||||||
|
replacement_cost, current_fund_balance, annual_contribution, condition_rating
|
||||||
|
FROM reserve_components
|
||||||
|
ORDER BY remaining_life_years ASC NULLS LAST
|
||||||
|
`),
|
||||||
|
// Capital projects
|
||||||
|
qr.query(`
|
||||||
|
SELECT name, estimated_cost, target_year, target_month, fund_source,
|
||||||
|
status, priority, current_fund_balance, funded_percentage
|
||||||
|
FROM projects
|
||||||
|
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
||||||
|
ORDER BY target_year, target_month NULLS LAST
|
||||||
|
`),
|
||||||
|
// Reserve budget
|
||||||
|
qr.query(
|
||||||
|
`SELECT a.name, a.account_number, 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) as annual_total
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1 AND b.fund_type = 'reserve'
|
||||||
|
ORDER BY a.account_type, a.account_number`,
|
||||||
|
[year],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reserveCash = accounts
|
||||||
|
.filter((a: any) => a.account_type === 'asset')
|
||||||
|
.reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
||||||
|
|
||||||
|
const totalInvestments = investments
|
||||||
|
.reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
|
||||||
|
const totalReserveFund = reserveCash + totalInvestments;
|
||||||
|
|
||||||
|
const totalReplacementCost = reserveComponents
|
||||||
|
.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0);
|
||||||
|
|
||||||
|
const totalComponentFunded = reserveComponents
|
||||||
|
.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0);
|
||||||
|
|
||||||
|
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
|
||||||
|
|
||||||
|
const totalProjectCost = projects
|
||||||
|
.reduce((s: number, p: any) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||||
|
|
||||||
|
const annualReserveContribution = budgets
|
||||||
|
.filter((b: any) => b.account_type === 'income')
|
||||||
|
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
||||||
|
|
||||||
|
const annualReserveExpenses = budgets
|
||||||
|
.filter((b: any) => b.account_type === 'expense')
|
||||||
|
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
||||||
|
|
||||||
|
// Components needing replacement within 5 years
|
||||||
|
const urgentComponents = reserveComponents.filter(
|
||||||
|
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reserveCash,
|
||||||
|
totalInvestments,
|
||||||
|
totalReserveFund,
|
||||||
|
accounts,
|
||||||
|
investments,
|
||||||
|
reserveComponents,
|
||||||
|
projects,
|
||||||
|
budgets,
|
||||||
|
totalReplacementCost,
|
||||||
|
totalComponentFunded,
|
||||||
|
percentFunded,
|
||||||
|
totalProjectCost,
|
||||||
|
annualReserveContribution,
|
||||||
|
annualReserveExpenses,
|
||||||
|
urgentComponents,
|
||||||
|
year,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI Prompt Construction ──
|
||||||
|
|
||||||
|
private buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const systemPrompt = `You are an HOA financial health analyst. You evaluate the operating fund health of homeowners associations on a scale of 0-100.
|
||||||
|
|
||||||
|
SCORING GUIDELINES:
|
||||||
|
- 90-100 (Excellent): Strong cash reserves (6+ months expenses), income exceeding expenses, low delinquency, well under budget
|
||||||
|
- 75-89 (Good): Healthy cash position (3-6 months expenses), income roughly matching expenses, manageable delinquency
|
||||||
|
- 60-74 (Fair): Adequate but thin cash cushion (2-3 months), budget variances, moderate delinquency
|
||||||
|
- 40-59 (Needs Attention): Low cash reserves (<2 months expenses), spending over budget, rising delinquency
|
||||||
|
- 20-39 (At Risk): Critically low cash, significant budget overruns, high delinquency threatening operations
|
||||||
|
- 0-19 (Critical): Negative cash flow, unable to meet obligations, emergency situation
|
||||||
|
|
||||||
|
KEY FACTORS TO EVALUATE:
|
||||||
|
1. Cash reserves relative to monthly operating expenses (months of runway)
|
||||||
|
2. Budget performance: YTD actual income vs budgeted, YTD actual expenses vs budgeted
|
||||||
|
3. Assessment collection rate and delinquency
|
||||||
|
4. Income-to-expense ratio
|
||||||
|
5. Emergency buffer adequacy
|
||||||
|
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||||
|
{
|
||||||
|
"score": 82,
|
||||||
|
"label": "Good",
|
||||||
|
"summary": "1-2 sentence plain-English summary of operating fund health",
|
||||||
|
"factors": [
|
||||||
|
{ "name": "Cash Reserves", "impact": "positive", "detail": "6.2 months of expenses in operating cash" },
|
||||||
|
{ "name": "Budget Performance", "impact": "neutral", "detail": "YTD spending is 3% over budget" }
|
||||||
|
],
|
||||||
|
"recommendations": [
|
||||||
|
{ "priority": "medium", "text": "Consider increasing delinquency follow-up to reduce $4,200 in overdue assessments" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar amounts.`;
|
||||||
|
|
||||||
|
const accountLines = data.accounts
|
||||||
|
.map((a: any) => `- ${a.name} (${a.account_number}) [${a.account_type}]: $${parseFloat(a.balance || '0').toFixed(2)}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const budgetLines = data.budgets
|
||||||
|
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
|
||||||
|
.join('\n') || 'No budget line items.';
|
||||||
|
|
||||||
|
const assessmentLines = data.assessments
|
||||||
|
.map((a: any) => `- ${a.name}: $${parseFloat(a.regular_assessment || '0').toFixed(2)}/unit × ${a.unit_count} units (${a.frequency})`)
|
||||||
|
.join('\n') || 'No assessment groups.';
|
||||||
|
|
||||||
|
const userPrompt = `Evaluate this HOA's operating fund health.
|
||||||
|
|
||||||
|
TODAY: ${today}
|
||||||
|
FISCAL YEAR: ${data.year}
|
||||||
|
CURRENT MONTH: ${data.currentMonth} of 12
|
||||||
|
|
||||||
|
=== OPERATING FUND ACCOUNTS ===
|
||||||
|
${accountLines}
|
||||||
|
|
||||||
|
Total Operating Cash: $${data.operatingCash.toFixed(2)}
|
||||||
|
|
||||||
|
=== BUDGET (${data.year}) ===
|
||||||
|
${budgetLines}
|
||||||
|
|
||||||
|
Budgeted Annual Income: $${data.budgetedIncomeAnnual.toFixed(2)}
|
||||||
|
Budgeted Annual Expenses: $${data.budgetedExpenseAnnual.toFixed(2)}
|
||||||
|
Monthly Expense Run Rate: $${(data.budgetedExpenseAnnual / 12).toFixed(2)}
|
||||||
|
|
||||||
|
=== BUDGET VS ACTUAL (YTD through month ${data.currentMonth}) ===
|
||||||
|
Budgeted Income YTD: $${data.budgetedIncomeYTD.toFixed(2)}
|
||||||
|
Actual Income YTD: $${data.ytdIncome.toFixed(2)}
|
||||||
|
Income Variance: $${(data.ytdIncome - data.budgetedIncomeYTD).toFixed(2)} (${data.budgetedIncomeYTD > 0 ? ((data.ytdIncome / data.budgetedIncomeYTD) * 100).toFixed(1) : 0}% of budget)
|
||||||
|
|
||||||
|
Budgeted Expenses YTD: $${data.budgetedExpenseYTD.toFixed(2)}
|
||||||
|
Actual Expenses YTD: $${data.ytdExpense.toFixed(2)}
|
||||||
|
Expense Variance: $${(data.ytdExpense - data.budgetedExpenseYTD).toFixed(2)} (${data.budgetedExpenseYTD > 0 ? ((data.ytdExpense / data.budgetedExpenseYTD) * 100).toFixed(1) : 0}% of budget)
|
||||||
|
|
||||||
|
=== CASH RUNWAY ===
|
||||||
|
Months of Operating Expenses Covered: ${data.monthsOfExpenses.toFixed(1)} months
|
||||||
|
|
||||||
|
=== ASSESSMENT INCOME ===
|
||||||
|
${assessmentLines}
|
||||||
|
Monthly Assessment Income: $${data.monthlyAssessmentIncome.toFixed(2)}
|
||||||
|
|
||||||
|
=== DELINQUENCY ===
|
||||||
|
Overdue Invoices: ${data.delinquentCount}
|
||||||
|
Total Overdue Amount: $${data.delinquentAmount.toFixed(2)}
|
||||||
|
Delinquency Rate: ${data.monthlyAssessmentIncome > 0 ? ((data.delinquentAmount / (data.monthlyAssessmentIncome * 3)) * 100).toFixed(1) : 0}% of quarterly income`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildReservePrompt(data: any): Array<{ role: string; content: string }> {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const systemPrompt = `You are an HOA reserve fund analyst. You evaluate reserve fund health on a scale of 0-100, assessing whether the HOA is adequately prepared for future capital expenditures.
|
||||||
|
|
||||||
|
SCORING GUIDELINES:
|
||||||
|
- 90-100 (Excellent): 70%+ funded ratio, strong annual contributions, well-documented components, investments earning returns, no urgent unfunded projects
|
||||||
|
- 75-89 (Good): 50-70% funded, adequate contributions, most components tracked, some investments
|
||||||
|
- 60-74 (Fair): 30-50% funded, contributions below ideal, some components untracked, limited investments
|
||||||
|
- 40-59 (Needs Attention): 20-30% funded, inadequate contributions, multiple urgent unfunded components
|
||||||
|
- 20-39 (At Risk): <20% funded, no meaningful contributions, urgent replacements needed with no funding
|
||||||
|
- 0-19 (Critical): Nearly depleted reserves, imminent major expenditures, no funding plan
|
||||||
|
|
||||||
|
KEY FACTORS TO EVALUATE:
|
||||||
|
1. Percent funded (total reserve assets vs total replacement costs)
|
||||||
|
2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?)
|
||||||
|
3. Component urgency (components due within 5 years and their funding status)
|
||||||
|
4. Capital project readiness (are planned projects adequately funded?)
|
||||||
|
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
|
||||||
|
6. Diversity of reserve components (is the full building covered?)
|
||||||
|
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||||
|
{
|
||||||
|
"score": 65,
|
||||||
|
"label": "Fair",
|
||||||
|
"summary": "1-2 sentence plain-English summary of reserve fund health",
|
||||||
|
"factors": [
|
||||||
|
{ "name": "Funded Ratio", "impact": "negative", "detail": "Only 42% funded against $1.2M in replacement costs" },
|
||||||
|
{ "name": "Annual Contributions", "impact": "positive", "detail": "$48,000/year being set aside for reserves" }
|
||||||
|
],
|
||||||
|
"recommendations": [
|
||||||
|
{ "priority": "high", "text": "Roof replacement due in 2 years needs $180,000 — only $45,000 currently set aside" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar amounts and timelines.`;
|
||||||
|
|
||||||
|
const accountLines = data.accounts
|
||||||
|
.map((a: any) => `- ${a.name} (${a.account_number}) [${a.account_type}]: $${parseFloat(a.balance || '0').toFixed(2)}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const investmentLines = data.investments.length === 0
|
||||||
|
? 'No reserve investments.'
|
||||||
|
: data.investments.map((i: any) =>
|
||||||
|
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const componentLines = data.reserveComponents.length === 0
|
||||||
|
? 'No reserve components tracked.'
|
||||||
|
: data.reserveComponents.map((c: any) => {
|
||||||
|
const cost = parseFloat(c.replacement_cost || '0');
|
||||||
|
const funded = parseFloat(c.current_fund_balance || '0');
|
||||||
|
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
||||||
|
return `- ${c.name} [${c.category}] | Life: ${c.useful_life_years}yr, Remaining: ${c.remaining_life_years}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const projectLines = data.projects.length === 0
|
||||||
|
? 'No capital projects planned.'
|
||||||
|
: data.projects.map((p: any) =>
|
||||||
|
`- ${p.name} | Cost: $${parseFloat(p.estimated_cost || '0').toFixed(0)} | Target: ${p.target_year || '?'}/${p.target_month || '?'} | Status: ${p.status} | Funded: ${parseFloat(p.funded_percentage || '0').toFixed(0)}% | Source: ${p.fund_source}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const budgetLines = data.budgets
|
||||||
|
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
|
||||||
|
.join('\n') || 'No reserve budget line items.';
|
||||||
|
|
||||||
|
const urgentLines = data.urgentComponents.length === 0
|
||||||
|
? 'None — no components due within 5 years.'
|
||||||
|
: data.urgentComponents.map((c: any) => {
|
||||||
|
const cost = parseFloat(c.replacement_cost || '0');
|
||||||
|
const funded = parseFloat(c.current_fund_balance || '0');
|
||||||
|
const gap = cost - funded;
|
||||||
|
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const userPrompt = `Evaluate this HOA's reserve fund health.
|
||||||
|
|
||||||
|
TODAY: ${today}
|
||||||
|
FISCAL YEAR: ${data.year}
|
||||||
|
|
||||||
|
=== RESERVE FUND OVERVIEW ===
|
||||||
|
Reserve Cash (bank accounts): $${data.reserveCash.toFixed(2)}
|
||||||
|
Reserve Investments: $${data.totalInvestments.toFixed(2)}
|
||||||
|
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
|
||||||
|
|
||||||
|
Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)}
|
||||||
|
Percent Funded: ${data.percentFunded.toFixed(1)}%
|
||||||
|
|
||||||
|
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
|
||||||
|
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
|
||||||
|
Net Annual Reserve Growth: $${(data.annualReserveContribution - data.annualReserveExpenses).toFixed(2)}
|
||||||
|
|
||||||
|
=== RESERVE ACCOUNTS ===
|
||||||
|
${accountLines}
|
||||||
|
|
||||||
|
=== RESERVE INVESTMENTS ===
|
||||||
|
${investmentLines}
|
||||||
|
|
||||||
|
=== RESERVE COMPONENTS (ordered by urgency) ===
|
||||||
|
${componentLines}
|
||||||
|
|
||||||
|
=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) ===
|
||||||
|
${urgentLines}
|
||||||
|
|
||||||
|
=== CAPITAL PROJECTS ===
|
||||||
|
${projectLines}
|
||||||
|
Total Planned Project Cost: $${data.totalProjectCost.toFixed(2)}
|
||||||
|
|
||||||
|
=== RESERVE BUDGET (${data.year}) ===
|
||||||
|
${budgetLines}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI API Call (mirrors investment-planning pattern) ──
|
||||||
|
|
||||||
|
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIHealthResponse> {
|
||||||
|
const apiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
|
||||||
|
const apiKey = this.configService.get<string>('AI_API_KEY');
|
||||||
|
const model = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
this.logger.error('AI_API_KEY not configured');
|
||||||
|
return {
|
||||||
|
score: 50,
|
||||||
|
label: 'Unknown',
|
||||||
|
summary: 'AI health scoring is not available. Configure AI_API_KEY in environment.',
|
||||||
|
factors: [],
|
||||||
|
recommendations: [{ priority: 'high', text: 'Configure AI_API_KEY to enable health scoring.' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 2048,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyString = JSON.stringify(requestBody);
|
||||||
|
|
||||||
|
this.debug('health_prompt_system', messages[0]?.content);
|
||||||
|
this.debug('health_prompt_user', messages[1]?.content);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { URL } = await import('url');
|
||||||
|
const https = await import('https');
|
||||||
|
|
||||||
|
const aiResult = await new Promise<any>((resolve, reject) => {
|
||||||
|
const url = new URL(`${apiUrl}/chat/completions`);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || 443,
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
|
},
|
||||||
|
timeout: 120000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({ status: res.statusCode, body: data });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => reject(err));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timed out after 120s'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(bodyString);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aiResult.status >= 400) {
|
||||||
|
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(aiResult.body);
|
||||||
|
const content = data.choices?.[0]?.message?.content || null;
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('AI model returned empty content');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON — handle markdown fences and thinking blocks
|
||||||
|
let cleaned = content.trim();
|
||||||
|
if (cleaned.startsWith('```')) {
|
||||||
|
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||||
|
}
|
||||||
|
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleaned) as AIHealthResponse;
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (typeof parsed.score !== 'number' || parsed.score < 0 || parsed.score > 100) {
|
||||||
|
throw new Error(`Invalid score: ${parsed.score}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure label matches score
|
||||||
|
if (!parsed.label) {
|
||||||
|
if (parsed.score >= 90) parsed.label = 'Excellent';
|
||||||
|
else if (parsed.score >= 75) parsed.label = 'Good';
|
||||||
|
else if (parsed.score >= 60) parsed.label = 'Fair';
|
||||||
|
else if (parsed.score >= 40) parsed.label = 'Needs Attention';
|
||||||
|
else if (parsed.score >= 20) parsed.label = 'At Risk';
|
||||||
|
else parsed.label = 'Critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debug('health_response', parsed);
|
||||||
|
return parsed;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Health score AI call failed: ${error.message}`);
|
||||||
|
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
return {
|
||||||
|
score: 50,
|
||||||
|
label: 'Unknown',
|
||||||
|
summary: 'AI returned an invalid response format. Please try again.',
|
||||||
|
factors: [],
|
||||||
|
recommendations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 $$;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||||
Badge, Loader, Center, Divider,
|
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
|
||||||
|
Popover, List,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCash,
|
IconCash,
|
||||||
@@ -8,11 +9,215 @@ import {
|
|||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconBuildingBank,
|
IconBuildingBank,
|
||||||
|
IconTrendingUp,
|
||||||
|
IconTrendingDown,
|
||||||
|
IconMinus,
|
||||||
|
IconHeartbeat,
|
||||||
|
IconRefresh,
|
||||||
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface HealthScore {
|
||||||
|
id: string;
|
||||||
|
score_type: string;
|
||||||
|
score: number;
|
||||||
|
previous_score: number | null;
|
||||||
|
trajectory: string | null;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>;
|
||||||
|
recommendations: Array<{ priority: string; text: string }>;
|
||||||
|
missing_data: string[] | null;
|
||||||
|
status: string;
|
||||||
|
response_time_ms: number | null;
|
||||||
|
calculated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthScoresData {
|
||||||
|
operating: HealthScore | null;
|
||||||
|
reserve: HealthScore | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }: { score: HealthScore | null; title: string; icon: React.ReactNode }) {
|
||||||
|
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>
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
<Center h={100}>
|
||||||
|
<Text c="dimmed" size="sm">No health score yet</Text>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{icon}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score.status === 'error') {
|
||||||
|
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>
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
<Center h={100}>
|
||||||
|
<Badge color="red" variant="light">Error calculating score</Badge>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{icon}
|
||||||
|
</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>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
total_cash: string;
|
total_cash: string;
|
||||||
total_receivables: string;
|
total_receivables: string;
|
||||||
@@ -33,6 +238,7 @@ interface DashboardData {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<DashboardData>({
|
const { data, isLoading } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
@@ -40,6 +246,19 @@ export function DashboardPage() {
|
|||||||
enabled: !!currentOrg,
|
enabled: !!currentOrg,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: healthScores } = useQuery<HealthScoresData>({
|
||||||
|
queryKey: ['health-scores'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||||
|
enabled: !!currentOrg,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recalcMutation = useMutation({
|
||||||
|
mutationFn: () => api.post('/health-scores/calculate'),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const fmt = (v: string | number) =>
|
const fmt = (v: string | number) =>
|
||||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
@@ -66,6 +285,41 @@ export function DashboardPage() {
|
|||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
||||||
|
<Tooltip label="Recalculate health scores now">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-xs"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={recalcMutation.isPending}
|
||||||
|
onClick={() => recalcMutation.mutate()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<HealthScoreCard
|
||||||
|
score={healthScores?.reserve || null}
|
||||||
|
title="Reserve Fund"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon color="violet" variant="light" size={36} radius="md">
|
||||||
|
<IconHeartbeat size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user