Compare commits
2 Commits
feature/bo
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b83defbc3 | |||
| 1e31595d7f |
26
backend/package-lock.json
generated
26
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.03.10",
|
"version": "2026.3.2-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.03.10",
|
"version": "2026.3.2-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
@@ -16,12 +16,10 @@
|
|||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"newrelic": "latest",
|
"newrelic": "latest",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -1793,17 +1791,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/throttler": {
|
|
||||||
"version": "6.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
|
||||||
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
|
||||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
|
||||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/typeorm": {
|
"node_modules/@nestjs/typeorm": {
|
||||||
"version": "10.0.2",
|
"version": "10.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
||||||
@@ -5290,15 +5277,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/helmet": {
|
|
||||||
"version": "8.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
|
||||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/html-entities": {
|
"node_modules/html-entities": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.03.16",
|
"version": "2026.3.2-beta",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,14 +25,11 @@
|
|||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"newrelic": "latest",
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
@@ -40,6 +37,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
|
"newrelic": "latest",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
@@ -28,7 +27,6 @@ import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.
|
|||||||
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 { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||||
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -54,10 +52,6 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
ThrottlerModule.forRoot([{
|
|
||||||
ttl: 60000, // 1-minute window
|
|
||||||
limit: 100, // 100 requests per minute (global default)
|
|
||||||
}]),
|
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
@@ -80,7 +74,6 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
AttachmentsModule,
|
AttachmentsModule,
|
||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
HealthScoresModule,
|
HealthScoresModule,
|
||||||
BoardPlanningModule,
|
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -330,8 +330,6 @@ export class TenantSchemaService {
|
|||||||
risk_notes JSONB,
|
risk_notes JSONB,
|
||||||
requested_by UUID,
|
requested_by UUID,
|
||||||
response_time_ms INTEGER,
|
response_time_ms INTEGER,
|
||||||
status VARCHAR(20) DEFAULT 'complete',
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
@@ -366,99 +364,6 @@ export class TenantSchemaService {
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
// Board Planning - Scenarios
|
|
||||||
`CREATE TABLE "${s}".board_scenarios (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN ('investment', 'assessment')),
|
|
||||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'approved', 'archived')),
|
|
||||||
projection_months INTEGER DEFAULT 36,
|
|
||||||
projection_cache JSONB,
|
|
||||||
projection_cached_at TIMESTAMPTZ,
|
|
||||||
created_by UUID NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Board Planning - Scenario Investments
|
|
||||||
`CREATE TABLE "${s}".scenario_investments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
|
|
||||||
source_recommendation_id UUID,
|
|
||||||
label VARCHAR(255) NOT NULL,
|
|
||||||
investment_type VARCHAR(50) CHECK (investment_type IN ('cd', 'money_market', 'treasury', 'savings', 'other')),
|
|
||||||
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
|
|
||||||
principal DECIMAL(15,2) NOT NULL,
|
|
||||||
interest_rate DECIMAL(6,4),
|
|
||||||
term_months INTEGER,
|
|
||||||
institution VARCHAR(255),
|
|
||||||
purchase_date DATE,
|
|
||||||
maturity_date DATE,
|
|
||||||
auto_renew BOOLEAN DEFAULT FALSE,
|
|
||||||
executed_investment_id UUID,
|
|
||||||
notes TEXT,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Board Planning - Scenario Assessments
|
|
||||||
`CREATE TABLE "${s}".scenario_assessments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
|
|
||||||
change_type VARCHAR(30) NOT NULL CHECK (change_type IN ('dues_increase', 'special_assessment', 'dues_decrease')),
|
|
||||||
label VARCHAR(255) NOT NULL,
|
|
||||||
target_fund VARCHAR(20) CHECK (target_fund IN ('operating', 'reserve', 'both')),
|
|
||||||
percentage_change DECIMAL(6,3),
|
|
||||||
flat_amount_change DECIMAL(10,2),
|
|
||||||
special_total DECIMAL(15,2),
|
|
||||||
special_per_unit DECIMAL(10,2),
|
|
||||||
special_installments INTEGER DEFAULT 1,
|
|
||||||
effective_date DATE NOT NULL,
|
|
||||||
end_date DATE,
|
|
||||||
applies_to_group_id UUID,
|
|
||||||
notes TEXT,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Budget Plans
|
|
||||||
`CREATE TABLE "${s}".budget_plans (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
fiscal_year INTEGER NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'approved', 'ratified')),
|
|
||||||
base_year INTEGER NOT NULL,
|
|
||||||
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
|
|
||||||
notes TEXT,
|
|
||||||
created_by UUID,
|
|
||||||
approved_by UUID,
|
|
||||||
approved_at TIMESTAMPTZ,
|
|
||||||
ratified_by UUID,
|
|
||||||
ratified_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(fiscal_year)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Budget Plan Lines
|
|
||||||
`CREATE TABLE "${s}".budget_plan_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
budget_plan_id UUID NOT NULL REFERENCES "${s}".budget_plans(id) ON DELETE CASCADE,
|
|
||||||
account_id UUID NOT NULL REFERENCES "${s}".accounts(id),
|
|
||||||
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
|
|
||||||
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
|
|
||||||
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
|
|
||||||
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
|
|
||||||
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
|
|
||||||
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
|
|
||||||
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
|
|
||||||
is_manually_adjusted BOOLEAN DEFAULT FALSE,
|
|
||||||
notes TEXT,
|
|
||||||
UNIQUE(budget_plan_id, account_id, fund_type)
|
|
||||||
)`,
|
|
||||||
|
|
||||||
// Indexes
|
// Indexes
|
||||||
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
||||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
||||||
@@ -471,12 +376,6 @@ export class TenantSchemaService {
|
|||||||
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
|
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
|
||||||
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
|
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
|
||||||
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
|
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
|
||||||
`CREATE INDEX "idx_${s}_bs_type_status" ON "${s}".board_scenarios(scenario_type, status)`,
|
|
||||||
`CREATE INDEX "idx_${s}_si_scenario" ON "${s}".scenario_investments(scenario_id)`,
|
|
||||||
`CREATE INDEX "idx_${s}_sa_scenario" ON "${s}".scenario_assessments(scenario_id)`,
|
|
||||||
`CREATE INDEX "idx_${s}_bp_year" ON "${s}".budget_plans(fiscal_year)`,
|
|
||||||
`CREATE INDEX "idx_${s}_bp_status" ON "${s}".budget_plans(status)`,
|
|
||||||
`CREATE INDEX "idx_${s}_bpl_plan" ON "${s}".budget_plan_lines(budget_plan_id)`,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
// In-memory cache for org info to avoid DB hit per request
|
// In-memory cache for org status to avoid DB hit per request
|
||||||
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
|
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
||||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -30,25 +30,23 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
const secret = this.configService.get<string>('JWT_SECRET');
|
const secret = this.configService.get<string>('JWT_SECRET');
|
||||||
const decoded = jwt.verify(token, secret!) as any;
|
const decoded = jwt.verify(token, secret!) as any;
|
||||||
if (decoded?.orgId) {
|
if (decoded?.orgSchema) {
|
||||||
// Look up org info (status + schema) from orgId with caching
|
// Check if the org is still active (catches post-JWT suspension)
|
||||||
const orgInfo = await this.getOrgInfo(decoded.orgId);
|
if (decoded.orgId) {
|
||||||
if (orgInfo) {
|
const status = await this.getOrgStatus(decoded.orgId);
|
||||||
if (['suspended', 'archived'].includes(orgInfo.status)) {
|
if (status && ['suspended', 'archived'].includes(status)) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
|
message: `This organization has been ${status}. Please contact your administrator.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
req.tenantSchema = orgInfo.schemaName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.tenantSchema = decoded.orgSchema;
|
||||||
req.orgId = decoded.orgId;
|
req.orgId = decoded.orgId;
|
||||||
req.userId = decoded.sub;
|
req.userId = decoded.sub;
|
||||||
req.userRole = decoded.role;
|
req.userRole = decoded.role;
|
||||||
} else if (decoded?.sub) {
|
|
||||||
// Superadmin or user without org — still set userId
|
|
||||||
req.userId = decoded.sub;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Token invalid or expired - let Passport handle the auth error
|
// Token invalid or expired - let Passport handle the auth error
|
||||||
@@ -57,23 +55,19 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
|
private async getOrgStatus(orgId: string): Promise<string | null> {
|
||||||
const cached = this.orgCache.get(orgId);
|
const cached = this.orgStatusCache.get(orgId);
|
||||||
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||||
return { status: cached.status, schemaName: cached.schemaName };
|
return cached.status;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.dataSource.query(
|
const result = await this.dataSource.query(
|
||||||
`SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
|
`SELECT status FROM shared.organizations WHERE id = $1`,
|
||||||
[orgId],
|
[orgId],
|
||||||
);
|
);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
this.orgCache.set(orgId, {
|
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
||||||
status: result[0].status,
|
return result[0].status;
|
||||||
schemaName: result[0].schemaName,
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
});
|
|
||||||
return { status: result[0].status, schemaName: result[0].schemaName };
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — don't block requests on cache miss errors
|
// Non-critical — don't block requests on cache miss errors
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as os from 'node:os';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import helmet from 'helmet';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||||
@@ -42,24 +41,6 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
// 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)
|
// Request logging — only in development (too noisy / slow for prod)
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
app.use((req: any, _res: any, next: any) => {
|
app.use((req: any, _res: any, next: any) => {
|
||||||
@@ -82,17 +63,15 @@ async function bootstrap() {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Swagger docs — disabled in production to avoid exposing API surface
|
// Swagger docs — available in all environments
|
||||||
if (!isProduction) {
|
const config = new DocumentBuilder()
|
||||||
const config = new DocumentBuilder()
|
.setTitle('HOA LedgerIQ API')
|
||||||
.setTitle('HOA LedgerIQ API')
|
.setDescription('API for the HOA LedgerIQ')
|
||||||
.setDescription('API for the HOA LedgerIQ')
|
.setVersion('2026.3.2')
|
||||||
.setVersion('2026.3.11')
|
.addBearerAuth()
|
||||||
.addBearerAuth()
|
.build();
|
||||||
.build();
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
|
||||||
}
|
|
||||||
|
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -24,14 +23,12 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
||||||
async register(@Body() dto: RegisterDto) {
|
async register(@Body() dto: RegisterDto) {
|
||||||
return this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
||||||
@UseGuards(AuthGuard('local'))
|
@UseGuards(AuthGuard('local'))
|
||||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export class AuthService {
|
|||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
orgId: membership.organizationId,
|
orgId: membership.organizationId,
|
||||||
|
orgSchema: membership.organization.schemaName,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ export class AuthService {
|
|||||||
|
|
||||||
if (defaultOrg) {
|
if (defaultOrg) {
|
||||||
payload.orgId = defaultOrg.organizationId;
|
payload.orgId = defaultOrg.organizationId;
|
||||||
|
payload.orgSchema = defaultOrg.organization?.schemaName;
|
||||||
payload.role = defaultOrg.role;
|
payload.role = defaultOrg.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +195,7 @@ export class AuthService {
|
|||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
name: uo.organization?.name,
|
name: uo.organization?.name,
|
||||||
|
schemaName: uo.organization?.schemaName,
|
||||||
status: uo.organization?.status,
|
status: uo.organization?.status,
|
||||||
role: uo.role,
|
role: uo.role,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
orgId: payload.orgId,
|
orgId: payload.orgId,
|
||||||
|
orgSchema: payload.orgSchema,
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
isSuperadmin: payload.isSuperadmin || false,
|
isSuperadmin: payload.isSuperadmin || false,
|
||||||
impersonatedBy: payload.impersonatedBy || null,
|
impersonatedBy: payload.impersonatedBy || null,
|
||||||
|
|||||||
@@ -1,546 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
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],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, UseGuards, Req, Logger } from '@nestjs/common';
|
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
@@ -9,68 +9,42 @@ import { HealthScoresService } from './health-scores.service';
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class HealthScoresController {
|
export class HealthScoresController {
|
||||||
private readonly logger = new Logger(HealthScoresController.name);
|
|
||||||
|
|
||||||
constructor(private service: HealthScoresService) {}
|
constructor(private service: HealthScoresService) {}
|
||||||
|
|
||||||
@Get('latest')
|
@Get('latest')
|
||||||
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||||
getLatest(@Req() req: any) {
|
getLatest(@Req() req: any) {
|
||||||
const schema = req.tenantSchema;
|
const schema = req.user?.orgSchema;
|
||||||
return this.service.getLatestScores(schema);
|
return this.service.getLatestScores(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('calculate')
|
@Post('calculate')
|
||||||
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
|
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculate(@Req() req: any) {
|
async calculate(@Req() req: any) {
|
||||||
const schema = req.tenantSchema;
|
const schema = req.user?.orgSchema;
|
||||||
|
const [operating, reserve] = await Promise.all([
|
||||||
// Fire-and-forget — background processing saves results to DB
|
|
||||||
Promise.all([
|
|
||||||
this.service.calculateScore(schema, 'operating'),
|
this.service.calculateScore(schema, 'operating'),
|
||||||
this.service.calculateScore(schema, 'reserve'),
|
this.service.calculateScore(schema, 'reserve'),
|
||||||
]).catch((err) => {
|
]);
|
||||||
this.logger.error(`Background health score calculation failed: ${err.message}`);
|
return { operating, reserve };
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'processing',
|
|
||||||
message: 'Health score calculations started. Results will appear when ready.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('calculate/operating')
|
@Post('calculate/operating')
|
||||||
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
|
@ApiOperation({ summary: 'Recalculate operating fund health score only' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculateOperating(@Req() req: any) {
|
async calculateOperating(@Req() req: any) {
|
||||||
const schema = req.tenantSchema;
|
const schema = req.user?.orgSchema;
|
||||||
|
const operating = await this.service.calculateScore(schema, 'operating');
|
||||||
// Fire-and-forget
|
return { operating };
|
||||||
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')
|
@Post('calculate/reserve')
|
||||||
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
|
@ApiOperation({ summary: 'Recalculate reserve fund health score only' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculateReserve(@Req() req: any) {
|
async calculateReserve(@Req() req: any) {
|
||||||
const schema = req.tenantSchema;
|
const schema = req.user?.orgSchema;
|
||||||
|
const reserve = await this.service.calculateScore(schema, 'reserve');
|
||||||
// Fire-and-forget
|
return { reserve };
|
||||||
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.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,12 +220,12 @@ export class HealthScoresService {
|
|||||||
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
|
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should have reserve-funded projects with estimated costs (warn but don't block)
|
// Should have capital projects (warn but don't block)
|
||||||
const projects = await qr.query(
|
const projects = await qr.query(
|
||||||
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true AND fund_source = 'reserve'`,
|
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true`,
|
||||||
);
|
);
|
||||||
if (parseInt(projects[0].cnt) === 0) {
|
if (parseInt(projects[0].cnt) === 0) {
|
||||||
missing.push('No reserve-funded projects found. Add projects with estimated costs for an accurate funded-ratio calculation.');
|
missing.push('No capital projects found. Add planned capital projects for a more accurate reserve health assessment.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,12 +558,10 @@ export class HealthScoresService {
|
|||||||
FROM reserve_components
|
FROM reserve_components
|
||||||
ORDER BY remaining_life_years ASC NULLS LAST
|
ORDER BY remaining_life_years ASC NULLS LAST
|
||||||
`),
|
`),
|
||||||
// Capital projects (include component-level fields for funded ratio when reserve_components is empty)
|
// Capital projects
|
||||||
qr.query(`
|
qr.query(`
|
||||||
SELECT name, estimated_cost, actual_cost, target_year, target_month, fund_source,
|
SELECT name, estimated_cost, target_year, target_month, fund_source,
|
||||||
status, priority, current_fund_balance, funded_percentage,
|
status, priority, current_fund_balance, funded_percentage
|
||||||
category, useful_life_years, remaining_life_years, condition_rating,
|
|
||||||
annual_contribution
|
|
||||||
FROM projects
|
FROM projects
|
||||||
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
||||||
ORDER BY target_year, target_month NULLS LAST
|
ORDER BY target_year, target_month NULLS LAST
|
||||||
@@ -598,19 +596,11 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
const totalReserveFund = reserveCash + totalInvestments;
|
const totalReserveFund = reserveCash + totalInvestments;
|
||||||
|
|
||||||
// Use reserve_components for funded ratio when available; fall back to
|
const totalReplacementCost = reserveComponents
|
||||||
// reserve-funded projects (which carry the same estimated_cost / lifecycle
|
.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0);
|
||||||
// fields that users actually populate on the Projects page).
|
|
||||||
const reserveProjects = projects.filter((p: any) => p.fund_source === 'reserve');
|
|
||||||
const useComponentsTable = reserveComponents.length > 0;
|
|
||||||
|
|
||||||
const totalReplacementCost = useComponentsTable
|
const totalComponentFunded = reserveComponents
|
||||||
? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0)
|
.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0);
|
||||||
: reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.estimated_cost || '0'), 0);
|
|
||||||
|
|
||||||
const totalComponentFunded = useComponentsTable
|
|
||||||
? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0)
|
|
||||||
: reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.current_fund_balance || '0'), 0);
|
|
||||||
|
|
||||||
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
|
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
|
||||||
|
|
||||||
@@ -625,14 +615,10 @@ export class HealthScoresService {
|
|||||||
.filter((b: any) => b.account_type === 'expense')
|
.filter((b: any) => b.account_type === 'expense')
|
||||||
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
||||||
|
|
||||||
// Components needing replacement within 5 years — use whichever source has data
|
// Components needing replacement within 5 years
|
||||||
const urgentComponents = useComponentsTable
|
const urgentComponents = reserveComponents.filter(
|
||||||
? reserveComponents.filter(
|
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
||||||
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
);
|
||||||
)
|
|
||||||
: reserveProjects.filter(
|
|
||||||
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Build 12-month forward reserve cash flow projection ──
|
// ── Build 12-month forward reserve cash flow projection ──
|
||||||
|
|
||||||
@@ -763,7 +749,6 @@ export class HealthScoresService {
|
|||||||
accounts,
|
accounts,
|
||||||
investments,
|
investments,
|
||||||
reserveComponents,
|
reserveComponents,
|
||||||
reserveProjects,
|
|
||||||
projects,
|
projects,
|
||||||
budgets,
|
budgets,
|
||||||
assessments,
|
assessments,
|
||||||
@@ -974,15 +959,13 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
`- ${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');
|
).join('\n');
|
||||||
|
|
||||||
// Build component lines from reserve_components if available, otherwise from reserve-funded projects
|
const componentLines = data.reserveComponents.length === 0
|
||||||
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
|
? 'No reserve components tracked.'
|
||||||
const componentLines = componentSource.length === 0
|
: data.reserveComponents.map((c: any) => {
|
||||||
? 'No reserve components or reserve projects tracked.'
|
const cost = parseFloat(c.replacement_cost || '0');
|
||||||
: componentSource.map((c: any) => {
|
|
||||||
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(c.current_fund_balance || '0');
|
||||||
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
||||||
return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
|
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');
|
}).join('\n');
|
||||||
|
|
||||||
const projectLines = data.projects.length === 0
|
const projectLines = data.projects.length === 0
|
||||||
@@ -998,7 +981,7 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
const urgentLines = data.urgentComponents.length === 0
|
const urgentLines = data.urgentComponents.length === 0
|
||||||
? 'None — no components due within 5 years.'
|
? 'None — no components due within 5 years.'
|
||||||
: data.urgentComponents.map((c: any) => {
|
: data.urgentComponents.map((c: any) => {
|
||||||
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
const cost = parseFloat(c.replacement_cost || '0');
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(c.current_fund_balance || '0');
|
||||||
const gap = cost - funded;
|
const gap = cost - funded;
|
||||||
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
|
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
|
||||||
@@ -1014,8 +997,8 @@ Reserve Cash (bank accounts): $${data.reserveCash.toFixed(2)}
|
|||||||
Reserve Investments: $${data.totalInvestments.toFixed(2)}
|
Reserve Investments: $${data.totalInvestments.toFixed(2)}
|
||||||
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
|
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
|
||||||
|
|
||||||
Total Replacement Cost (all components): ${data.totalReplacementCost > 0 ? '$' + data.totalReplacementCost.toFixed(2) : '$0.00 (no reserve components entered — funded ratio cannot be calculated)'}
|
Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)}
|
||||||
Percent Funded: ${data.totalReplacementCost > 0 ? data.percentFunded.toFixed(1) + '%' : 'N/A — no reserve components with replacement costs have been entered. Do NOT report a 0% funded ratio; instead note that funded ratio is unavailable due to missing component data.'}
|
Percent Funded: ${data.percentFunded.toFixed(1)}%
|
||||||
|
|
||||||
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
|
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
|
||||||
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
|
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
|
||||||
@@ -1132,7 +1115,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
timeout: 600000, // 10 minute timeout
|
timeout: 120000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -1146,7 +1129,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
|
|||||||
req.on('error', (err) => reject(err));
|
req.on('error', (err) => reject(err));
|
||||||
req.on('timeout', () => {
|
req.on('timeout', () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error('Request timed out after 600s'));
|
reject(new Error('Request timed out after 120s'));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyString);
|
req.write(bodyString);
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export class InvestmentPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
triggerRecommendations(@Req() req: any) {
|
getRecommendations(@Req() req: any) {
|
||||||
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,15 +38,6 @@ export interface MarketRate {
|
|||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecommendationComponent {
|
|
||||||
label: string;
|
|
||||||
amount: number;
|
|
||||||
term_months: number;
|
|
||||||
rate: number;
|
|
||||||
bank_name?: string;
|
|
||||||
investment_type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Recommendation {
|
export interface Recommendation {
|
||||||
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
||||||
priority: 'high' | 'medium' | 'low';
|
priority: 'high' | 'medium' | 'low';
|
||||||
@@ -59,7 +50,6 @@ export interface Recommendation {
|
|||||||
suggested_rate?: number;
|
suggested_rate?: number;
|
||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
components?: RecommendationComponent[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIResponse {
|
export interface AIResponse {
|
||||||
@@ -75,9 +65,6 @@ export interface SavedRecommendation {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
response_time_ms: number;
|
response_time_ms: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
status: 'processing' | 'complete' | 'error';
|
|
||||||
last_failed: boolean;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -209,33 +196,14 @@ export class InvestmentPlanningService {
|
|||||||
return rates.cd;
|
return rates.cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the status/error_message columns exist (for tenants created before this migration).
|
|
||||||
*/
|
|
||||||
private async ensureStatusColumn(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.tenant.query(
|
|
||||||
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'complete'`,
|
|
||||||
);
|
|
||||||
await this.tenant.query(
|
|
||||||
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS error_message TEXT`,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Ignore — column may already exist or table may not exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the latest saved AI recommendation for this tenant.
|
* Get the latest saved AI recommendation for this tenant.
|
||||||
* Returns status and last_failed flag for UI state management.
|
|
||||||
*/
|
*/
|
||||||
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
||||||
try {
|
try {
|
||||||
await this.ensureStatusColumn();
|
|
||||||
|
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
||||||
response_time_ms, status, error_message, created_at
|
response_time_ms, created_at
|
||||||
FROM ai_recommendations
|
FROM ai_recommendations
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
@@ -244,64 +212,6 @@ export class InvestmentPlanningService {
|
|||||||
if (!rows || rows.length === 0) return null;
|
if (!rows || rows.length === 0) return null;
|
||||||
|
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const status = row.status || 'complete';
|
|
||||||
|
|
||||||
// If still processing, return processing status
|
|
||||||
if (status === 'processing') {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
recommendations: [],
|
|
||||||
overall_assessment: '',
|
|
||||||
risk_notes: [],
|
|
||||||
response_time_ms: 0,
|
|
||||||
created_at: row.created_at,
|
|
||||||
status: 'processing',
|
|
||||||
last_failed: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If latest attempt failed, return the last successful result with last_failed flag
|
|
||||||
if (status === 'error') {
|
|
||||||
const lastGood = await this.tenant.query(
|
|
||||||
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
|
||||||
response_time_ms, created_at
|
|
||||||
FROM ai_recommendations
|
|
||||||
WHERE status = 'complete'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lastGood?.length) {
|
|
||||||
const goodRow = lastGood[0];
|
|
||||||
const recData = goodRow.recommendations_json || {};
|
|
||||||
return {
|
|
||||||
id: goodRow.id,
|
|
||||||
recommendations: recData.recommendations || [],
|
|
||||||
overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '',
|
|
||||||
risk_notes: goodRow.risk_notes || recData.risk_notes || [],
|
|
||||||
response_time_ms: goodRow.response_time_ms || 0,
|
|
||||||
created_at: goodRow.created_at,
|
|
||||||
status: 'complete',
|
|
||||||
last_failed: true,
|
|
||||||
error_message: row.error_message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// No previous good result — return error state
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
recommendations: [],
|
|
||||||
overall_assessment: row.error_message || 'AI analysis failed. Please try again.',
|
|
||||||
risk_notes: [],
|
|
||||||
response_time_ms: 0,
|
|
||||||
created_at: row.created_at,
|
|
||||||
status: 'error',
|
|
||||||
last_failed: true,
|
|
||||||
error_message: row.error_message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete — return the data normally
|
|
||||||
const recData = row.recommendations_json || {};
|
const recData = row.recommendations_json || {};
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -310,8 +220,6 @@ export class InvestmentPlanningService {
|
|||||||
risk_notes: row.risk_notes || recData.risk_notes || [],
|
risk_notes: row.risk_notes || recData.risk_notes || [],
|
||||||
response_time_ms: row.response_time_ms || 0,
|
response_time_ms: row.response_time_ms || 0,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
status: 'complete',
|
|
||||||
last_failed: false,
|
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Table might not exist yet (pre-migration tenants)
|
// Table might not exist yet (pre-migration tenants)
|
||||||
@@ -320,153 +228,15 @@ export class InvestmentPlanningService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a 'processing' placeholder record and return its ID.
|
|
||||||
*/
|
|
||||||
private async saveProcessingRecord(userId?: string): Promise<string> {
|
|
||||||
await this.ensureStatusColumn();
|
|
||||||
const rows = await this.tenant.query(
|
|
||||||
`INSERT INTO ai_recommendations
|
|
||||||
(recommendations_json, overall_assessment, risk_notes, requested_by, status)
|
|
||||||
VALUES ('{}', '', '[]', $1, 'processing')
|
|
||||||
RETURNING id`,
|
|
||||||
[userId || null],
|
|
||||||
);
|
|
||||||
return rows[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a processing record with completed results.
|
|
||||||
*/
|
|
||||||
private async updateRecommendationComplete(
|
|
||||||
jobId: string,
|
|
||||||
aiResponse: AIResponse,
|
|
||||||
userId: string | undefined,
|
|
||||||
elapsed: number,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.tenant.query(
|
|
||||||
`UPDATE ai_recommendations
|
|
||||||
SET recommendations_json = $1,
|
|
||||||
overall_assessment = $2,
|
|
||||||
risk_notes = $3,
|
|
||||||
response_time_ms = $4,
|
|
||||||
status = 'complete'
|
|
||||||
WHERE id = $5`,
|
|
||||||
[
|
|
||||||
JSON.stringify(aiResponse),
|
|
||||||
aiResponse.overall_assessment || '',
|
|
||||||
JSON.stringify(aiResponse.risk_notes || []),
|
|
||||||
elapsed,
|
|
||||||
jobId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.warn(`Could not update recommendation ${jobId}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a processing record with error status.
|
|
||||||
*/
|
|
||||||
private async updateRecommendationError(jobId: string, errorMessage: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.tenant.query(
|
|
||||||
`UPDATE ai_recommendations
|
|
||||||
SET status = 'error',
|
|
||||||
error_message = $1
|
|
||||||
WHERE id = $2`,
|
|
||||||
[errorMessage, jobId],
|
|
||||||
);
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.warn(`Could not update recommendation error ${jobId}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger AI recommendations asynchronously.
|
|
||||||
* Saves a 'processing' record, starts the AI work in the background, and returns immediately.
|
|
||||||
* The TenantService instance remains alive via closure reference for the duration of the background work.
|
|
||||||
*/
|
|
||||||
async triggerAIRecommendations(userId?: string, orgId?: string): Promise<{ status: string; message: string }> {
|
|
||||||
const jobId = await this.saveProcessingRecord(userId);
|
|
||||||
this.logger.log(`AI recommendation triggered (job ${jobId}), starting background processing...`);
|
|
||||||
|
|
||||||
// Fire-and-forget — the Promise keeps this service instance (and TenantService) alive
|
|
||||||
this.runBackgroundRecommendations(jobId, userId, orgId).catch((err) => {
|
|
||||||
this.logger.error(`Background AI recommendation failed (job ${jobId}): ${err.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'processing',
|
|
||||||
message: 'AI analysis has been started. You can navigate away safely — results will appear when ready.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the full AI recommendation pipeline in the background.
|
|
||||||
*/
|
|
||||||
private async runBackgroundRecommendations(jobId: string, userId?: string, orgId?: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
const [snapshot, allRates, monthlyForecast] = await Promise.all([
|
|
||||||
this.getFinancialSnapshot(),
|
|
||||||
this.getMarketRates(),
|
|
||||||
this.getMonthlyForecast(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.debug('background_snapshot_summary', {
|
|
||||||
job_id: jobId,
|
|
||||||
operating_cash: snapshot.summary.operating_cash,
|
|
||||||
reserve_cash: snapshot.summary.reserve_cash,
|
|
||||||
total_all: snapshot.summary.total_all,
|
|
||||||
investment_accounts: snapshot.investment_accounts.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
|
|
||||||
const aiResponse = await this.callAI(messages);
|
|
||||||
const elapsed = Date.now() - startTime;
|
|
||||||
|
|
||||||
this.debug('background_final_response', {
|
|
||||||
job_id: jobId,
|
|
||||||
recommendation_count: aiResponse.recommendations.length,
|
|
||||||
has_assessment: !!aiResponse.overall_assessment,
|
|
||||||
elapsed_ms: elapsed,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if the AI returned a graceful error (empty recommendations with error message)
|
|
||||||
const isGracefulError = aiResponse.recommendations.length === 0 &&
|
|
||||||
(aiResponse.overall_assessment?.includes('Unable to generate') ||
|
|
||||||
aiResponse.overall_assessment?.includes('invalid response'));
|
|
||||||
|
|
||||||
if (isGracefulError) {
|
|
||||||
await this.updateRecommendationError(jobId, aiResponse.overall_assessment);
|
|
||||||
} else {
|
|
||||||
await this.updateRecommendationComplete(jobId, aiResponse, userId, elapsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log AI usage (fire-and-forget)
|
|
||||||
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
|
|
||||||
|
|
||||||
this.logger.log(`Background AI recommendation completed (job ${jobId}) in ${elapsed}ms`);
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error(`Background AI recommendation error (job ${jobId}): ${err.message}`);
|
|
||||||
await this.updateRecommendationError(jobId, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save AI recommendation result to tenant schema.
|
* Save AI recommendation result to tenant schema.
|
||||||
* @deprecated Use triggerAIRecommendations() for async flow instead
|
|
||||||
*/
|
*/
|
||||||
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
|
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.ensureStatusColumn();
|
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO ai_recommendations
|
`INSERT INTO ai_recommendations
|
||||||
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
|
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'complete')`,
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
[
|
[
|
||||||
JSON.stringify(aiResponse),
|
JSON.stringify(aiResponse),
|
||||||
aiResponse.overall_assessment || '',
|
aiResponse.overall_assessment || '',
|
||||||
@@ -914,28 +684,13 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
|
|||||||
"suggested_term": "12 months",
|
"suggested_term": "12 months",
|
||||||
"suggested_rate": 4.50,
|
"suggested_rate": 4.50,
|
||||||
"bank_name": "Bank name from market rates (if applicable)",
|
"bank_name": "Bank name from market rates (if applicable)",
|
||||||
"rationale": "Financial reasoning for why this makes sense",
|
"rationale": "Financial reasoning for why this makes sense"
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"label": "Component label (e.g. '6-Month CD at Marcus')",
|
|
||||||
"amount": 6600.00,
|
|
||||||
"term_months": 6,
|
|
||||||
"rate": 4.05,
|
|
||||||
"bank_name": "Marcus",
|
|
||||||
"investment_type": "cd"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
|
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
|
||||||
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
||||||
}
|
}
|
||||||
|
|
||||||
IMPORTANT ABOUT COMPONENTS:
|
|
||||||
- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts.
|
|
||||||
- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array.
|
|
||||||
- For simple single-investment recommendations, omit the "components" field entirely.
|
|
||||||
|
|
||||||
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
|
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
|
||||||
|
|
||||||
// Build the data context for the user prompt
|
// Build the data context for the user prompt
|
||||||
@@ -1118,7 +873,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
timeout: 600000, // 10 minute timeout
|
timeout: 300000, // 5 minute timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -1132,7 +887,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
req.on('error', (err) => reject(err));
|
req.on('error', (err) => reject(err));
|
||||||
req.on('timeout', () => {
|
req.on('timeout', () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error(`Request timed out after 600s`));
|
reject(new Error(`Request timed out after 300s`));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyString);
|
req.write(bodyString);
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ export class JournalEntriesService {
|
|||||||
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT je.*,
|
SELECT je.*,
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
|
||||||
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
|
|
||||||
ELSE COALESCE(SUM(jel.debit), 0)
|
|
||||||
END as total_debit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
|
||||||
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
|
|
||||||
ELSE COALESCE(SUM(jel.credit), 0)
|
|
||||||
END as total_credit,
|
|
||||||
json_agg(json_build_object(
|
json_agg(json_build_object(
|
||||||
'id', jel.id, 'account_id', jel.account_id,
|
'id', jel.id, 'account_id', jel.account_id,
|
||||||
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
||||||
|
|||||||
@@ -153,14 +153,6 @@ export class OrganizationsService {
|
|||||||
existing.role = data.role;
|
existing.role = data.role;
|
||||||
return this.userOrgRepository.save(existing);
|
return this.userOrgRepository.save(existing);
|
||||||
}
|
}
|
||||||
// Update password for existing user being added to a new org
|
|
||||||
if (data.password) {
|
|
||||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
|
||||||
await dataSource.query(
|
|
||||||
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
|
|
||||||
[passwordHash, userId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Create new user
|
// Create new user
|
||||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
|||||||
@@ -716,38 +716,14 @@ export class ReportsService {
|
|||||||
`);
|
`);
|
||||||
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
|
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
|
||||||
|
|
||||||
// Interest earned YTD: actual interest income from journal entries for current year
|
// Interest earned YTD: approximate from current_value - principal (unrealized gains)
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const interestEarned = await this.tenant.query(`
|
const interestEarned = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
|
SELECT COALESCE(SUM(current_value - principal), 0) as total
|
||||||
FROM accounts a
|
FROM investment_accounts WHERE is_active = true AND current_value > principal
|
||||||
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
|
// Planned capital spend for current year
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
const capitalSpend = await this.tenant.query(`
|
const capitalSpend = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||||
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
||||||
@@ -773,9 +749,7 @@ export class ReportsService {
|
|||||||
operating_investments: operatingInvestments.toFixed(2),
|
operating_investments: operatingInvestments.toFixed(2),
|
||||||
reserve_investments: reserveInvestments.toFixed(2),
|
reserve_investments: reserveInvestments.toFixed(2),
|
||||||
est_monthly_interest: estMonthlyInterest.toFixed(2),
|
est_monthly_interest: estMonthlyInterest.toFixed(2),
|
||||||
interest_earned_ytd: ytdInterest.toFixed(2),
|
interest_earned_ytd: interestEarned[0]?.total || '0.00',
|
||||||
interest_last_year: parseFloat(interestLastYear[0]?.total || '0').toFixed(2),
|
|
||||||
interest_projected: projectedInterest.toFixed(2),
|
|
||||||
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -864,37 +838,15 @@ export class ReportsService {
|
|||||||
// We need budgets for startYear and startYear+1 to cover 24 months
|
// We need budgets for startYear and startYear+1 to cover 24 months
|
||||||
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||||
|
|
||||||
const endYear = startYear + Math.ceil(months / 12) + 1;
|
for (const yr of [startYear, startYear + 1, startYear + 2]) {
|
||||||
for (let yr = startYear; yr <= endYear; yr++) {
|
const budgetRows = await this.tenant.query(
|
||||||
let budgetRows: any[];
|
`SELECT b.fund_type, a.account_type,
|
||||||
try {
|
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
|
||||||
budgetRows = await this.tenant.query(
|
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||||
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
|
FROM budgets b
|
||||||
SELECT b.account_id, b.fund_type, a.account_type,
|
JOIN accounts a ON a.id = b.account_id
|
||||||
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,
|
WHERE b.fiscal_year = $1`, [yr],
|
||||||
1 as source_priority
|
);
|
||||||
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
|
|
||||||
UNION ALL
|
|
||||||
SELECT bpl.account_id, bpl.fund_type, a.account_type,
|
|
||||||
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt,
|
|
||||||
2 as source_priority
|
|
||||||
FROM budget_plan_lines bpl
|
|
||||||
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
|
|
||||||
JOIN accounts a ON a.id = bpl.account_id
|
|
||||||
WHERE bp.fiscal_year = $1
|
|
||||||
) combined
|
|
||||||
ORDER BY account_id, fund_type, source_priority`, [yr],
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
budgetRows = await this.tenant.query(
|
|
||||||
`SELECT b.fund_type, a.account_type,
|
|
||||||
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
|
|
||||||
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
|
||||||
FROM budgets b
|
|
||||||
JOIN accounts a ON a.id = b.account_id
|
|
||||||
WHERE b.fiscal_year = $1`, [yr],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (let m = 0; m < 12; m++) {
|
for (let m = 0; m < 12; m++) {
|
||||||
const key = `${yr}-${m + 1}`;
|
const key = `${yr}-${m + 1}`;
|
||||||
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
-- 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 $$;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
-- 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$;
|
|
||||||
@@ -9,34 +9,5 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<script>
|
|
||||||
(function(d,t) {
|
|
||||||
var BASE_URL="https://chat.hoaledgeriq.com";
|
|
||||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
|
||||||
g.src=BASE_URL+"/packs/js/sdk.js";
|
|
||||||
g.async=true;
|
|
||||||
s.parentNode.insertBefore(g,s);
|
|
||||||
g.onload=function(){
|
|
||||||
window.chatwootSDK.run({
|
|
||||||
websiteToken:'K6VXvTtKXvaCMvre4yK85SPb',
|
|
||||||
baseUrl:BASE_URL
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})(document,"script");
|
|
||||||
window.addEventListener('chatwoot:ready', function() {
|
|
||||||
try {
|
|
||||||
var raw = localStorage.getItem('ledgeriq-auth');
|
|
||||||
if (!raw) return;
|
|
||||||
var auth = JSON.parse(raw);
|
|
||||||
var user = auth && auth.state && auth.state.user;
|
|
||||||
if (user && window.$chatwoot) {
|
|
||||||
window.$chatwoot.setUser(user.id, {
|
|
||||||
name: (user.firstName || '') + ' ' + (user.lastName || ''),
|
|
||||||
email: user.email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.03.10",
|
"version": "2026.3.2-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.03.10",
|
"version": "2026.3.2-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.03.16",
|
"version": "2026.3.2-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroups
|
|||||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||||
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||||
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
||||||
import { InvestmentScenariosPage } from './pages/board-planning/InvestmentScenariosPage';
|
|
||||||
import { InvestmentScenarioDetailPage } from './pages/board-planning/InvestmentScenarioDetailPage';
|
|
||||||
import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenariosPage';
|
|
||||||
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
|
|
||||||
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
|
||||||
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -143,12 +137,6 @@ export function App() {
|
|||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||||
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
|
|
||||||
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
|
||||||
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
|
||||||
<Route path="board-planning/assessments" element={<AssessmentScenariosPage />} />
|
|
||||||
<Route path="board-planning/assessments/:id" element={<AssessmentScenarioDetailPage />} />
|
|
||||||
<Route path="board-planning/compare" element={<ScenarioComparisonPage />} />
|
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
|
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconLogout,
|
IconLogout,
|
||||||
@@ -9,21 +9,17 @@ import {
|
|||||||
IconUserCog,
|
IconUserCog,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconSun,
|
|
||||||
IconMoon,
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { AppTour } from '../onboarding/AppTour';
|
import { AppTour } from '../onboarding/AppTour';
|
||||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
import logoSrc from '../../assets/logo.png';
|
import logoSrc from '../../assets/logo.svg';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [opened, { toggle, close }] = useDisclosure();
|
const [opened, { toggle, close }] = useDisclosure();
|
||||||
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||||
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
@@ -106,31 +102,12 @@ export function AppLayout() {
|
|||||||
<Group h={60} px="md" justify="space-between">
|
<Group h={60} px="md" justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||||
<img
|
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
|
||||||
src={logoSrc}
|
|
||||||
alt="HOA LedgerIQ"
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
...(colorScheme === 'dark' ? {
|
|
||||||
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
|
||||||
} : {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
{currentOrg && (
|
{currentOrg && (
|
||||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||||
)}
|
)}
|
||||||
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
onClick={toggleColorScheme}
|
|
||||||
aria-label="Toggle color scheme"
|
|
||||||
>
|
|
||||||
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Menu shadow="md" width={220}>
|
<Menu shadow="md" width={220}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ import {
|
|||||||
IconClipboardCheck,
|
IconClipboardCheck,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHeartRateMonitor,
|
IconHeartRateMonitor,
|
||||||
IconCalculator,
|
|
||||||
IconGitCompare,
|
|
||||||
IconScale,
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -55,30 +52,11 @@ const navSections = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Planning',
|
label: 'Planning',
|
||||||
items: [
|
|
||||||
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
|
|
||||||
{
|
|
||||||
label: 'Projects', icon: IconShieldCheck, path: '/projects',
|
|
||||||
children: [
|
|
||||||
{ label: 'Capital Planning', path: '/capital-projects' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning',
|
|
||||||
children: [
|
|
||||||
{ label: 'Investment Scenarios', path: '/board-planning/investments' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Board Reference',
|
|
||||||
items: [
|
items: [
|
||||||
|
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
||||||
|
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
||||||
|
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -163,8 +141,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{section.items.map((item: any) =>
|
{section.items.map((item: any) =>
|
||||||
item.children && !item.path ? (
|
item.children ? (
|
||||||
// Collapsible group without a parent route (e.g. Reports)
|
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.label}
|
key={item.label}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
@@ -183,29 +160,6 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
) : item.children && item.path ? (
|
|
||||||
// Parent with its own route + nested children (e.g. Projects > Capital Planning)
|
|
||||||
<NavLink
|
|
||||||
key={item.path}
|
|
||||||
label={item.label}
|
|
||||||
leftSection={<item.icon size={18} />}
|
|
||||||
defaultOpened={
|
|
||||||
location.pathname === item.path ||
|
|
||||||
item.children.some((c: any) => location.pathname.startsWith(c.path))
|
|
||||||
}
|
|
||||||
data-tour={item.tourId || undefined}
|
|
||||||
active={location.pathname === item.path}
|
|
||||||
onClick={() => go(item.path!)}
|
|
||||||
>
|
|
||||||
{item.children.map((child: any) => (
|
|
||||||
<NavLink
|
|
||||||
key={child.path}
|
|
||||||
label={child.label}
|
|
||||||
active={location.pathname === child.path}
|
|
||||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); go(child.path); }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</NavLink>
|
|
||||||
) : (
|
) : (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import '@mantine/dates/styles.css';
|
|||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { theme } from './theme/theme';
|
import { theme } from './theme/theme';
|
||||||
import { usePreferencesStore } from './stores/preferencesStore';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -22,11 +21,9 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function Root() {
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
<React.StrictMode>
|
||||||
|
<MantineProvider theme={theme}>
|
||||||
return (
|
|
||||||
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
|
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -36,11 +33,5 @@ function Root() {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<Root />
|
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,15 +16,13 @@ import { IconAlertCircle } from '@tabler/icons-react';
|
|||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import logoSrc from '../../assets/logo.svg';
|
||||||
import logoSrc from '../../assets/logo.png';
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: { email: '', password: '' },
|
initialValues: { email: '', password: '' },
|
||||||
@@ -59,16 +57,7 @@ export function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<Container size={420} my={80}>
|
<Container size={420} my={80}>
|
||||||
<Center>
|
<Center>
|
||||||
<img
|
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 60 }} />
|
||||||
src={logoSrc}
|
|
||||||
alt="HOA LedgerIQ"
|
|
||||||
style={{
|
|
||||||
height: 60,
|
|
||||||
...(isDark ? {
|
|
||||||
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
|
||||||
} : {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Center>
|
</Center>
|
||||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export function SelectOrgPage() {
|
|||||||
<Text fw={500}>{org.name}</Text>
|
<Text fw={500}>{org.name}</Text>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Badge size="sm" variant="light">{org.role}</Badge>
|
<Badge size="sm" variant="light">{org.role}</Badge>
|
||||||
|
{org.schemaName && (
|
||||||
|
<Badge size="xs" variant="dot" color="gray">
|
||||||
|
{org.schemaName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,776 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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,15 +1,13 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Badge, Card, Alert,
|
Select, Loader, Center, Badge, Card, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } from '@tabler/icons-react';
|
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -26,6 +24,27 @@ interface BudgetLine {
|
|||||||
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a currency-formatted value: "$48,065.21", "$(13,000.00)", " $- "
|
||||||
|
*/
|
||||||
|
function parseCurrencyValue(val: string): number {
|
||||||
|
if (!val) return 0;
|
||||||
|
let s = val.trim();
|
||||||
|
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
|
||||||
|
|
||||||
|
const isNegative = s.includes('(') && s.includes(')');
|
||||||
|
s = s.replace(/[$,\s()]/g, '');
|
||||||
|
if (!s || s === '-') return 0;
|
||||||
|
|
||||||
|
const num = parseFloat(s);
|
||||||
|
if (isNaN(num)) return 0;
|
||||||
|
return isNegative ? -num : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure all monthly values are numbers (PostgreSQL can return strings for NUMERIC columns)
|
||||||
|
* and compute annual_total as the sum of all monthly values.
|
||||||
|
*/
|
||||||
function hydrateBudgetLine(row: any): BudgetLine {
|
function hydrateBudgetLine(row: any): BudgetLine {
|
||||||
const line: any = { ...row };
|
const line: any = { ...row };
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
@@ -35,64 +54,77 @@ function hydrateBudgetLine(row: any): BudgetLine {
|
|||||||
return line as BudgetLine;
|
return line as BudgetLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.trim().split('\n');
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||||
|
const rows: Record<string, string>[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
// Handle quoted fields containing commas
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let j = 0; j < line.length; j++) {
|
||||||
|
const ch = line[j];
|
||||||
|
if (ch === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
} else if (ch === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((h, idx) => {
|
||||||
|
row[h] = values[idx] || '';
|
||||||
|
});
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
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';
|
|
||||||
|
|
||||||
// Query is the single source of truth for budget data
|
const { isLoading } = useQuery<BudgetLine[]>({
|
||||||
const { data: queryData, isLoading, isFetching } = useQuery<BudgetLine[]>({
|
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/budgets/${year}`);
|
const { data } = await api.get(`/budgets/${year}`);
|
||||||
return (data as any[]).map(hydrateBudgetLine);
|
// Hydrate each line: ensure numbers and compute annual_total
|
||||||
|
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
||||||
|
setBudgetData(hydrated);
|
||||||
|
return hydrated;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use edit data when editing, otherwise use query data
|
|
||||||
const isEditing = editData !== null;
|
|
||||||
const budgetData = isEditing ? editData : (queryData || []);
|
|
||||||
const hasBudget = budgetData.length > 0;
|
|
||||||
const cellsEditable = !isReadOnly && isEditing;
|
|
||||||
|
|
||||||
const handleStartEdit = () => {
|
|
||||||
setEditData(queryData ? [...queryData] : []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
setEditData(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearChange = (v: string | null) => {
|
|
||||||
if (v) {
|
|
||||||
setYear(v);
|
|
||||||
setEditData(null); // Cancel any in-progress edit when switching years
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const payload = budgetData
|
const lines = budgetData
|
||||||
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
||||||
.map((b) => ({
|
.map((b) => ({
|
||||||
accountId: b.account_id,
|
account_id: b.account_id,
|
||||||
fundType: b.fund_type,
|
fund_type: b.fund_type,
|
||||||
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
||||||
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
||||||
sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
|
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
|
||||||
}));
|
}));
|
||||||
return api.put(`/budgets/${year}`, payload);
|
return api.put(`/budgets/${year}`, { lines });
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
setEditData(null);
|
|
||||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -100,22 +132,109 @@ export function BudgetsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCell = (idx: number, month: string, value: number) => {
|
const importMutation = useMutation({
|
||||||
if (!editData) return;
|
mutationFn: async (lines: Record<string, string>[]) => {
|
||||||
const updated = [...editData];
|
const parsed = lines.map((row) => ({
|
||||||
(updated[idx] as any)[month] = value || 0;
|
account_number: row.account_number || row.accountnumber || '',
|
||||||
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
|
account_name: row.account_name || row.accountname || '',
|
||||||
setEditData(updated);
|
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 yearOptions = useMemo(() => Array.from({ length: 5 }, (_, i) => {
|
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];
|
||||||
|
(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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 1 + i;
|
const y = new Date().getFullYear() - 1 + i;
|
||||||
return { value: String(y), label: String(y) };
|
return { value: String(y), label: String(y) };
|
||||||
}), []);
|
});
|
||||||
|
|
||||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||||
|
|
||||||
// Show loader on initial load or when switching years with no cached data
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
||||||
@@ -124,6 +243,7 @@ export function BudgetsPage() {
|
|||||||
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
|
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
|
||||||
const totalOperatingIncome = operatingIncomeLines.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 totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
const totalIncome = totalOperatingIncome + totalReserveIncome;
|
||||||
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,59 +251,42 @@ export function BudgetsPage() {
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Budget Manager</Title>
|
<Title order={2}>Budget Manager</Title>
|
||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={handleYearChange} w={120} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||||
{isFetching && !isLoading && <Loader size="xs" />}
|
<Button
|
||||||
{!isReadOnly && hasBudget && (
|
variant="outline"
|
||||||
<>
|
leftSection={<IconDownload size={16} />}
|
||||||
{!isEditing ? (
|
onClick={handleDownloadTemplate}
|
||||||
<Button
|
>
|
||||||
variant="outline"
|
Download Template
|
||||||
leftSection={<IconPencil size={16} />}
|
</Button>
|
||||||
onClick={handleStartEdit}
|
{!isReadOnly && (<>
|
||||||
>
|
<Button
|
||||||
Edit Budget
|
variant="outline"
|
||||||
</Button>
|
leftSection={<IconUpload size={16} />}
|
||||||
) : (
|
onClick={handleImportCSV}
|
||||||
<>
|
loading={importMutation.isPending}
|
||||||
<Button
|
>
|
||||||
variant="outline"
|
Import CSV
|
||||||
color="gray"
|
</Button>
|
||||||
leftSection={<IconX size={16} />}
|
<input
|
||||||
onClick={handleCancelEdit}
|
type="file"
|
||||||
>
|
ref={fileInputRef}
|
||||||
Cancel
|
style={{ display: 'none' }}
|
||||||
</Button>
|
accept=".csv,.txt"
|
||||||
<Button
|
onChange={handleFileChange}
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
/>
|
||||||
onClick={() => saveMutation.mutate()}
|
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||||
loading={saveMutation.isPending}
|
Save Budget
|
||||||
>
|
</Button>
|
||||||
Save Budget
|
</>)}
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{!hasBudget && !isLoading && (
|
{budgetData.length === 0 && !isLoading && (
|
||||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
<Stack gap="sm">
|
No budget data for {year}. Import a CSV to get started. Your CSV should have columns:{' '}
|
||||||
<Text>No budget data for {year}.</Text>
|
<Text span ff="monospace" size="xs">account_number, account_name, jan, feb, ..., dec</Text>.
|
||||||
<Text size="sm">
|
Accounts will be auto-created if they don't exist yet.
|
||||||
To create or import a budget, use the <Text span fw={600}>Budget Planner</Text> to build,
|
|
||||||
review, and ratify a budget for this year. Once ratified, it will appear here.
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconArrowRight size={16} />}
|
|
||||||
w="fit-content"
|
|
||||||
onClick={() => navigate('/board-planning/budgets')}
|
|
||||||
>
|
|
||||||
Go to Budget Planner
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -214,8 +317,8 @@ export function BudgetsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
||||||
{monthLabels.map((m) => (
|
{monthLabels.map((m) => (
|
||||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||||
))}
|
))}
|
||||||
@@ -226,7 +329,7 @@ export function BudgetsPage() {
|
|||||||
{budgetData.length === 0 && (
|
{budgetData.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={15}>
|
<Table.Td colSpan={15}>
|
||||||
<Text ta="center" c="dimmed" py="lg">No budget data for this year.</Text>
|
<Text ta="center" c="dimmed" py="lg">No budget data. Import a CSV or add income/expense accounts to get started.</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -234,7 +337,7 @@ export function BudgetsPage() {
|
|||||||
const lines = budgetData.filter((b) => b.account_type === type);
|
const lines = budgetData.filter((b) => b.account_type === type);
|
||||||
if (lines.length === 0) return null;
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
|
const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8';
|
||||||
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -265,9 +368,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 0,
|
left: 0,
|
||||||
background: stickyBg,
|
background: 'white',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: `1px solid ${stickyBorder}`,
|
borderRight: '1px solid #e9ecef',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
@@ -276,9 +379,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 120,
|
left: 120,
|
||||||
background: stickyBg,
|
background: 'white',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: `1px solid ${stickyBorder}`,
|
borderRight: '1px solid #e9ecef',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -288,21 +391,16 @@ export function BudgetsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
{months.map((m) => (
|
{months.map((m) => (
|
||||||
<Table.Td key={m} p={2}>
|
<Table.Td key={m} p={2}>
|
||||||
{cellsEditable ? (
|
<NumberInput
|
||||||
<NumberInput
|
value={(line as any)[m] || 0}
|
||||||
value={(line as any)[m] || 0}
|
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
size="xs"
|
||||||
size="xs"
|
hideControls
|
||||||
hideControls
|
decimalScale={2}
|
||||||
decimalScale={2}
|
min={0}
|
||||||
min={0}
|
disabled={isReadOnly}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<Text size="sm" ta="right" ff="monospace">
|
|
||||||
{fmt((line as any)[m] || 0)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
))}
|
))}
|
||||||
<Table.Td ta="right" fw={500} ff="monospace">
|
<Table.Td ta="right" fw={500} ff="monospace">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
||||||
@@ -80,7 +79,6 @@ export function CashFlowForecastPage() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentYear = now.getFullYear();
|
const currentYear = now.getFullYear();
|
||||||
const currentMonth = now.getMonth() + 1;
|
const currentMonth = now.getMonth() + 1;
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
|
|
||||||
// Filter: All, Operating, Reserve
|
// Filter: All, Operating, Reserve
|
||||||
const [fundFilter, setFundFilter] = useState<string>('all');
|
const [fundFilter, setFundFilter] = useState<string>('all');
|
||||||
@@ -420,10 +418,10 @@ export function CashFlowForecastPage() {
|
|||||||
<tr
|
<tr
|
||||||
key={d.month}
|
key={d.month}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`,
|
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||||
backgroundColor: d.is_forecast
|
backgroundColor: d.is_forecast
|
||||||
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)')
|
? 'var(--mantine-color-orange-0)'
|
||||||
: i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : 'var(--mantine-color-gray-0)'),
|
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } 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';
|
||||||
|
|
||||||
@@ -306,8 +306,6 @@ interface DashboardData {
|
|||||||
reserve_investments: string;
|
reserve_investments: string;
|
||||||
est_monthly_interest: string;
|
est_monthly_interest: string;
|
||||||
interest_earned_ytd: string;
|
interest_earned_ytd: string;
|
||||||
interest_last_year: string;
|
|
||||||
interest_projected: string;
|
|
||||||
planned_capital_spend: string;
|
planned_capital_spend: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,9 +313,9 @@ export function DashboardPage() {
|
|||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Track whether a refresh is in progress (per score type) for async polling
|
// Track whether last refresh attempt failed (per score type)
|
||||||
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
|
const [operatingFailed, setOperatingFailed] = useState(false);
|
||||||
const [reserveRefreshing, setReserveRefreshing] = useState(false);
|
const [reserveFailed, setReserveFailed] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<DashboardData>({
|
const { data, isLoading } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
@@ -329,66 +327,33 @@ export function DashboardPage() {
|
|||||||
queryKey: ['health-scores'],
|
queryKey: ['health-scores'],
|
||||||
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||||
enabled: !!currentOrg,
|
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
|
// Separate mutations for each score type
|
||||||
const handleRefreshOperating = useCallback(async () => {
|
const recalcOperatingMutation = useMutation({
|
||||||
const prevId = healthScores?.operating?.id;
|
mutationFn: () => api.post('/health-scores/calculate/operating'),
|
||||||
setOperatingRefreshing(true);
|
onSuccess: () => {
|
||||||
try {
|
setOperatingFailed(false);
|
||||||
await api.post('/health-scores/calculate/operating');
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
} catch {
|
},
|
||||||
// Trigger failed at network level — polling will pick up any backend-saved error
|
onError: () => {
|
||||||
}
|
setOperatingFailed(true);
|
||||||
// Start polling — watch for the health score to change (new id or updated timestamp)
|
// Still refresh to get whatever the backend saved (could be cached data)
|
||||||
const pollUntilDone = () => {
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
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 recalcReserveMutation = useMutation({
|
||||||
const prevId = healthScores?.reserve?.id;
|
mutationFn: () => api.post('/health-scores/calculate/reserve'),
|
||||||
setReserveRefreshing(true);
|
onSuccess: () => {
|
||||||
try {
|
setReserveFailed(false);
|
||||||
await api.post('/health-scores/calculate/reserve');
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
} catch {
|
},
|
||||||
// Trigger failed at network level
|
onError: () => {
|
||||||
}
|
setReserveFailed(true);
|
||||||
const pollUntilDone = () => {
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
const checkInterval = setInterval(async () => {
|
},
|
||||||
try {
|
});
|
||||||
const { data: latest } = await api.get('/health-scores/latest');
|
|
||||||
const newScore = latest?.reserve;
|
|
||||||
if (newScore && newScore.id !== prevId) {
|
|
||||||
setReserveRefreshing(false);
|
|
||||||
queryClient.setQueryData(['health-scores'], latest);
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep polling
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
setTimeout(() => { clearInterval(checkInterval); setReserveRefreshing(false); }, 660000);
|
|
||||||
};
|
|
||||||
pollUntilDone();
|
|
||||||
}, [healthScores?.reserve?.id, queryClient]);
|
|
||||||
|
|
||||||
const fmt = (v: string | number) =>
|
const fmt = (v: string | number) =>
|
||||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
@@ -416,6 +381,7 @@ export function DashboardPage() {
|
|||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.operating || null}
|
score={healthScores?.operating || null}
|
||||||
@@ -425,9 +391,9 @@ export function DashboardPage() {
|
|||||||
<IconHeartbeat size={20} />
|
<IconHeartbeat size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={operatingRefreshing}
|
isRefreshing={recalcOperatingMutation.isPending}
|
||||||
onRefresh={handleRefreshOperating}
|
onRefresh={() => recalcOperatingMutation.mutate()}
|
||||||
lastFailed={!!healthScores?.operating_last_failed}
|
lastFailed={operatingFailed || !!healthScores?.operating_last_failed}
|
||||||
/>
|
/>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.reserve || null}
|
score={healthScores?.reserve || null}
|
||||||
@@ -437,9 +403,9 @@ export function DashboardPage() {
|
|||||||
<IconHeartbeat size={20} />
|
<IconHeartbeat size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={reserveRefreshing}
|
isRefreshing={recalcReserveMutation.isPending}
|
||||||
onRefresh={handleRefreshReserve}
|
onRefresh={() => recalcReserveMutation.mutate()}
|
||||||
lastFailed={!!healthScores?.reserve_last_failed}
|
lastFailed={reserveFailed || !!healthScores?.reserve_last_failed}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
@@ -543,30 +509,7 @@ export function DashboardPage() {
|
|||||||
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Interest Earned YoY</Text>
|
<Text size="sm" c="dimmed">Planned Capital Spend</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>
|
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider my={4} />
|
<Divider my={4} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
@@ -19,10 +19,6 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Collapse,
|
Collapse,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
TextInput,
|
|
||||||
Progress,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBulb,
|
IconBulb,
|
||||||
@@ -36,12 +32,9 @@ import {
|
|||||||
IconPigMoney,
|
IconPigMoney,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconPlaylistAdd,
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { DateInput } from '@mantine/dates';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
@@ -87,15 +80,6 @@ interface MarketRatesResponse {
|
|||||||
high_yield_savings: MarketRate[];
|
high_yield_savings: MarketRate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecommendationComponent {
|
|
||||||
label: string;
|
|
||||||
amount: number;
|
|
||||||
term_months: number;
|
|
||||||
rate: number;
|
|
||||||
bank_name?: string;
|
|
||||||
investment_type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Recommendation {
|
interface Recommendation {
|
||||||
type: string;
|
type: string;
|
||||||
priority: 'high' | 'medium' | 'low';
|
priority: 'high' | 'medium' | 'low';
|
||||||
@@ -108,7 +92,6 @@ interface Recommendation {
|
|||||||
suggested_rate?: number;
|
suggested_rate?: number;
|
||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
components?: RecommendationComponent[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AIResponse {
|
interface AIResponse {
|
||||||
@@ -124,9 +107,6 @@ interface SavedRecommendation {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
response_time_ms: number;
|
response_time_ms: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
status: 'processing' | 'complete' | 'error';
|
|
||||||
last_failed: boolean;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
@@ -201,31 +181,14 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean
|
|||||||
|
|
||||||
// ── Recommendations Display Component ──
|
// ── Recommendations Display Component ──
|
||||||
|
|
||||||
function RecommendationsDisplay({
|
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
|
||||||
aiResult,
|
|
||||||
lastUpdated,
|
|
||||||
lastFailed,
|
|
||||||
onAddToPlan,
|
|
||||||
}: {
|
|
||||||
aiResult: AIResponse;
|
|
||||||
lastUpdated?: string;
|
|
||||||
lastFailed?: boolean;
|
|
||||||
onAddToPlan?: (rec: Recommendation) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{/* Last Updated timestamp + failure message */}
|
{/* Last Updated timestamp */}
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<Stack gap={0} align="flex-end">
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
<Text size="xs" c="dimmed" ta="right">
|
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
</Text>
|
||||||
</Text>
|
|
||||||
{lastFailed && (
|
|
||||||
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
|
||||||
last analysis failed — showing cached data
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overall Assessment */}
|
{/* Overall Assessment */}
|
||||||
@@ -346,17 +309,6 @@ function RecommendationsDisplay({
|
|||||||
<Alert variant="light" color="gray" title="Rationale">
|
<Alert variant="light" color="gray" title="Rationale">
|
||||||
<Text size="sm">{rec.rationale}</Text>
|
<Text size="sm">{rec.rationale}</Text>
|
||||||
</Alert>
|
</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>
|
</Stack>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
@@ -375,92 +327,9 @@ function RecommendationsDisplay({
|
|||||||
// ── Main Component ──
|
// ── Main Component ──
|
||||||
|
|
||||||
export function InvestmentPlanningPage() {
|
export function InvestmentPlanningPage() {
|
||||||
const navigate = useNavigate();
|
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||||
const [isTriggering, setIsTriggering] = useState(false);
|
|
||||||
const [planModalOpen, setPlanModalOpen] = useState(false);
|
|
||||||
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
|
|
||||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
|
||||||
const [newScenarioName, setNewScenarioName] = useState('');
|
|
||||||
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
|
||||||
|
|
||||||
// Load investment scenarios for the "Add to Plan" modal
|
|
||||||
const { data: investmentScenarios } = useQuery<any[]>({
|
|
||||||
queryKey: ['board-planning-scenarios', 'investment'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get('/board-planning/scenarios?type=investment');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const addToPlanMutation = useMutation({
|
|
||||||
mutationFn: async ({ scenarioId, rec }: { scenarioId: string; rec: Recommendation }) => {
|
|
||||||
await api.post(`/board-planning/scenarios/${scenarioId}/investments/from-recommendation`, {
|
|
||||||
title: rec.title,
|
|
||||||
investmentType: rec.type === 'cd_ladder' ? 'cd' : rec.type === 'new_investment' ? undefined : undefined,
|
|
||||||
fundType: rec.fund_type || 'reserve',
|
|
||||||
suggestedAmount: rec.suggested_amount,
|
|
||||||
suggestedRate: rec.suggested_rate,
|
|
||||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
|
||||||
bankName: rec.bank_name,
|
|
||||||
rationale: rec.rationale,
|
|
||||||
components: rec.components || undefined,
|
|
||||||
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
|
|
||||||
});
|
|
||||||
return scenarioId;
|
|
||||||
},
|
|
||||||
onSuccess: (scenarioId) => {
|
|
||||||
setPlanModalOpen(false);
|
|
||||||
setSelectedRec(null);
|
|
||||||
setTargetScenarioId(null);
|
|
||||||
notifications.show({
|
|
||||||
message: 'Recommendation added to investment scenario',
|
|
||||||
color: 'green',
|
|
||||||
autoClose: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createAndAddMutation = useMutation({
|
|
||||||
mutationFn: async ({ name, rec }: { name: string; rec: Recommendation }) => {
|
|
||||||
const { data: scenario } = await api.post('/board-planning/scenarios', {
|
|
||||||
name, scenarioType: 'investment',
|
|
||||||
});
|
|
||||||
await api.post(`/board-planning/scenarios/${scenario.id}/investments/from-recommendation`, {
|
|
||||||
title: rec.title,
|
|
||||||
investmentType: rec.type === 'cd_ladder' ? 'cd' : undefined,
|
|
||||||
fundType: rec.fund_type || 'reserve',
|
|
||||||
suggestedAmount: rec.suggested_amount,
|
|
||||||
suggestedRate: rec.suggested_rate,
|
|
||||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
|
||||||
bankName: rec.bank_name,
|
|
||||||
rationale: rec.rationale,
|
|
||||||
components: rec.components || undefined,
|
|
||||||
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
|
|
||||||
});
|
|
||||||
return scenario.id;
|
|
||||||
},
|
|
||||||
onSuccess: (scenarioId) => {
|
|
||||||
setPlanModalOpen(false);
|
|
||||||
setSelectedRec(null);
|
|
||||||
setNewScenarioName('');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
|
||||||
notifications.show({
|
|
||||||
message: 'New scenario created with recommendation',
|
|
||||||
color: 'green',
|
|
||||||
autoClose: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAddToPlan = (rec: Recommendation) => {
|
|
||||||
setSelectedRec(rec);
|
|
||||||
setTargetScenarioId(null);
|
|
||||||
setNewScenarioName('');
|
|
||||||
setInvestmentStartDate(new Date());
|
|
||||||
setPlanModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load financial snapshot on mount
|
// Load financial snapshot on mount
|
||||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||||
@@ -480,97 +349,50 @@ export function InvestmentPlanningPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load saved recommendation — polls every 3s when processing
|
// Load saved recommendation on mount
|
||||||
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
||||||
queryKey: ['investment-planning-saved-recommendation'],
|
queryKey: ['investment-planning-saved-recommendation'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/investment-planning/saved-recommendation');
|
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||||
return data;
|
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
|
// Populate AI results from saved recommendation on load
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (isTriggering && savedRec?.status === 'processing') {
|
if (savedRec && !aiResult) {
|
||||||
setIsTriggering(false);
|
setAiResult({
|
||||||
}
|
recommendations: savedRec.recommendations,
|
||||||
if (isTriggering && savedRec?.status === 'complete') {
|
overall_assessment: savedRec.overall_assessment,
|
||||||
setIsTriggering(false);
|
risk_notes: savedRec.risk_notes,
|
||||||
}
|
|
||||||
}, [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
|
setLastUpdated(savedRec.created_at);
|
||||||
setTimeout(() => {
|
|
||||||
aiSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
if (prevStatus === 'processing' && savedRec?.status === 'error') {
|
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
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)
|
// AI recommendation (on-demand)
|
||||||
const handleTriggerAI = useCallback(async () => {
|
const aiMutation = useMutation({
|
||||||
setIsTriggering(true);
|
mutationFn: async () => {
|
||||||
try {
|
const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 });
|
||||||
await api.post('/investment-planning/recommendations');
|
return data as AIResponse;
|
||||||
} catch (err: any) {
|
},
|
||||||
setIsTriggering(false);
|
onSuccess: (data) => {
|
||||||
notifications.show({
|
setAiResult(data);
|
||||||
message: err.response?.data?.message || 'Failed to start AI analysis',
|
setLastUpdated(new Date().toISOString());
|
||||||
color: 'red',
|
if (data.recommendations.length > 0) {
|
||||||
});
|
notifications.show({
|
||||||
}
|
message: `Generated ${data.recommendations.length} investment recommendations`,
|
||||||
}, []);
|
color: 'green',
|
||||||
|
});
|
||||||
// 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)
|
},
|
||||||
? {
|
onError: (err: any) => {
|
||||||
recommendations: savedRec!.recommendations,
|
notifications.show({
|
||||||
overall_assessment: savedRec!.overall_assessment,
|
message: err.response?.data?.message || 'Failed to get AI recommendations',
|
||||||
risk_notes: savedRec!.risk_notes,
|
color: 'red',
|
||||||
}
|
});
|
||||||
: null;
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (snapshotLoading) {
|
if (snapshotLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -808,7 +630,7 @@ export function InvestmentPlanningPage() {
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* ── Section 4: AI Investment Recommendations ── */}
|
{/* ── Section 4: AI Investment Recommendations ── */}
|
||||||
<Card withBorder p="lg" ref={aiSectionRef}>
|
<Card withBorder p="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ThemeIcon variant="light" color="grape" size="md">
|
<ThemeIcon variant="light" color="grape" size="md">
|
||||||
@@ -823,8 +645,8 @@ export function InvestmentPlanningPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconSparkles size={16} />}
|
leftSection={<IconSparkles size={16} />}
|
||||||
onClick={handleTriggerAI}
|
onClick={() => aiMutation.mutate()}
|
||||||
loading={isProcessing}
|
loading={aiMutation.isPending}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{ from: 'grape', to: 'violet' }}
|
gradient={{ from: 'grape', to: 'violet' }}
|
||||||
>
|
>
|
||||||
@@ -832,47 +654,28 @@ export function InvestmentPlanningPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Processing State - shown as banner when refreshing with existing results */}
|
{/* Loading State */}
|
||||||
{isProcessing && (
|
{aiMutation.isPending && (
|
||||||
<Alert variant="light" color="grape" mb="md" styles={{ root: { overflow: 'visible' } }}>
|
<Center py="xl">
|
||||||
<Group gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
<Loader size="sm" color="grape" />
|
<Loader size="lg" type="dots" />
|
||||||
<div style={{ flex: 1 }}>
|
<Text c="dimmed" size="sm">
|
||||||
<Text size="sm" fw={500}>
|
Analyzing your financial data and market rates...
|
||||||
{aiResult ? 'Refreshing AI analysis...' : 'Running AI analysis...'}
|
</Text>
|
||||||
</Text>
|
<Text c="dimmed" size="xs">
|
||||||
<Text size="xs" c="dimmed">
|
This may take a few minutes for complex tenant data
|
||||||
Analyzing your financial data, accounts, budgets, and current market rates
|
</Text>
|
||||||
</Text>
|
</Stack>
|
||||||
</div>
|
</Center>
|
||||||
</Group>
|
|
||||||
<Progress value={100} animated color="grape" size="xs" mt="xs" />
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error State (no cached data) */}
|
{/* Results */}
|
||||||
{hasError && !isProcessing && (
|
{aiResult && !aiMutation.isPending && (
|
||||||
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
|
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
|
||||||
<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) */}
|
{/* Empty State */}
|
||||||
{aiResult && (
|
{!aiResult && !aiMutation.isPending && (
|
||||||
<div style={isProcessing ? { opacity: 0.5, pointerEvents: 'none' } : undefined}>
|
|
||||||
<RecommendationsDisplay
|
|
||||||
aiResult={aiResult}
|
|
||||||
lastUpdated={savedRec?.created_at || undefined}
|
|
||||||
lastFailed={lastFailed}
|
|
||||||
onAddToPlan={handleAddToPlan}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State - only when no results and not processing */}
|
|
||||||
{!aiResult && !isProcessing && !hasError && (
|
|
||||||
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
||||||
<IconSparkles size={28} />
|
<IconSparkles size={28} />
|
||||||
@@ -889,77 +692,6 @@ export function InvestmentPlanningPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add to Investment Plan Modal */}
|
|
||||||
<Modal opened={planModalOpen} onClose={() => setPlanModalOpen(false)} title="Add to Investment Plan">
|
|
||||||
<Stack>
|
|
||||||
{selectedRec && (
|
|
||||||
<Alert variant="light" color="blue">
|
|
||||||
<Text size="sm" fw={600}>{selectedRec.title}</Text>
|
|
||||||
{selectedRec.suggested_amount != null && (
|
|
||||||
<Text size="sm">Amount: {fmt(selectedRec.suggested_amount)}</Text>
|
|
||||||
)}
|
|
||||||
{selectedRec.components && selectedRec.components.length > 0 && (
|
|
||||||
<Stack gap={2} mt={6}>
|
|
||||||
<Text size="xs" c="dimmed" fw={600}>{selectedRec.components.length} investments will be created:</Text>
|
|
||||||
{selectedRec.components.map((c, i) => (
|
|
||||||
<Text key={i} size="xs" c="dimmed">
|
|
||||||
{c.label}: {fmt(c.amount)} @ {c.rate}% ({c.term_months} mo)
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DateInput
|
|
||||||
label="Start Date"
|
|
||||||
description="Purchase date for the investment(s). Maturity dates are calculated automatically from term length."
|
|
||||||
value={investmentStartDate}
|
|
||||||
onChange={setInvestmentStartDate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{investmentScenarios && investmentScenarios.length > 0 && (
|
|
||||||
<Select
|
|
||||||
label="Add to existing scenario"
|
|
||||||
placeholder="Select a scenario..."
|
|
||||||
data={investmentScenarios.map((s: any) => ({ value: s.id, label: s.name }))}
|
|
||||||
value={targetScenarioId}
|
|
||||||
onChange={setTargetScenarioId}
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider label="or" labelPosition="center" />
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Create new scenario"
|
|
||||||
placeholder="e.g. Conservative Strategy"
|
|
||||||
value={newScenarioName}
|
|
||||||
onChange={(e) => { setNewScenarioName(e.target.value); setTargetScenarioId(null); }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
|
|
||||||
{targetScenarioId && selectedRec && (
|
|
||||||
<Button
|
|
||||||
onClick={() => addToPlanMutation.mutate({ scenarioId: targetScenarioId, rec: selectedRec })}
|
|
||||||
loading={addToPlanMutation.isPending}
|
|
||||||
>
|
|
||||||
Add to Scenario
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{newScenarioName && !targetScenarioId && selectedRec && (
|
|
||||||
<Button
|
|
||||||
onClick={() => createAndAddMutation.mutate({ name: newScenarioName, rec: selectedRec })}
|
|
||||||
loading={createAndAddMutation.isPending}
|
|
||||||
>
|
|
||||||
Create & Add
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Card, SimpleGrid, Badge, Alert, Modal,
|
Select, Loader, Center, Card, SimpleGrid, Badge, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconEdit,
|
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
interface ActualLine {
|
interface ActualLine {
|
||||||
@@ -66,15 +64,8 @@ export function MonthlyActualsPage() {
|
|||||||
const [month, setMonth] = useState(defaults.month);
|
const [month, setMonth] = useState(defaults.month);
|
||||||
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
||||||
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
|
||||||
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
|
||||||
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -87,15 +78,10 @@ export function MonthlyActualsPage() {
|
|||||||
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
|
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
|
||||||
setEditedAmounts({});
|
setEditedAmounts({});
|
||||||
setSavedJEId(data.existing_journal_entry_id || null);
|
setSavedJEId(data.existing_journal_entry_id || null);
|
||||||
// Default to read mode if actuals already exist, edit mode if new
|
|
||||||
setIsEditing(!data.existing_journal_entry_id);
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Whether actuals have been previously saved (reconciled)
|
|
||||||
const hasExistingActuals = !!savedJEId;
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const lines = (grid?.lines || [])
|
const lines = (grid?.lines || [])
|
||||||
@@ -115,8 +101,6 @@ export function MonthlyActualsPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
|
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
|
||||||
setSavedJEId(data.journal_entry_id);
|
setSavedJEId(data.journal_entry_id);
|
||||||
setIsEditing(false);
|
|
||||||
setEditedAmounts({});
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: data.message || 'Actuals saved and reconciled',
|
message: data.message || 'Actuals saved and reconciled',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
@@ -141,19 +125,6 @@ export function MonthlyActualsPage() {
|
|||||||
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
|
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditClick = () => {
|
|
||||||
if (hasExistingActuals) {
|
|
||||||
openConfirm();
|
|
||||||
} else {
|
|
||||||
setIsEditing(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmEdit = () => {
|
|
||||||
closeConfirm();
|
|
||||||
setIsEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines = grid?.lines || [];
|
const lines = grid?.lines || [];
|
||||||
const incomeLines = lines.filter((l) => l.account_type === 'income');
|
const incomeLines = lines.filter((l) => l.account_type === 'income');
|
||||||
const expenseLines = lines.filter((l) => l.account_type === 'expense');
|
const expenseLines = lines.filter((l) => l.account_type === 'expense');
|
||||||
@@ -166,6 +137,7 @@ export function MonthlyActualsPage() {
|
|||||||
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
|
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
|
||||||
}, [lines, editedAmounts]);
|
}, [lines, editedAmounts]);
|
||||||
|
|
||||||
|
const hasChanges = Object.keys(editedAmounts).length > 0;
|
||||||
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
|
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
@@ -191,7 +163,7 @@ export function MonthlyActualsPage() {
|
|||||||
{title}
|
{title}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(actualTotal)}</Table.Td>
|
<Table.Td />
|
||||||
<Table.Td ta="right" fw={700} ff="monospace"
|
<Table.Td ta="right" fw={700} ff="monospace"
|
||||||
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
|
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
|
||||||
>
|
>
|
||||||
@@ -206,16 +178,16 @@ export function MonthlyActualsPage() {
|
|||||||
<Table.Tr key={line.account_id}>
|
<Table.Tr key={line.account_id}>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
|
position: 'sticky', left: 0, background: 'white', zIndex: 1,
|
||||||
borderRight: `1px solid ${stickyBorder}`,
|
borderRight: '1px solid #e9ecef',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
|
position: 'sticky', left: 120, background: 'white', zIndex: 1,
|
||||||
borderRight: `1px solid ${stickyBorder}`,
|
borderRight: '1px solid #e9ecef',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -226,21 +198,17 @@ export function MonthlyActualsPage() {
|
|||||||
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
|
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
|
||||||
{fmt(line.budget_amount)}
|
{fmt(line.budget_amount)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td p={isEditing ? 2 : undefined} style={{ minWidth: 130 }}>
|
<Table.Td p={2} style={{ minWidth: 130 }}>
|
||||||
{isEditing ? (
|
<NumberInput
|
||||||
<NumberInput
|
value={amount}
|
||||||
value={amount}
|
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
||||||
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
size="xs"
|
||||||
size="xs"
|
hideControls
|
||||||
hideControls
|
decimalScale={2}
|
||||||
decimalScale={2}
|
allowNegative
|
||||||
allowNegative
|
disabled={isReadOnly}
|
||||||
disabled={isReadOnly}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" ff="monospace" ta="right">{fmt(amount)}</Text>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
ta="right" ff="monospace" style={{ minWidth: 110 }}
|
ta="right" ff="monospace" style={{ minWidth: 110 }}
|
||||||
@@ -264,24 +232,14 @@ export function MonthlyActualsPage() {
|
|||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||||
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
||||||
{!isReadOnly && !isEditing && (
|
{!isReadOnly && (
|
||||||
<Button
|
|
||||||
leftSection={<IconEdit size={16} />}
|
|
||||||
variant="light"
|
|
||||||
onClick={handleEditClick}
|
|
||||||
disabled={lines.length === 0}
|
|
||||||
>
|
|
||||||
Edit Actuals
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isReadOnly && isEditing && (
|
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
loading={saveMutation.isPending}
|
loading={saveMutation.isPending}
|
||||||
disabled={lines.length === 0}
|
disabled={lines.length === 0}
|
||||||
>
|
>
|
||||||
Save Actuals
|
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -318,7 +276,7 @@ export function MonthlyActualsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasExistingActuals && !isEditing && (
|
{savedJEId && (
|
||||||
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
|
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
@@ -334,10 +292,10 @@ export function MonthlyActualsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
|
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>
|
||||||
Acct #
|
Acct #
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>
|
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>
|
||||||
Account Name
|
Account Name
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
||||||
@@ -346,8 +304,8 @@ export function MonthlyActualsPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
|
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
|
||||||
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
|
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,26 +317,6 @@ export function MonthlyActualsPage() {
|
|||||||
<AttachmentPanel journalEntryId={savedJEId} />
|
<AttachmentPanel journalEntryId={savedJEId} />
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirmation modal for editing reconciled actuals */}
|
|
||||||
<Modal opened={confirmOpened} onClose={closeConfirm} title="Edit Reconciled Actuals" centered>
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm">
|
|
||||||
Actuals for <Text span fw={700}>{monthLabel} {year}</Text> have already been
|
|
||||||
reconciled. Editing will void the existing journal entry and create a new one
|
|
||||||
when you save.
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Press Edit to proceed, or Cancel to keep the current values.
|
|
||||||
</Text>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={closeConfirm}>Cancel</Button>
|
|
||||||
<Button color="orange" leftSection={<IconEdit size={16} />} onClick={handleConfirmEdit}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
IconUser, IconPalette, IconClock, IconBell, IconEye,
|
IconUser, IconPalette, IconClock, IconBell, IconEye,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
|
|
||||||
export function UserPreferencesPage() {
|
export function UserPreferencesPage() {
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -68,10 +66,7 @@ export function UserPreferencesPage() {
|
|||||||
<Text size="sm">Dark Mode</Text>
|
<Text size="sm">Dark Mode</Text>
|
||||||
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
|
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch disabled />
|
||||||
checked={colorScheme === 'dark'}
|
|
||||||
onChange={toggleColorScheme}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -81,7 +76,7 @@ export function UserPreferencesPage() {
|
|||||||
<Switch disabled />
|
<Switch disabled />
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
|
|
||||||
interface BudgetVsActualLine {
|
interface BudgetVsActualLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -47,9 +46,6 @@ const monthFilterOptions = [
|
|||||||
export function BudgetVsActualPage() {
|
export function BudgetVsActualPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [month, setMonth] = useState('');
|
const [month, setMonth] = useState('');
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
|
||||||
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -96,7 +92,7 @@ export function BudgetVsActualPage() {
|
|||||||
|
|
||||||
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
||||||
<>
|
<>
|
||||||
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
|
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
|
||||||
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
{sectionLines.map((line) => {
|
{sectionLines.map((line) => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
|
|
||||||
interface BudgetVsActualItem {
|
interface BudgetVsActualItem {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -49,9 +48,6 @@ export function QuarterlyReportPage() {
|
|||||||
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
||||||
const defaultQuarter = currentQuarter;
|
const defaultQuarter = currentQuarter;
|
||||||
const defaultYear = now.getFullYear();
|
const defaultYear = now.getFullYear();
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
|
||||||
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
|
||||||
|
|
||||||
const [year, setYear] = useState(String(defaultYear));
|
const [year, setYear] = useState(String(defaultYear));
|
||||||
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
||||||
@@ -211,7 +207,7 @@ export function QuarterlyReportPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{incomeItems.length > 0 && (
|
{incomeItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: incomeBg }}>
|
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||||
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -219,7 +215,7 @@ export function QuarterlyReportPage() {
|
|||||||
<BVARow key={item.account_id} item={item} isExpense={false} />
|
<BVARow key={item.account_id} item={item} isExpense={false} />
|
||||||
))}
|
))}
|
||||||
{incomeItems.length > 0 && (
|
{incomeItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: incomeBg }}>
|
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||||
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||||
@@ -230,7 +226,7 @@ export function QuarterlyReportPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
{expenseItems.length > 0 && (
|
{expenseItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: expenseBg }}>
|
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||||
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -238,7 +234,7 @@ export function QuarterlyReportPage() {
|
|||||||
<BVARow key={item.account_id} item={item} isExpense={true} />
|
<BVARow key={item.account_id} item={item} isExpense={true} />
|
||||||
))}
|
))}
|
||||||
{expenseItems.length > 0 && (
|
{expenseItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: expenseBg }}>
|
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||||
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function SettingsPage() {
|
|||||||
<Text size="sm" c="dimmed">Your Role</Text>
|
<Text size="sm" c="dimmed">Your Role</Text>
|
||||||
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Schema</Text>
|
||||||
|
<Text size="sm" ff="monospace" c="dimmed">{currentOrg?.schemaName || 'N/A'}</Text>
|
||||||
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -113,7 +117,7 @@ export function SettingsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Version</Text>
|
<Text size="sm" c="dimmed">Version</Text>
|
||||||
<Badge variant="light">2026.03.10</Badge>
|
<Badge variant="light">2026.3.2 (beta)</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">API</Text>
|
<Text size="sm" c="dimmed">API</Text>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface Organization {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
schemaName?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import { persist } from 'zustand/middleware';
|
|
||||||
|
|
||||||
type ColorScheme = 'light' | 'dark';
|
|
||||||
|
|
||||||
interface PreferencesState {
|
|
||||||
colorScheme: ColorScheme;
|
|
||||||
toggleColorScheme: () => void;
|
|
||||||
setColorScheme: (scheme: ColorScheme) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePreferencesStore = create<PreferencesState>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
colorScheme: 'light',
|
|
||||||
toggleColorScheme: () =>
|
|
||||||
set((state) => ({
|
|
||||||
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
|
|
||||||
})),
|
|
||||||
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'ledgeriq-preferences',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -23,8 +23,21 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints now return immediately (async processing in background)
|
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
|
||||||
# No special timeout needed — kept for documentation purposes
|
location /api/investment-planning/recommendations {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
# Everything else -> Vite dev server (frontend)
|
# Everything else -> Vite dev server (frontend)
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -74,8 +74,20 @@ server {
|
|||||||
proxy_send_timeout 15s;
|
proxy_send_timeout 15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints now return immediately (async processing in background)
|
# AI endpoints — longer timeouts (LLM calls can take minutes)
|
||||||
# No special timeout overrides needed
|
location /api/investment-planning/recommendations {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/health-scores/calculate {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
# --- Frontend → React SPA served by nginx (port 3001) ---
|
# --- Frontend → React SPA served by nginx (port 3001) ---
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -40,8 +40,20 @@ server {
|
|||||||
proxy_send_timeout 15s;
|
proxy_send_timeout 15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints now return immediately (async processing in background)
|
# AI endpoints → longer timeouts
|
||||||
# No special timeout overrides needed
|
location /api/investment-planning/recommendations {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/health-scores/calculate {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
# --- Static frontend → built React assets ---
|
# --- Static frontend → built React assets ---
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -60,8 +60,37 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints now return immediately (async processing in background)
|
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
|
||||||
# No special timeout overrides needed
|
location /api/investment-planning/recommendations {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# AI health-score endpoint also needs a longer timeout
|
||||||
|
location /api/health-scores/calculate {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
# Everything else -> Vite dev server (frontend)
|
# Everything else -> Vite dev server (frontend)
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# reset-password.sh — Reset a user's password in HOA LedgerIQ
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/reset-password.sh <email> <new-password>
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# ./scripts/reset-password.sh admin@hoaledgeriq.com MyNewPassword123
|
|
||||||
# ./scripts/reset-password.sh admin@sunrisevalley.org SecurePass!
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ---- Defaults ----
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
DB_USER="${POSTGRES_USER:-hoafinance}"
|
|
||||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
|
||||||
COMPOSE_CMD="docker compose"
|
|
||||||
|
|
||||||
# If running with the SSL override, detect it
|
|
||||||
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
|
|
||||||
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
|
|
||||||
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
|
|
||||||
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- Colors ----
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
|
||||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
|
||||||
die() { err "$@"; exit 1; }
|
|
||||||
|
|
||||||
# ---- Helpers ----
|
|
||||||
|
|
||||||
ensure_containers_running() {
|
|
||||||
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
|
|
||||||
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
|
|
||||||
fi
|
|
||||||
if ! $COMPOSE_CMD ps backend 2>/dev/null | grep -q "running\|Up"; then
|
|
||||||
die "Backend container is not running. Start it with: docker compose up -d backend"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- CLI ----
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
HOA LedgerIQ Password Reset
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
$(basename "$0") <email> <new-password>
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
$(basename "$0") admin@hoaledgeriq.com MyNewPassword123
|
|
||||||
$(basename "$0") admin@sunrisevalley.org SecurePass!
|
|
||||||
|
|
||||||
This script:
|
|
||||||
1. Verifies the user exists in the database
|
|
||||||
2. Generates a bcrypt hash using bcryptjs (same library the app uses)
|
|
||||||
3. Updates the password in the database
|
|
||||||
4. Verifies the new hash works
|
|
||||||
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse args
|
|
||||||
case "${1:-}" in
|
|
||||||
-h|--help|help|"") usage ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[ $# -lt 2 ] && die "Usage: $(basename "$0") <email> <new-password>"
|
|
||||||
|
|
||||||
EMAIL="$1"
|
|
||||||
NEW_PASSWORD="$2"
|
|
||||||
|
|
||||||
# Load .env if present
|
|
||||||
if [ -f "$PROJECT_DIR/.env" ]; then
|
|
||||||
set -a
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source "$PROJECT_DIR/.env"
|
|
||||||
set +a
|
|
||||||
DB_USER="${POSTGRES_USER:-hoafinance}"
|
|
||||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure containers are running
|
|
||||||
info "Checking containers ..."
|
|
||||||
ensure_containers_running
|
|
||||||
|
|
||||||
# Verify user exists
|
|
||||||
info "Looking up user: ${EMAIL} ..."
|
|
||||||
USER_RECORD=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
|
|
||||||
-t -A -c "SELECT id, email, first_name, last_name, is_superadmin FROM shared.users WHERE email = '${EMAIL}';" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$USER_RECORD" ]; then
|
|
||||||
die "No user found with email: ${EMAIL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parse user info for display
|
|
||||||
IFS='|' read -r USER_ID USER_EMAIL FIRST_NAME LAST_NAME IS_SUPER <<< "$USER_RECORD"
|
|
||||||
info "Found user: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
|
|
||||||
if [ "$IS_SUPER" = "t" ]; then
|
|
||||||
warn "This is a superadmin account"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate bcrypt hash using bcryptjs inside the backend container
|
|
||||||
info "Generating bcrypt hash ..."
|
|
||||||
HASH=$($COMPOSE_CMD exec -T backend node -e "
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
bcrypt.hash(process.argv[1], 12).then(h => process.stdout.write(h));
|
|
||||||
" "$NEW_PASSWORD" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$HASH" ] || [ ${#HASH} -lt 50 ]; then
|
|
||||||
die "Failed to generate bcrypt hash. Is the backend container running?"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update the password using a heredoc to avoid shell escaping issues with $ in hashes
|
|
||||||
info "Updating password ..."
|
|
||||||
UPDATE_RESULT=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -t -A <<EOSQL
|
|
||||||
UPDATE shared.users SET password_hash = '${HASH}', updated_at = NOW() WHERE email = '${EMAIL}';
|
|
||||||
EOSQL
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "$UPDATE_RESULT" != *"UPDATE 1"* ]]; then
|
|
||||||
die "Password update failed. Result: ${UPDATE_RESULT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify the new hash works
|
|
||||||
info "Verifying new password ..."
|
|
||||||
VERIFY=$($COMPOSE_CMD exec -T backend node -e "
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
bcrypt.compare(process.argv[1], process.argv[2]).then(r => process.stdout.write(String(r)));
|
|
||||||
" "$NEW_PASSWORD" "$HASH" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$VERIFY" != "true" ]; then
|
|
||||||
die "Verification failed — the hash does not match the password. Something went wrong."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
ok "Password reset successful!"
|
|
||||||
echo ""
|
|
||||||
info " User: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
|
|
||||||
info " Login: ${EMAIL}"
|
|
||||||
echo ""
|
|
||||||
Reference in New Issue
Block a user