Merge branch 'main' into claude/beautiful-gauss
This commit is contained in:
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.19",
|
"version": "2026.3.24",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
|
|||||||
import { BillingModule } from './modules/billing/billing.module';
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
import { EmailModule } from './modules/email/email.module';
|
import { EmailModule } from './modules/email/email.module';
|
||||||
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
||||||
|
import { IdeasModule } from './modules/ideas/ideas.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -88,6 +89,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
OnboardingModule,
|
OnboardingModule,
|
||||||
|
IdeasModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ export class AccountsController {
|
|||||||
return this.accountsService.adjustBalance(id, dto);
|
return this.accountsService.adjustBalance(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('transfer')
|
||||||
|
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
|
||||||
|
transferFunds(
|
||||||
|
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
|
||||||
|
) {
|
||||||
|
return this.accountsService.transferFunds(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get account by ID' })
|
@ApiOperation({ summary: 'Get account by ID' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -360,6 +360,62 @@ export class AccountsService {
|
|||||||
return journalEntry;
|
return journalEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async transferFunds(dto: {
|
||||||
|
fromAccountId: string;
|
||||||
|
toAccountId: string;
|
||||||
|
amount: number;
|
||||||
|
transferDate: string;
|
||||||
|
memo?: string;
|
||||||
|
}) {
|
||||||
|
if (dto.amount <= 0) throw new BadRequestException('Transfer amount must be positive');
|
||||||
|
if (dto.fromAccountId === dto.toAccountId) throw new BadRequestException('Cannot transfer to the same account');
|
||||||
|
|
||||||
|
const fromAccount = await this.findOne(dto.fromAccountId);
|
||||||
|
const toAccount = await this.findOne(dto.toAccountId);
|
||||||
|
|
||||||
|
if (fromAccount.account_type !== 'asset') throw new BadRequestException('Source account must be an asset account');
|
||||||
|
if (toAccount.account_type !== 'asset') throw new BadRequestException('Destination account must be an asset account');
|
||||||
|
|
||||||
|
// Find fiscal period
|
||||||
|
const asOf = new Date(dto.transferDate);
|
||||||
|
const year = asOf.getFullYear();
|
||||||
|
const month = asOf.getMonth() + 1;
|
||||||
|
const periods = await this.tenant.query(
|
||||||
|
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
if (!periods.length) {
|
||||||
|
throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memo = dto.memo || `Transfer from ${fromAccount.name} to ${toAccount.name}`;
|
||||||
|
|
||||||
|
// Create journal entry: debit destination (increase), credit source (decrease)
|
||||||
|
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 *`,
|
||||||
|
[dto.transferDate, memo, periods[0].id, '00000000-0000-0000-0000-000000000000'],
|
||||||
|
);
|
||||||
|
const je = jeRows[0];
|
||||||
|
|
||||||
|
// Credit source account (reduces asset balance)
|
||||||
|
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, dto.fromAccountId, dto.amount, memo],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debit destination account (increases asset balance)
|
||||||
|
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, dto.toAccountId, dto.amount, memo],
|
||||||
|
);
|
||||||
|
|
||||||
|
return je;
|
||||||
|
}
|
||||||
|
|
||||||
async getTrialBalance(asOfDate?: string) {
|
async getTrialBalance(asOfDate?: string) {
|
||||||
const dateFilter = asOfDate
|
const dateFilter = asOfDate
|
||||||
? `AND je.entry_date <= $1`
|
? `AND je.entry_date <= $1`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { OrganizationsService } from '../organizations/organizations.service';
|
import { OrganizationsService } from '../organizations/organizations.service';
|
||||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
|
import { IdeasService } from '../ideas/ideas.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@@ -17,6 +18,7 @@ export class AdminController {
|
|||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private orgService: OrganizationsService,
|
private orgService: OrganizationsService,
|
||||||
private analyticsService: AdminAnalyticsService,
|
private analyticsService: AdminAnalyticsService,
|
||||||
|
private ideasService: IdeasService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async requireSuperadmin(req: any) {
|
private async requireSuperadmin(req: any) {
|
||||||
@@ -196,4 +198,45 @@ export class AdminController {
|
|||||||
|
|
||||||
return { success: true, organization: org };
|
return { success: true, organization: org };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ideation ──
|
||||||
|
|
||||||
|
@Get('ideas')
|
||||||
|
async listAllIdeas(@Req() req: any) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
return this.ideasService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('ideas/:id/status')
|
||||||
|
async updateIdeaStatus(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { status: string },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const idea = await this.ideasService.updateStatus(id, body.status);
|
||||||
|
return { success: true, idea };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('ideas/:id/note')
|
||||||
|
async updateIdeaNote(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { adminNote: string },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const idea = await this.ideasService.updateNote(id, body.adminNote);
|
||||||
|
return { success: true, idea };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('organizations/:id/settings')
|
||||||
|
async updateOrgSettings(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Record<string, any>,
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const org = await this.orgService.updateSettings(id, body);
|
||||||
|
return { success: true, organization: org };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||||
|
import { IdeasModule } from '../ideas/ideas.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
IdeasModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ export class BoardPlanningProjectionService {
|
|||||||
return this.computeProjection(scenarioId);
|
return this.computeProjection(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute full projection for a scenario. */
|
/** Compute full projection for a scenario. Also auto-creates renewal records for auto_renew investments. */
|
||||||
async computeProjection(scenarioId: string) {
|
async computeProjection(scenarioId: string) {
|
||||||
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||||
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
||||||
const scenario = scenarioRows[0];
|
const scenario = scenarioRows[0];
|
||||||
|
|
||||||
|
// Auto-create renewal investment records for auto_renew investments that have maturity dates
|
||||||
|
await this.ensureRenewalRecords(scenarioId);
|
||||||
|
|
||||||
const investments = await this.tenant.query(
|
const investments = await this.tenant.query(
|
||||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
||||||
);
|
);
|
||||||
@@ -152,6 +155,53 @@ export class BoardPlanningProjectionService {
|
|||||||
|
|
||||||
// ── Private Helpers ──
|
// ── Private Helpers ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each auto_renew investment with a maturity_date, ensure a corresponding
|
||||||
|
* renewal investment record exists (starting at maturity_date, same term).
|
||||||
|
* The renewal record has auto_renew=false so it won't create infinite chains.
|
||||||
|
*/
|
||||||
|
private async ensureRenewalRecords(scenarioId: string) {
|
||||||
|
const autoRenewInvestments = await this.tenant.query(
|
||||||
|
`SELECT * FROM scenario_investments
|
||||||
|
WHERE scenario_id = $1 AND auto_renew = true AND maturity_date IS NOT NULL AND executed_investment_id IS NULL`,
|
||||||
|
[scenarioId],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const inv of autoRenewInvestments) {
|
||||||
|
// Check if a renewal record already exists (linked by notes convention or same label pattern)
|
||||||
|
const renewalLabel = `${inv.label} (Renewal)`;
|
||||||
|
const existing = await this.tenant.query(
|
||||||
|
`SELECT id FROM scenario_investments WHERE scenario_id = $1 AND label = $2 AND purchase_date = $3`,
|
||||||
|
[scenarioId, renewalLabel, inv.maturity_date],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) continue; // Already created
|
||||||
|
|
||||||
|
// Compute new maturity date from original term
|
||||||
|
let newMaturityDate: string | null = null;
|
||||||
|
const termMonths = parseInt(inv.term_months) || 0;
|
||||||
|
if (termMonths > 0 && inv.maturity_date) {
|
||||||
|
const d = new Date(inv.maturity_date);
|
||||||
|
d.setMonth(d.getMonth() + termMonths);
|
||||||
|
newMaturityDate = d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO scenario_investments
|
||||||
|
(scenario_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, false, $11, $12)`,
|
||||||
|
[
|
||||||
|
scenarioId, renewalLabel, inv.investment_type, inv.fund_type,
|
||||||
|
inv.principal, inv.interest_rate, inv.term_months || null,
|
||||||
|
inv.institution, inv.maturity_date, newMaturityDate,
|
||||||
|
`Auto-created renewal of "${inv.label}". Modify as needed.`,
|
||||||
|
(parseInt(inv.sort_order) || 0) + 1,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getBaselineState(startYear: number, months: number) {
|
private async getBaselineState(startYear: number, months: number) {
|
||||||
// Current balances from asset accounts
|
// Current balances from asset accounts
|
||||||
const opCashRows = await this.tenant.query(`
|
const opCashRows = await this.tenant.query(`
|
||||||
@@ -403,11 +453,9 @@ export class BoardPlanningProjectionService {
|
|||||||
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
||||||
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
||||||
|
|
||||||
// Auto-renew: immediately reinvest
|
// Note: auto_renew investments now create separate renewal records
|
||||||
if (inv.auto_renew) {
|
// (via ensureRenewalRecords), so the renewal purchase is handled by
|
||||||
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
// that record's purchase_date logic above — no inline reinvest needed.
|
||||||
else { resCashFlow -= principal; resInvChange += principal; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -625,14 +625,16 @@ 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
|
// Projects due within 5 years — based on planned date (target_year/target_month),
|
||||||
const urgentComponents = useComponentsTable
|
// NOT remaining_life_years. The planned date is the board's decision on when to act;
|
||||||
? reserveComponents.filter(
|
// remaining life is documentation-only reference info.
|
||||||
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
const now = new Date();
|
||||||
)
|
const fiveYearsFromNow = new Date(now.getFullYear() + 5, now.getMonth(), 1);
|
||||||
: reserveProjects.filter(
|
const urgentProjects = reserveProjects.filter((p: any) => {
|
||||||
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
|
if (!p.target_year) return false;
|
||||||
);
|
const targetDate = new Date(parseInt(p.target_year), (parseInt(p.target_month) || 6) - 1, 1);
|
||||||
|
return targetDate <= fiveYearsFromNow;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Build 12-month forward reserve cash flow projection ──
|
// ── Build 12-month forward reserve cash flow projection ──
|
||||||
|
|
||||||
@@ -773,7 +775,7 @@ export class HealthScoresService {
|
|||||||
totalProjectCost,
|
totalProjectCost,
|
||||||
annualReserveContribution,
|
annualReserveContribution,
|
||||||
annualReserveExpenses,
|
annualReserveExpenses,
|
||||||
urgentComponents,
|
urgentProjects,
|
||||||
monthlySpecialAssessmentIncome,
|
monthlySpecialAssessmentIncome,
|
||||||
year,
|
year,
|
||||||
forecast,
|
forecast,
|
||||||
@@ -940,12 +942,13 @@ SCORING GUIDELINES:
|
|||||||
|
|
||||||
KEY FACTORS TO EVALUATE:
|
KEY FACTORS TO EVALUATE:
|
||||||
1. Percent funded (total reserve assets vs total replacement costs)
|
1. Percent funded (total reserve assets vs total replacement costs)
|
||||||
2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?)
|
2. Annual contribution adequacy (is annual contribution enough to keep pace with planned projects?)
|
||||||
3. Component urgency (components due within 5 years and their funding status)
|
3. Project urgency — based ONLY on the "Planned Date" field. The Planned Date is the board's decision on when a project will be executed. Do NOT use "Useful Life" or "Remaining Life" to determine urgency — those are reference information only. A project is only urgent if its Planned Date falls within the next 1-3 years.
|
||||||
4. Capital project readiness (are planned projects adequately funded?)
|
4. Capital project readiness (are planned projects adequately funded by their planned dates?)
|
||||||
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
|
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
|
||||||
6. Diversity of reserve components (is the full building covered?)
|
6. Diversity of reserve components (is the full scope of community infrastructure tracked?)
|
||||||
7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low.
|
7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low.
|
||||||
|
8. IMPORTANT — Projects with no Planned Date or with "Not scheduled" should be noted but NOT treated as urgent or imminent. Only assess urgency for projects with actual planned dates.
|
||||||
|
|
||||||
RESPONSE FORMAT:
|
RESPONSE FORMAT:
|
||||||
Respond with ONLY valid JSON (no markdown, no code fences):
|
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||||
@@ -974,7 +977,8 @@ 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
|
// Build component lines from reserve_components if available, otherwise from reserve-funded projects.
|
||||||
|
// Use planned date (target_year/target_month) as the authoritative timeline, not remaining_life_years.
|
||||||
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
|
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
|
||||||
const componentLines = componentSource.length === 0
|
const componentLines = componentSource.length === 0
|
||||||
? 'No reserve components or reserve projects tracked.'
|
? 'No reserve components or reserve projects tracked.'
|
||||||
@@ -982,7 +986,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
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)}`;
|
const plannedDate = c.target_year ? `${c.target_year}/${c.target_month || '?'}` : 'Not scheduled';
|
||||||
|
return `- ${c.name} [${c.category || 'N/A'}] | Planned Date: ${plannedDate} | Useful Life: ${c.useful_life_years || '?'}yr (reference only) | 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
|
||||||
@@ -995,13 +1000,14 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
|
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
|
||||||
.join('\n') || 'No reserve budget line items.';
|
.join('\n') || 'No reserve budget line items.';
|
||||||
|
|
||||||
const urgentLines = data.urgentComponents.length === 0
|
const urgentLines = data.urgentProjects.length === 0
|
||||||
? 'None — no components due within 5 years.'
|
? 'None — no reserve projects planned within 5 years.'
|
||||||
: data.urgentComponents.map((c: any) => {
|
: data.urgentProjects.map((p: any) => {
|
||||||
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
const cost = parseFloat(p.estimated_cost || '0');
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(p.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`;
|
const targetDate = `${p.target_year}/${p.target_month || '?'}`;
|
||||||
|
return `- ${p.name}: planned for ${targetDate}, Cost: $${cost.toFixed(0)}, $${gap.toFixed(0)} funding gap`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const userPrompt = `Evaluate this HOA's reserve fund health.
|
const userPrompt = `Evaluate this HOA's reserve fund health.
|
||||||
@@ -1027,10 +1033,10 @@ ${accountLines}
|
|||||||
=== RESERVE INVESTMENTS ===
|
=== RESERVE INVESTMENTS ===
|
||||||
${investmentLines}
|
${investmentLines}
|
||||||
|
|
||||||
=== RESERVE COMPONENTS (ordered by urgency) ===
|
=== RESERVE COMPONENTS (ordered by planned date) ===
|
||||||
${componentLines}
|
${componentLines}
|
||||||
|
|
||||||
=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) ===
|
=== PROJECTS PLANNED WITHIN 5 YEARS (by planned date) ===
|
||||||
${urgentLines}
|
${urgentLines}
|
||||||
|
|
||||||
=== CAPITAL PROJECTS ===
|
=== CAPITAL PROJECTS ===
|
||||||
|
|||||||
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateIdeaDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Organization } from '../../organizations/entities/organization.entity';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'shared', name: 'ideas' })
|
||||||
|
export class Idea {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'org_id' })
|
||||||
|
orgId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, default: 'new' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ name: 'admin_note', type: 'text', nullable: true })
|
||||||
|
adminNote: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Organization)
|
||||||
|
@JoinColumn({ name: 'org_id' })
|
||||||
|
organization: Organization;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { IdeasService } from './ideas.service';
|
||||||
|
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||||
|
|
||||||
|
@ApiTags('ideas')
|
||||||
|
@Controller('ideas')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class IdeasController {
|
||||||
|
constructor(private ideasService: IdeasService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Req() req: any, @Body() dto: CreateIdeaDto) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
const userId = req.user.userId || req.user.sub;
|
||||||
|
const idea = await this.ideasService.create(orgId, userId, dto);
|
||||||
|
return { success: true, idea };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findByOrg(@Req() req: any) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
return this.ideasService.findByOrg(orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/ideas/ideas.module.ts
Normal file
14
backend/src/modules/ideas/ideas.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Idea } from './entities/idea.entity';
|
||||||
|
import { Organization } from '../organizations/entities/organization.entity';
|
||||||
|
import { IdeasController } from './ideas.controller';
|
||||||
|
import { IdeasService } from './ideas.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Idea, Organization])],
|
||||||
|
controllers: [IdeasController],
|
||||||
|
providers: [IdeasService],
|
||||||
|
exports: [IdeasService],
|
||||||
|
})
|
||||||
|
export class IdeasModule {}
|
||||||
89
backend/src/modules/ideas/ideas.service.ts
Normal file
89
backend/src/modules/ideas/ideas.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Idea } from './entities/idea.entity';
|
||||||
|
import { Organization } from '../organizations/entities/organization.entity';
|
||||||
|
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IdeasService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Idea)
|
||||||
|
private ideasRepository: Repository<Idea>,
|
||||||
|
@InjectRepository(Organization)
|
||||||
|
private orgRepository: Repository<Organization>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
|
||||||
|
const org = await this.orgRepository.findOne({ where: { id: orgId } });
|
||||||
|
if (!org) {
|
||||||
|
throw new NotFoundException('Organization not found');
|
||||||
|
}
|
||||||
|
if (org.settings?.ideationEnabled !== true) {
|
||||||
|
throw new ForbiddenException('Ideation is not enabled for this organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = this.ideasRepository.create({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description,
|
||||||
|
});
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByOrg(orgId: string): Promise<Idea[]> {
|
||||||
|
return this.ideasRepository.find({
|
||||||
|
where: { orgId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<any[]> {
|
||||||
|
return this.ideasRepository
|
||||||
|
.createQueryBuilder('idea')
|
||||||
|
.leftJoin('idea.organization', 'org')
|
||||||
|
.leftJoin('idea.user', 'user')
|
||||||
|
.select([
|
||||||
|
'idea.id AS id',
|
||||||
|
'idea.title AS title',
|
||||||
|
'idea.description AS description',
|
||||||
|
'idea.status AS status',
|
||||||
|
'idea.createdAt AS "createdAt"',
|
||||||
|
'idea.adminNote AS "adminNote"',
|
||||||
|
'org.id AS "orgId"',
|
||||||
|
'org.name AS "orgName"',
|
||||||
|
'user.id AS "userId"',
|
||||||
|
'user.email AS "userEmail"',
|
||||||
|
'user.firstName AS "userFirstName"',
|
||||||
|
'user.lastName AS "userLastName"',
|
||||||
|
])
|
||||||
|
.orderBy('idea.createdAt', 'DESC')
|
||||||
|
.getRawMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: string): Promise<Idea> {
|
||||||
|
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||||
|
if (!idea) {
|
||||||
|
throw new NotFoundException('Idea not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
idea.status = status;
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNote(id: string, adminNote: string): Promise<Idea> {
|
||||||
|
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||||
|
if (!idea) {
|
||||||
|
throw new NotFoundException('Idea not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
idea.adminNote = adminNote;
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,11 @@ export class ReportsController {
|
|||||||
return this.reportsService.getDashboardKPIs();
|
return this.reportsService.getDashboardKPIs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('upcoming-investment-activities')
|
||||||
|
getUpcomingInvestmentActivities() {
|
||||||
|
return this.reportsService.getUpcomingInvestmentActivities();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('cash-flow-forecast')
|
@Get('cash-flow-forecast')
|
||||||
getCashFlowForecast(
|
getCashFlowForecast(
|
||||||
@Query('startYear') startYear?: string,
|
@Query('startYear') startYear?: string,
|
||||||
@@ -75,6 +80,13 @@ export class ReportsController {
|
|||||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('capital-planning')
|
||||||
|
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
||||||
|
return this.reportsService.getCapitalPlanningReport(
|
||||||
|
parseInt(startYear || '') || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('quarterly')
|
@Get('quarterly')
|
||||||
getQuarterlyFinancial(
|
getQuarterlyFinancial(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
|
|||||||
@@ -780,6 +780,78 @@ export class ReportsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUpcomingInvestmentActivities() {
|
||||||
|
const now = new Date();
|
||||||
|
const in45Days = new Date(now);
|
||||||
|
in45Days.setDate(in45Days.getDate() + 45);
|
||||||
|
const in60Days = new Date(now);
|
||||||
|
in60Days.setDate(in60Days.getDate() + 60);
|
||||||
|
|
||||||
|
// 1. Investments maturing within 45 days
|
||||||
|
const maturingInvestments = await this.tenant.query(`
|
||||||
|
SELECT id, name, institution, investment_type, fund_type, current_value, principal,
|
||||||
|
interest_rate, maturity_date, purchase_date
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true
|
||||||
|
AND maturity_date IS NOT NULL
|
||||||
|
AND maturity_date BETWEEN CURRENT_DATE AND $1::date
|
||||||
|
ORDER BY maturity_date ASC
|
||||||
|
`, [in45Days.toISOString().split('T')[0]]);
|
||||||
|
|
||||||
|
// Compute interest earned and days remaining for each
|
||||||
|
const maturing = maturingInvestments.map((inv: any) => {
|
||||||
|
const principal = parseFloat(inv.principal) || parseFloat(inv.current_value) || 0;
|
||||||
|
const rate = parseFloat(inv.interest_rate) || 0;
|
||||||
|
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : now;
|
||||||
|
const maturityDate = new Date(inv.maturity_date);
|
||||||
|
const daysHeld = Math.max((maturityDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||||
|
const interestEarned = principal * (rate / 100) * (daysHeld / 365);
|
||||||
|
const daysRemaining = Math.max(Math.ceil((maturityDate.getTime() - now.getTime()) / 86400000), 0);
|
||||||
|
return {
|
||||||
|
...inv,
|
||||||
|
interest_earned: interestEarned.toFixed(2),
|
||||||
|
maturity_value: (principal + interestEarned).toFixed(2),
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
activity_type: 'maturity',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Approved scenario investments due to execute within 60 days
|
||||||
|
let scenarioItems: any[] = [];
|
||||||
|
try {
|
||||||
|
scenarioItems = await this.tenant.query(`
|
||||||
|
SELECT si.id, si.label, si.investment_type, si.fund_type, si.principal,
|
||||||
|
si.interest_rate, si.purchase_date, si.maturity_date, si.institution,
|
||||||
|
bs.name as scenario_name, bs.status as scenario_status
|
||||||
|
FROM scenario_investments si
|
||||||
|
JOIN board_scenarios bs ON bs.id = si.scenario_id
|
||||||
|
WHERE bs.status = 'approved'
|
||||||
|
AND si.executed_investment_id IS NULL
|
||||||
|
AND si.purchase_date IS NOT NULL
|
||||||
|
AND si.purchase_date BETWEEN CURRENT_DATE AND $1::date
|
||||||
|
ORDER BY si.purchase_date ASC
|
||||||
|
`, [in60Days.toISOString().split('T')[0]]);
|
||||||
|
} catch {
|
||||||
|
// scenario tables may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcoming = scenarioItems.map((si: any) => {
|
||||||
|
const purchaseDate = new Date(si.purchase_date);
|
||||||
|
const daysUntil = Math.max(Math.ceil((purchaseDate.getTime() - now.getTime()) / 86400000), 0);
|
||||||
|
return {
|
||||||
|
...si,
|
||||||
|
days_until: daysUntil,
|
||||||
|
activity_type: 'planned_purchase',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
maturing_investments: maturing,
|
||||||
|
upcoming_scenario_investments: upcoming,
|
||||||
|
total_activities: maturing.length + upcoming.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
||||||
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
||||||
@@ -1264,4 +1336,120 @@ export class ReportsService {
|
|||||||
over_budget_items: overBudgetItems,
|
over_budget_items: overBudgetItems,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCapitalPlanningReport(startYear?: number) {
|
||||||
|
const baseYear = startYear || new Date().getFullYear();
|
||||||
|
const years = [baseYear, baseYear + 1, baseYear + 2, baseYear + 3, baseYear + 4];
|
||||||
|
|
||||||
|
// Get all active projects
|
||||||
|
const projects = await this.tenant.query(
|
||||||
|
`SELECT id, name, description, category, estimated_cost, target_year, target_month,
|
||||||
|
useful_life_years, last_replacement_date, next_replacement_date, fund_source,
|
||||||
|
status, priority, condition_rating
|
||||||
|
FROM projects
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY category NULLS LAST, priority, name`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also try capital_projects table
|
||||||
|
let capitalProjects: any[] = [];
|
||||||
|
try {
|
||||||
|
capitalProjects = await this.tenant.query(
|
||||||
|
`SELECT id, name, description, estimated_cost, target_year, target_month,
|
||||||
|
fund_source, status, priority, notes
|
||||||
|
FROM capital_projects
|
||||||
|
WHERE status NOT IN ('cancelled')
|
||||||
|
ORDER BY priority, name`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Table may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and group by category
|
||||||
|
const allProjects = [
|
||||||
|
...projects.map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
category: p.category || 'Uncategorized',
|
||||||
|
estimated_cost: parseFloat(p.estimated_cost) || 0,
|
||||||
|
target_year: parseInt(p.target_year) || null,
|
||||||
|
useful_life_years: parseInt(p.useful_life_years) || null,
|
||||||
|
last_replacement_date: p.last_replacement_date,
|
||||||
|
fund_source: p.fund_source || 'reserve',
|
||||||
|
status: p.status,
|
||||||
|
priority: parseInt(p.priority) || 3,
|
||||||
|
condition_rating: parseInt(p.condition_rating) || null,
|
||||||
|
})),
|
||||||
|
...capitalProjects
|
||||||
|
.filter((cp: any) => !projects.some((p: any) => p.name === cp.name && p.target_year === cp.target_year))
|
||||||
|
.map((cp: any) => ({
|
||||||
|
id: cp.id,
|
||||||
|
name: cp.name,
|
||||||
|
description: cp.description,
|
||||||
|
category: 'Capital Projects',
|
||||||
|
estimated_cost: parseFloat(cp.estimated_cost) || 0,
|
||||||
|
target_year: parseInt(cp.target_year) || null,
|
||||||
|
useful_life_years: null,
|
||||||
|
last_replacement_date: null,
|
||||||
|
fund_source: cp.fund_source || 'reserve',
|
||||||
|
status: cp.status,
|
||||||
|
priority: parseInt(cp.priority) || 3,
|
||||||
|
condition_rating: null,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const categories: Record<string, any[]> = {};
|
||||||
|
for (const project of allProjects) {
|
||||||
|
const cat = project.category;
|
||||||
|
if (!categories[cat]) categories[cat] = [];
|
||||||
|
categories[cat].push(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build year columns for each project
|
||||||
|
const categoryData = Object.entries(categories).map(([category, items]) => ({
|
||||||
|
category,
|
||||||
|
projects: items.map((p) => {
|
||||||
|
const yearAmounts: Record<number, number> = {};
|
||||||
|
let beyond = 0;
|
||||||
|
if (p.target_year) {
|
||||||
|
if (p.target_year >= years[0] && p.target_year <= years[4]) {
|
||||||
|
yearAmounts[p.target_year] = p.estimated_cost;
|
||||||
|
} else if (p.target_year > years[4]) {
|
||||||
|
beyond = p.estimated_cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
year_amounts: yearAmounts,
|
||||||
|
beyond,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Compute totals per year
|
||||||
|
const yearTotals: Record<number, number> = {};
|
||||||
|
let beyondTotal = 0;
|
||||||
|
for (const y of years) yearTotals[y] = 0;
|
||||||
|
for (const cat of categoryData) {
|
||||||
|
for (const p of cat.projects) {
|
||||||
|
for (const y of years) {
|
||||||
|
yearTotals[y] += p.year_amounts[y] || 0;
|
||||||
|
}
|
||||||
|
beyondTotal += p.beyond;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${years[4] - years[0] + 1}-YEAR CAPITAL PROJECT FORECAST`,
|
||||||
|
start_year: years[0],
|
||||||
|
years,
|
||||||
|
categories: categoryData,
|
||||||
|
year_totals: yearTotals,
|
||||||
|
beyond_total: beyondTotal,
|
||||||
|
grand_total: Object.values(yearTotals).reduce((a, b) => a + b, 0) + beyondTotal,
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
db/migrations/018-ideas.sql
Normal file
15
db/migrations/018-ideas.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Ideation feature: shared ideas table for cross-tenant idea submissions
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.ideas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC);
|
||||||
2
db/migrations/019-ideas-admin-note.sql
Normal file
2
db/migrations/019-ideas-admin-note.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add private admin note column to ideas table
|
||||||
|
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;
|
||||||
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.3.17",
|
"version": "2026.3.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"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.3.19",
|
"version": "2026.3.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import { CashFlowPage } from './pages/reports/CashFlowPage';
|
|||||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||||
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
||||||
|
import { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage';
|
||||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||||
import { AdminPage } from './pages/admin/AdminPage';
|
import { AdminPage } from './pages/admin/AdminPage';
|
||||||
|
import { AdminIdeasPage } from './pages/admin/AdminIdeasPage';
|
||||||
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||||
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';
|
||||||
@@ -132,6 +134,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<AdminPage />} />
|
<Route index element={<AdminPage />} />
|
||||||
|
<Route path="ideas" element={<AdminIdeasPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Main app routes (require auth + org) */}
|
{/* Main app routes (require auth + org) */}
|
||||||
@@ -167,6 +170,7 @@ 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="reports/capital-planning" element={<CapitalPlanningPage />} />
|
||||||
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
|
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
|
||||||
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
||||||
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
||||||
|
|||||||
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface IdeaModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const submitIdea = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.post('/ideas', { title, description });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Failed to submit idea',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Title"
|
||||||
|
placeholder="Brief summary of your idea"
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Describe your idea in more detail (optional)"
|
||||||
|
minRows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => submitIdea.mutate()}
|
||||||
|
loading={submitIdea.isPending}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
>
|
||||||
|
Submit Idea
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
|
IconBulb,
|
||||||
} 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';
|
||||||
@@ -18,6 +19,7 @@ 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 { IdeaModal } from '../ideas/IdeaModal';
|
||||||
import logoSrc from '../../assets/logo.png';
|
import logoSrc from '../../assets/logo.png';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
@@ -28,6 +30,10 @@ export function AppLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
|
|
||||||
|
// ── Ideation State ──
|
||||||
|
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
|
||||||
|
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
|
||||||
|
|
||||||
// ── Onboarding State ──
|
// ── Onboarding State ──
|
||||||
const [showTour, setShowTour] = useState(false);
|
const [showTour, setShowTour] = useState(false);
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
@@ -121,6 +127,13 @@ export function AppLayout() {
|
|||||||
{currentOrg && (
|
{currentOrg && (
|
||||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||||
)}
|
)}
|
||||||
|
{ideationEnabled && (
|
||||||
|
<Tooltip label="Submit an idea">
|
||||||
|
<ActionIcon variant="default" size="lg" onClick={openIdeaModal} aria-label="Submit idea">
|
||||||
|
<IconBulb size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -209,6 +222,9 @@ export function AppLayout() {
|
|||||||
{/* ── Onboarding Components ── */}
|
{/* ── Onboarding Components ── */}
|
||||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||||
|
|
||||||
|
{/* ── Ideation Modal ── */}
|
||||||
|
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
IconCalculator,
|
IconCalculator,
|
||||||
IconGitCompare,
|
IconGitCompare,
|
||||||
IconScale,
|
IconScale,
|
||||||
|
IconBulb,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -94,6 +95,7 @@ const navSections = [
|
|||||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||||
{ label: 'Year-End', path: '/reports/year-end' },
|
{ label: 'Year-End', path: '/reports/year-end' },
|
||||||
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
||||||
|
{ label: 'Capital Planning', path: '/reports/capital-planning' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -131,6 +133,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
onClick={() => go('/admin')}
|
onClick={() => go('/admin')}
|
||||||
color="red"
|
color="red"
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="Idea Submissions"
|
||||||
|
leftSection={<IconBulb size={18} />}
|
||||||
|
active={location.pathname === '/admin/ideas'}
|
||||||
|
onClick={() => go('/admin/ideas')}
|
||||||
|
color="yellow"
|
||||||
|
/>
|
||||||
{organizations && organizations.length > 0 && (
|
{organizations && organizations.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
@@ -229,6 +238,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
onClick={() => go('/admin')}
|
onClick={() => go('/admin')}
|
||||||
color="red"
|
color="red"
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="Idea Submissions"
|
||||||
|
leftSection={<IconBulb size={18} />}
|
||||||
|
active={location.pathname === '/admin/ideas'}
|
||||||
|
onClick={() => go('/admin/ideas')}
|
||||||
|
color="yellow"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconAdjustments,
|
IconAdjustments,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconArrowsTransferDown,
|
||||||
} 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';
|
||||||
@@ -126,6 +127,7 @@ export function AccountsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
@@ -283,6 +285,39 @@ export function AccountsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Transfer form ──
|
||||||
|
const transferForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
fromAccountId: '',
|
||||||
|
toAccountId: '',
|
||||||
|
amount: 0,
|
||||||
|
transferDate: new Date() as Date | null,
|
||||||
|
memo: '',
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
fromAccountId: (v) => (v ? null : 'Required'),
|
||||||
|
toAccountId: (v, values) => !v ? 'Required' : v === values.fromAccountId ? 'Must be different from source' : null,
|
||||||
|
amount: (v) => (v > 0 ? null : 'Must be greater than 0'),
|
||||||
|
transferDate: (v) => (v ? null : 'Required'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferMutation = useMutation({
|
||||||
|
mutationFn: (values: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo: string }) =>
|
||||||
|
api.post('/accounts/transfer', values),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
notifications.show({ message: 'Transfer completed successfully', color: 'green' });
|
||||||
|
closeTransfer();
|
||||||
|
transferForm.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Transfer failed', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── Investment edit form ──
|
// ── Investment edit form ──
|
||||||
const invForm = useForm({
|
const invForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -408,6 +443,9 @@ export function AccountsPage() {
|
|||||||
const activeAccounts = filtered.filter((a) => a.is_active);
|
const activeAccounts = filtered.filter((a) => a.is_active);
|
||||||
const archivedAccounts = filtered.filter((a) => !a.is_active);
|
const archivedAccounts = filtered.filter((a) => !a.is_active);
|
||||||
|
|
||||||
|
// Asset accounts for transfer modal (all active asset accounts, not just filtered by search)
|
||||||
|
const assetAccounts = accounts.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset');
|
||||||
|
|
||||||
// ── Investments split by fund type ──
|
// ── Investments split by fund type ──
|
||||||
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
|
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
|
||||||
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
|
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
|
||||||
@@ -505,9 +543,14 @@ export function AccountsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<>
|
||||||
Add Account
|
<Button variant="light" leftSection={<IconArrowsTransferDown size={16} />} onClick={openTransfer}>
|
||||||
</Button>
|
Transfer Funds
|
||||||
|
</Button>
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
|
Add Account
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -854,6 +897,69 @@ export function AccountsPage() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Transfer Funds Modal */}
|
||||||
|
<Modal opened={transferOpened} onClose={closeTransfer} title="Transfer Funds Between Accounts" size="md" closeOnClickOutside={false}>
|
||||||
|
<form onSubmit={transferForm.onSubmit((values) => {
|
||||||
|
transferMutation.mutate({
|
||||||
|
...values,
|
||||||
|
transferDate: values.transferDate ? values.transferDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
})}>
|
||||||
|
<Stack>
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
|
This creates a journal entry transferring funds between asset accounts.
|
||||||
|
Both accounts will be updated in the general ledger.
|
||||||
|
</Alert>
|
||||||
|
<Select
|
||||||
|
label="From Account"
|
||||||
|
placeholder="Select source account"
|
||||||
|
required
|
||||||
|
data={assetAccounts.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
|
||||||
|
}))}
|
||||||
|
searchable
|
||||||
|
{...transferForm.getInputProps('fromAccountId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="To Account"
|
||||||
|
placeholder="Select destination account"
|
||||||
|
required
|
||||||
|
data={assetAccounts
|
||||||
|
.filter((a) => a.id !== transferForm.values.fromAccountId)
|
||||||
|
.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
|
||||||
|
}))}
|
||||||
|
searchable
|
||||||
|
{...transferForm.getInputProps('toAccountId')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Amount"
|
||||||
|
required
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
thousandSeparator=","
|
||||||
|
min={0.01}
|
||||||
|
{...transferForm.getInputProps('amount')}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Transfer Date"
|
||||||
|
required
|
||||||
|
{...transferForm.getInputProps('transferDate')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Memo (optional)"
|
||||||
|
placeholder="e.g. Monthly reserve contribution"
|
||||||
|
{...transferForm.getInputProps('memo')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" leftSection={<IconArrowsTransferDown size={16} />} loading={transferMutation.isPending}>
|
||||||
|
Complete Transfer
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Investment Edit Modal */}
|
{/* Investment Edit Modal */}
|
||||||
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
||||||
{editingInvestment && (
|
{editingInvestment && (
|
||||||
|
|||||||
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
|
||||||
|
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
|
||||||
|
Tooltip, Paper,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconBulb, IconSearch, IconNote, IconFilter,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface AdminIdea {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
adminNote: string | null;
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
userFirstName: string;
|
||||||
|
userLastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
new: 'blue',
|
||||||
|
reviewed: 'yellow',
|
||||||
|
accepted: 'green',
|
||||||
|
rejected: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'new', label: 'New' },
|
||||||
|
{ value: 'reviewed', label: 'Reviewed' },
|
||||||
|
{ value: 'accepted', label: 'Accepted' },
|
||||||
|
{ value: 'rejected', label: 'Rejected' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminIdeasPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
|
||||||
|
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||||
|
const [noteText, setNoteText] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
|
||||||
|
queryKey: ['admin-ideas'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatus = useMutation({
|
||||||
|
mutationFn: async ({ id, status }: { id: string; status: string }) => {
|
||||||
|
await api.put(`/admin/ideas/${id}/status`, { status });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
||||||
|
notifications.show({ message: 'Status updated', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateNote = useMutation({
|
||||||
|
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
|
||||||
|
await api.put(`/admin/ideas/${id}/note`, { adminNote });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
||||||
|
notifications.show({ message: 'Note saved', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openIdeaDetail = (idea: AdminIdea) => {
|
||||||
|
setSelectedIdea(idea);
|
||||||
|
setNoteText(idea.adminNote || '');
|
||||||
|
openDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNote = () => {
|
||||||
|
if (selectedIdea) {
|
||||||
|
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = (ideas || []).filter((idea) => {
|
||||||
|
const matchesSearch = !search ||
|
||||||
|
idea.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.userEmail.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchesStatus = !statusFilter || idea.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
total: ideas?.length || 0,
|
||||||
|
new: ideas?.filter(i => i.status === 'new').length || 0,
|
||||||
|
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
|
||||||
|
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
|
||||||
|
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Center h={400}><Loader /></Center>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<IconBulb size={28} />
|
||||||
|
<Title order={2}>Idea Submissions</Title>
|
||||||
|
</Group>
|
||||||
|
<Badge size="lg" variant="light">{counts.total} total</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
|
||||||
|
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
|
||||||
|
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
|
||||||
|
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
|
||||||
|
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
|
||||||
|
</Paper>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search ideas, tenants, users..."
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All statuses"
|
||||||
|
leftSection={<IconFilter size={16} />}
|
||||||
|
data={statusOptions}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={setStatusFilter}
|
||||||
|
clearable
|
||||||
|
w={160}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Ideas table */}
|
||||||
|
<Card withBorder p={0}>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th>Tenant</Table.Th>
|
||||||
|
<Table.Th>Submitted By</Table.Th>
|
||||||
|
<Table.Th>Title</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th w={40}></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={6}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((idea) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={idea.id}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => openIdeaDetail(idea)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(idea.createdAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500}>{idea.orgName}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
|
||||||
|
{idea.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{idea.adminNote && (
|
||||||
|
<Tooltip label="Has admin note">
|
||||||
|
<IconNote size={16} color="gray" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={detailOpened}
|
||||||
|
onClose={closeDetail}
|
||||||
|
title={<Text fw={600}>Idea Detail</Text>}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{selectedIdea && (
|
||||||
|
<Stack>
|
||||||
|
<Card withBorder>
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
<Text size="xs" c="dimmed">Tenant</Text>
|
||||||
|
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Submitted By</Text>
|
||||||
|
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
|
||||||
|
<Text size="xs" c="dimmed">Date</Text>
|
||||||
|
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Title</Text>
|
||||||
|
<Text size="sm">{selectedIdea.title}</Text>
|
||||||
|
{selectedIdea.description && (
|
||||||
|
<>
|
||||||
|
<Text fw={600} mt="md" mb="xs">Description</Text>
|
||||||
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Status</Text>
|
||||||
|
<Select
|
||||||
|
data={statusOptions}
|
||||||
|
value={selectedIdea.status}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (val && val !== selectedIdea.status) {
|
||||||
|
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedIdea({ ...selectedIdea, status: val });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text fw={600}>Private Admin Note</Text>
|
||||||
|
<Text size="xs" c="dimmed">Only visible to super admins</Text>
|
||||||
|
</Group>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
|
||||||
|
minRows={3}
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
mt="xs"
|
||||||
|
onClick={handleSaveNote}
|
||||||
|
loading={updateNote.isPending}
|
||||||
|
disabled={noteText === (selectedIdea.adminNote || '')}
|
||||||
|
>
|
||||||
|
Save Note
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
||||||
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
||||||
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
||||||
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
|
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
|
||||||
} 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 { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -211,6 +211,16 @@ export function AdminPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleIdeation = useMutation({
|
||||||
|
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
|
||||||
|
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const impersonateUser = useMutation({
|
const impersonateUser = useMutation({
|
||||||
mutationFn: async (userId: string) => {
|
mutationFn: async (userId: string) => {
|
||||||
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
||||||
@@ -782,6 +792,27 @@ export function AdminPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Feature Toggles</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconBulb size={16} />
|
||||||
|
<div>
|
||||||
|
<Text size="sm">Ideation</Text>
|
||||||
|
<Text size="xs" c="dimmed">Allow users to submit feature ideas</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
checked={tenantDetail.organization.settings?.ideationEnabled === true}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (selectedOrgId) {
|
||||||
|
toggleIdeation.mutate({ orgId: selectedOrgId, enabled: e.currentTarget.checked });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<Text fw={600} mb="xs">Subscription</Text>
|
<Text fw={600} mb="xs">Subscription</Text>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||||
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
||||||
@@ -40,7 +40,7 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: projection, isLoading: projLoading } = useQuery({
|
const { data: projection, isLoading: projLoading, dataUpdatedAt: projUpdatedAt } = useQuery({
|
||||||
queryKey: ['board-planning-projection', id],
|
queryKey: ['board-planning-projection', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
||||||
@@ -49,6 +49,17 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When projection refreshes (which may create auto-renew records on the backend),
|
||||||
|
// re-fetch the scenario so the investments list picks up any new renewal records.
|
||||||
|
const [lastProjUpdate, setLastProjUpdate] = useState(0);
|
||||||
|
if (projUpdatedAt && projUpdatedAt !== lastProjUpdate) {
|
||||||
|
setLastProjUpdate(projUpdatedAt);
|
||||||
|
if (lastProjUpdate > 0) {
|
||||||
|
// Only re-fetch after a real update (not the initial load)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
|
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -100,12 +111,40 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Compute shared time range for aligned charts (must be above early returns to satisfy Rules of Hooks)
|
||||||
|
const investments = scenario?.investments || [];
|
||||||
|
const summary = projection?.summary;
|
||||||
|
|
||||||
|
const { sharedStartDate, sharedEndDate } = useMemo(() => {
|
||||||
|
const allDates: Date[] = [];
|
||||||
|
|
||||||
|
// Dates from investments
|
||||||
|
for (const inv of investments) {
|
||||||
|
if (inv.purchase_date) allDates.push(new Date(inv.purchase_date));
|
||||||
|
if (inv.maturity_date) allDates.push(new Date(inv.maturity_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dates from projection datapoints
|
||||||
|
const dps = projection?.datapoints || [];
|
||||||
|
if (dps.length > 0) {
|
||||||
|
allDates.push(new Date(dps[0].year, dps[0].monthNum - 1, 1));
|
||||||
|
const last = dps[dps.length - 1];
|
||||||
|
allDates.push(new Date(last.year, last.monthNum - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allDates.length === 0) return { sharedStartDate: undefined, sharedEndDate: undefined };
|
||||||
|
|
||||||
|
const min = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||||
|
const max = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||||
|
return {
|
||||||
|
sharedStartDate: new Date(min.getFullYear(), min.getMonth(), 1),
|
||||||
|
sharedEndDate: new Date(max.getFullYear(), max.getMonth(), 1),
|
||||||
|
};
|
||||||
|
}, [investments, projection]);
|
||||||
|
|
||||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></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
|
// Build a lookup of per-investment interest from the projection
|
||||||
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
||||||
if (summary?.investment_interest_details) {
|
if (summary?.investment_interest_details) {
|
||||||
@@ -259,7 +298,13 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Investment Timeline */}
|
{/* Investment Timeline */}
|
||||||
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
|
{investments.length > 0 && (
|
||||||
|
<InvestmentTimeline
|
||||||
|
investments={investments}
|
||||||
|
sharedStartDate={sharedStartDate}
|
||||||
|
sharedEndDate={sharedEndDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Projection Chart */}
|
{/* Projection Chart */}
|
||||||
{projection && (
|
{projection && (
|
||||||
@@ -267,6 +312,8 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
datapoints={projection.datapoints || []}
|
datapoints={projection.datapoints || []}
|
||||||
title="Scenario Projection"
|
title="Scenario Projection"
|
||||||
summary={projection.summary}
|
summary={projection.summary}
|
||||||
|
sharedStartDate={sharedStartDate}
|
||||||
|
sharedEndDate={sharedEndDate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{projLoading && <Center py="xl"><Loader /></Center>}
|
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ const typeColors: Record<string, string> = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
investments: any[];
|
investments: any[];
|
||||||
|
/** Optional shared time range to align with ProjectionChart */
|
||||||
|
sharedStartDate?: Date;
|
||||||
|
sharedEndDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvestmentTimeline({ investments }: Props) {
|
export function InvestmentTimeline({ investments, sharedStartDate, sharedEndDate }: Props) {
|
||||||
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const items = investments
|
const items = investments
|
||||||
@@ -28,16 +31,24 @@ export function InvestmentTimeline({ investments }: Props) {
|
|||||||
|
|
||||||
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
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[];
|
// Use shared range if provided (to align with ProjectionChart), otherwise compute from investments
|
||||||
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
let startDate: Date;
|
||||||
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
let endDate: Date;
|
||||||
|
if (sharedStartDate && sharedEndDate) {
|
||||||
|
startDate = sharedStartDate;
|
||||||
|
endDate = sharedEndDate;
|
||||||
|
} else {
|
||||||
|
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
||||||
|
startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||||
|
endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||||
|
}
|
||||||
const totalMonths = Math.max(
|
const totalMonths = Math.max(
|
||||||
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { items, startDate, endDate, totalMonths };
|
return { items, startDate, endDate, totalMonths };
|
||||||
}, [investments]);
|
}, [investments, sharedStartDate, sharedEndDate]);
|
||||||
|
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,18 +23,31 @@ interface Props {
|
|||||||
datapoints: Datapoint[];
|
datapoints: Datapoint[];
|
||||||
title?: string;
|
title?: string;
|
||||||
summary?: any;
|
summary?: any;
|
||||||
|
/** Optional shared time range to align with InvestmentTimeline */
|
||||||
|
sharedStartDate?: Date;
|
||||||
|
sharedEndDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
|
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary, sharedStartDate, sharedEndDate }: Props) {
|
||||||
const [fundFilter, setFundFilter] = useState('all');
|
const [fundFilter, setFundFilter] = useState('all');
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
return datapoints.map((d) => ({
|
let filtered = datapoints;
|
||||||
|
// If shared range provided, filter datapoints to match
|
||||||
|
if (sharedStartDate && sharedEndDate) {
|
||||||
|
const startKey = sharedStartDate.getFullYear() * 12 + sharedStartDate.getMonth();
|
||||||
|
const endKey = sharedEndDate.getFullYear() * 12 + sharedEndDate.getMonth();
|
||||||
|
filtered = datapoints.filter((d) => {
|
||||||
|
const dpKey = d.year * 12 + (d.monthNum - 1);
|
||||||
|
return dpKey >= startKey && dpKey <= endKey;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return filtered.map((d) => ({
|
||||||
...d,
|
...d,
|
||||||
label: `${d.month}`,
|
label: `${d.month}`,
|
||||||
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||||
}));
|
}));
|
||||||
}, [datapoints]);
|
}, [datapoints, sharedStartDate, sharedEndDate]);
|
||||||
|
|
||||||
// Find first forecast month for reference line
|
// Find first forecast month for reference line
|
||||||
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
IconHeartbeat,
|
IconHeartbeat,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconCoin,
|
||||||
|
IconCalendarEvent,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -362,6 +364,16 @@ export function DashboardPage() {
|
|||||||
enabled: !!currentOrg,
|
enabled: !!currentOrg,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: investmentActivities } = useQuery<{
|
||||||
|
maturing_investments: any[];
|
||||||
|
upcoming_scenario_investments: any[];
|
||||||
|
total_activities: number;
|
||||||
|
}>({
|
||||||
|
queryKey: ['upcoming-investment-activities'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/reports/upcoming-investment-activities'); return data; },
|
||||||
|
enabled: !!currentOrg,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: healthScores } = useQuery<HealthScoresData>({
|
const { data: healthScores } = useQuery<HealthScoresData>({
|
||||||
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; },
|
||||||
@@ -531,6 +543,97 @@ export function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Upcoming Investment Activities */}
|
||||||
|
{(investmentActivities?.total_activities || 0) > 0 && (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon color="teal" variant="light" size={28} radius="md">
|
||||||
|
<IconCalendarEvent size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={4}>Upcoming Investment Activities</Title>
|
||||||
|
</Group>
|
||||||
|
<Badge variant="light" color="teal">{investmentActivities?.total_activities} upcoming</Badge>
|
||||||
|
</Group>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Activity</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
<Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th>Timeline</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(investmentActivities?.maturing_investments || []).map((inv: any) => (
|
||||||
|
<Table.Tr key={`mat-${inv.id}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconCoin size={14} color="var(--mantine-color-orange-6)" />
|
||||||
|
<Text size="sm" fw={500}>{inv.name}</Text>
|
||||||
|
</Group>
|
||||||
|
{inv.institution && <Text size="xs" c="dimmed">{inv.institution}</Text>}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color="orange" variant="light">Maturing</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
|
||||||
|
{inv.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={500}>{fmt(inv.maturity_value)}</Text>
|
||||||
|
<Text size="xs" c="green">+{fmt(inv.interest_earned)} interest</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={inv.days_remaining <= 14 ? 'red' : inv.days_remaining <= 30 ? 'yellow' : 'gray'} variant="light">
|
||||||
|
{inv.days_remaining} days
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => (
|
||||||
|
<Table.Tr key={`plan-${si.id}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconTrendingUp size={14} color="var(--mantine-color-blue-6)" />
|
||||||
|
<Text size="sm" fw={500}>{si.label}</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">Scenario: {si.scenario_name}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color="blue" variant="light">Planned Purchase</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color={si.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
|
||||||
|
{si.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={500}>{fmt(si.principal)}</Text>
|
||||||
|
{si.interest_rate && <Text size="xs" c="dimmed">{parseFloat(si.interest_rate).toFixed(2)}% APY</Text>}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{new Date(si.purchase_date).toLocaleDateString()}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={si.days_until <= 14 ? 'red' : si.days_until <= 30 ? 'yellow' : 'gray'} variant="light">
|
||||||
|
{si.days_until} days
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Title order={4}>Quick Stats</Title>
|
<Title order={4}>Quick Stats</Title>
|
||||||
|
|||||||
196
frontend/src/pages/reports/CapitalPlanningPage.tsx
Normal file
196
frontend/src/pages/reports/CapitalPlanningPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
|
||||||
|
Button, NumberInput,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconPrinter } from '@tabler/icons-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface ProjectItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
estimated_cost: number;
|
||||||
|
target_year: number | null;
|
||||||
|
useful_life_years: number | null;
|
||||||
|
last_replacement_date: string | null;
|
||||||
|
fund_source: string;
|
||||||
|
status: string;
|
||||||
|
priority: number;
|
||||||
|
condition_rating: number | null;
|
||||||
|
year_amounts: Record<number, number>;
|
||||||
|
beyond: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryGroup {
|
||||||
|
category: string;
|
||||||
|
projects: ProjectItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapitalPlanningData {
|
||||||
|
title: string;
|
||||||
|
start_year: number;
|
||||||
|
years: number[];
|
||||||
|
categories: CategoryGroup[];
|
||||||
|
year_totals: Record<number, number>;
|
||||||
|
beyond_total: number;
|
||||||
|
grand_total: number;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v: number) =>
|
||||||
|
v === 0 ? '-' : v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
export function CapitalPlanningPage() {
|
||||||
|
const [startYear, setStartYear] = useState(new Date().getFullYear());
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<CapitalPlanningData>({
|
||||||
|
queryKey: ['capital-planning', startYear],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/reports/capital-planning?startYear=${startYear}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
const years = data?.years || [];
|
||||||
|
const hasProjects = (data?.categories || []).some((c) => c.projects.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Capital Planning Report</Title>
|
||||||
|
<Text c="dimmed" size="sm">{data?.title || '5-Year Capital Project Forecast'}</Text>
|
||||||
|
</div>
|
||||||
|
<Group>
|
||||||
|
<NumberInput
|
||||||
|
size="xs"
|
||||||
|
w={100}
|
||||||
|
value={startYear}
|
||||||
|
onChange={(v) => v && setStartYear(Number(v))}
|
||||||
|
min={2020}
|
||||||
|
max={2050}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconPrinter size={16} />}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
>
|
||||||
|
Print / PDF
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!hasProjects ? (
|
||||||
|
<Card withBorder p="xl">
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No capital projects found. Add projects on the Projects page to generate this report.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card withBorder p="lg" className="capital-planning-print">
|
||||||
|
<Title order={3} ta="center" mb="xs">{data?.title}</Title>
|
||||||
|
<Text ta="center" c="dimmed" size="sm" mb="md">
|
||||||
|
Generated {new Date(data?.generated_at || '').toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Table striped withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th ta="center" w={60}>Life (yr)</Table.Th>
|
||||||
|
<Table.Th ta="center" w={90}>Last Done</Table.Th>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Th key={y} ta="right" w={100}>{y}</Table.Th>
|
||||||
|
))}
|
||||||
|
<Table.Th ta="right" w={100}>Beyond</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(data?.categories || []).map((cat) => {
|
||||||
|
const catTotals: Record<number, number> = {};
|
||||||
|
let catBeyond = 0;
|
||||||
|
for (const y of years) catTotals[y] = 0;
|
||||||
|
for (const p of cat.projects) {
|
||||||
|
for (const y of years) catTotals[y] += p.year_amounts[y] || 0;
|
||||||
|
catBeyond += p.beyond;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Table.Tr key={`cat-${cat.category}`} style={{ background: 'var(--mantine-color-blue-0)' }}>
|
||||||
|
<Table.Td colSpan={3 + years.length + 1}>
|
||||||
|
<Text fw={700} size="sm">{cat.category}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>,
|
||||||
|
...cat.projects.map((p) => (
|
||||||
|
<Table.Tr key={p.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{p.name}</Text>
|
||||||
|
{p.status !== 'planned' && (
|
||||||
|
<Badge size="xs" variant="light" ml={4}
|
||||||
|
color={p.status === 'completed' ? 'green' : p.status === 'in_progress' ? 'blue' : 'gray'}>
|
||||||
|
{p.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Text size="sm">{p.useful_life_years || '-'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Text size="sm">
|
||||||
|
{p.last_replacement_date
|
||||||
|
? new Date(p.last_replacement_date).getFullYear()
|
||||||
|
: '-'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text size="sm">{fmt(p.year_amounts[y] || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm">{fmt(p.beyond)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)),
|
||||||
|
<Table.Tr key={`subtotal-${cat.category}`} style={{ borderTop: '2px solid var(--mantine-color-gray-4)' }}>
|
||||||
|
<Table.Td colSpan={3}>
|
||||||
|
<Text size="sm" fw={600} fs="italic">Subtotal — {cat.category}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={600}>{fmt(catTotals[y])}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={600}>{fmt(catBeyond)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>,
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
<Table.Tfoot>
|
||||||
|
<Table.Tr style={{ background: 'var(--mantine-color-dark-0)' }}>
|
||||||
|
<Table.Td colSpan={3}>
|
||||||
|
<Text fw={700}>TOTAL</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text fw={700}>{fmt(data?.year_totals[y] || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text fw={700}>{fmt(data?.beyond_total || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tfoot>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -237,7 +237,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.18</Badge>
|
<Badge variant="light">2026.4.2</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>
|
||||||
|
|||||||
183
load-tests/auth-dashboard-flow.js
Normal file
183
load-tests/auth-dashboard-flow.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* HOALedgerIQ – Auth + Dashboard Load Test
|
||||||
|
* Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout
|
||||||
|
*
|
||||||
|
* Covers the highest-frequency production flow: a treasurer or admin
|
||||||
|
* opening the app, loading the dashboard, and reviewing financial reports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep, group } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Trend, Rate, Counter } from 'k6/metrics';
|
||||||
|
|
||||||
|
// ── Custom metrics ──────────────────────────────────────────────────────────
|
||||||
|
const loginDuration = new Trend('login_duration', true);
|
||||||
|
const dashboardDuration = new Trend('dashboard_duration', true);
|
||||||
|
const refreshDuration = new Trend('refresh_duration', true);
|
||||||
|
const authErrorRate = new Rate('auth_error_rate');
|
||||||
|
const dashboardErrorRate = new Rate('dashboard_error_rate');
|
||||||
|
const tokenRefreshCount = new Counter('token_refresh_count');
|
||||||
|
|
||||||
|
// ── User pool ────────────────────────────────────────────────────────────────
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
return open('../config/user-pool.csv')
|
||||||
|
.split('\n')
|
||||||
|
.slice(1) // skip header row
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => {
|
||||||
|
const [email, password, orgId, role] = line.split(',');
|
||||||
|
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Environment config ───────────────────────────────────────────────────────
|
||||||
|
const ENV = __ENV.TARGET_ENV || 'staging';
|
||||||
|
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
|
||||||
|
const BASE_URL = envConfig.baseUrl;
|
||||||
|
|
||||||
|
// ── Test options ─────────────────────────────────────────────────────────────
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
auth_dashboard: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 20 }, // warm up
|
||||||
|
{ duration: '5m', target: 100 }, // ramp to target load
|
||||||
|
{ duration: '5m', target: 100 }, // sustained load
|
||||||
|
{ duration: '3m', target: 200 }, // peak spike
|
||||||
|
{ duration: '2m', target: 0 }, // ramp down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
// Latency targets per environment (overridden by environments.json)
|
||||||
|
'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`],
|
||||||
|
'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
|
||||||
|
'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`],
|
||||||
|
'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function authHeaders(token) {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main scenario ────────────────────────────────────────────────────────────
|
||||||
|
export default function () {
|
||||||
|
const user = users[__VU % users.length];
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
// ── 1. Login ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:login', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
JSON.stringify({ email: user.email, password: user.password }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
loginDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'login 200': r => r.status === 200,
|
||||||
|
'has access_token': r => r.json('access_token') !== undefined,
|
||||||
|
'has orgId in body': r => r.json('user.orgId') !== undefined,
|
||||||
|
});
|
||||||
|
authErrorRate.add(!ok);
|
||||||
|
if (!ok) { sleep(1); return; }
|
||||||
|
|
||||||
|
accessToken = res.json('access_token');
|
||||||
|
// httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessToken) return;
|
||||||
|
sleep(1.5); // think time – user lands on dashboard
|
||||||
|
|
||||||
|
// ── 2. Load dashboard & key reports in parallel ─────────────────────────
|
||||||
|
group('dashboard:load', () => {
|
||||||
|
const requests = {
|
||||||
|
dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`],
|
||||||
|
balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`],
|
||||||
|
income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`],
|
||||||
|
profile: ['GET', `${BASE_URL}/api/auth/profile`],
|
||||||
|
accounts: ['GET', `${BASE_URL}/api/accounts`],
|
||||||
|
};
|
||||||
|
|
||||||
|
const responses = http.batch(
|
||||||
|
Object.entries(requests).map(([name, [method, url]]) => ({
|
||||||
|
method, url,
|
||||||
|
params: { headers: authHeaders(accessToken), tags: { name } },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
let allOk = true;
|
||||||
|
responses.forEach((res, i) => {
|
||||||
|
const name = Object.keys(requests)[i];
|
||||||
|
dashboardDuration.add(res.timings.duration, { endpoint: name });
|
||||||
|
const ok = check(res, {
|
||||||
|
[`${name} 200`]: r => r.status === 200,
|
||||||
|
[`${name} has body`]: r => r.body && r.body.length > 0,
|
||||||
|
});
|
||||||
|
if (!ok) allOk = false;
|
||||||
|
});
|
||||||
|
dashboardErrorRate.add(!allOk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2); // user reads the dashboard
|
||||||
|
|
||||||
|
// ── 3. Simulate token refresh (happens automatically in-app at 55min) ────
|
||||||
|
// In the load test we trigger it early to validate the refresh path under load
|
||||||
|
group('auth:refresh', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/refresh`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
headers: authHeaders(accessToken),
|
||||||
|
tags: { name: 'refresh' },
|
||||||
|
// k6 sends the httpOnly cookie from the jar automatically
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
refreshDuration.add(res.timings.duration);
|
||||||
|
tokenRefreshCount.add(1);
|
||||||
|
const ok = check(res, {
|
||||||
|
'refresh 200': r => r.status === 200,
|
||||||
|
'new access_token': r => r.json('access_token') !== undefined,
|
||||||
|
});
|
||||||
|
authErrorRate.add(!ok);
|
||||||
|
if (ok) accessToken = res.json('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 4. Drill into one report (cash-flow forecast – typically slowest) ────
|
||||||
|
group('dashboard:drill', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/reports/cash-flow-forecast`,
|
||||||
|
{ headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } }
|
||||||
|
);
|
||||||
|
dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' });
|
||||||
|
dashboardErrorRate.add(res.status !== 200);
|
||||||
|
check(res, { 'forecast 200': r => r.status === 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// ── 5. Logout ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:logout', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/logout`,
|
||||||
|
null,
|
||||||
|
{ headers: authHeaders(accessToken), tags: { name: 'logout' } }
|
||||||
|
);
|
||||||
|
check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
45
load-tests/baseline.json
Normal file
45
load-tests/baseline.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "Baseline p50/p95/p99 latency targets per endpoint. Update after each cycle where improvements are confirmed. Claude Code will tighten k6 thresholds in environments.json to match.",
|
||||||
|
"last_updated": "YYYY-MM-DD",
|
||||||
|
"last_run_cycle": 0,
|
||||||
|
"units": "milliseconds"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"POST /api/auth/login": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/auth/refresh": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/auth/logout": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/auth/profile": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"GET /api/reports/dashboard": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/balance-sheet": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/income-statement": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/cash-flow": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/cash-flow-forecast": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/aging": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/quarterly": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"journal_entries": {
|
||||||
|
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"budgets": {
|
||||||
|
"GET /api/budgets/:year": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/budgets/:year/vs-actual": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"invoices": {
|
||||||
|
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/invoices/generate-preview": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"payments": {
|
||||||
|
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
}
|
||||||
|
}
|
||||||
259
load-tests/crud-flow.js
Normal file
259
load-tests/crud-flow.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* HOALedgerIQ – Core CRUD Workflow Load Test
|
||||||
|
* Journey: Login → Create Journal Entry → Post It → Create Invoice →
|
||||||
|
* Record Payment → View Accounts → Budget vs Actual → Logout
|
||||||
|
*
|
||||||
|
* This scenario exercises write-heavy paths gated by WriteAccessGuard
|
||||||
|
* and the TenantMiddleware schema-switch. Run this alongside
|
||||||
|
* auth-dashboard-flow.js to simulate a realistic mixed workload.
|
||||||
|
*
|
||||||
|
* Role used: treasurer (has full write access, most common power user)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep, group } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Trend, Rate } from 'k6/metrics';
|
||||||
|
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
|
||||||
|
|
||||||
|
// ── Custom metrics ──────────────────────────────────────────────────────────
|
||||||
|
const journalEntryDuration = new Trend('journal_entry_duration', true);
|
||||||
|
const invoiceDuration = new Trend('invoice_duration', true);
|
||||||
|
const paymentDuration = new Trend('payment_duration', true);
|
||||||
|
const accountsReadDuration = new Trend('accounts_read_duration', true);
|
||||||
|
const budgetDuration = new Trend('budget_vs_actual_duration',true);
|
||||||
|
const crudErrorRate = new Rate('crud_error_rate');
|
||||||
|
const writeGuardErrorRate = new Rate('write_guard_error_rate');
|
||||||
|
|
||||||
|
// ── User pool (treasurer + admin roles only for write access) ────────────────
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
return open('../config/user-pool.csv')
|
||||||
|
.split('\n')
|
||||||
|
.slice(1)
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => {
|
||||||
|
const [email, password, orgId, role] = line.split(',');
|
||||||
|
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||||
|
})
|
||||||
|
.filter(u => ['treasurer', 'admin', 'president', 'manager'].includes(u.role));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Environment config ───────────────────────────────────────────────────────
|
||||||
|
const ENV = __ENV.TARGET_ENV || 'staging';
|
||||||
|
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
|
||||||
|
const BASE_URL = envConfig.baseUrl;
|
||||||
|
|
||||||
|
// ── Test options ─────────────────────────────────────────────────────────────
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
crud_workflow: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 10 }, // warm up (writes need more care)
|
||||||
|
{ duration: '5m', target: 50 }, // ramp to target
|
||||||
|
{ duration: '5m', target: 50 }, // sustained
|
||||||
|
{ duration: '3m', target: 100 }, // peak
|
||||||
|
{ duration: '2m', target: 0 }, // ramp down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
'journal_entry_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'invoice_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'payment_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'accounts_read_duration': [`p(95)<${envConfig.thresholds.read_p95}`],
|
||||||
|
'budget_vs_actual_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
|
||||||
|
'crud_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'write_guard_error_rate': ['rate<0.001'], // write-guard failures should be near-zero
|
||||||
|
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function jsonHeaders(token) {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentYear() {
|
||||||
|
return new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main scenario ────────────────────────────────────────────────────────────
|
||||||
|
export default function () {
|
||||||
|
const user = users[__VU % users.length];
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
// ── 1. Login ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:login', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
JSON.stringify({ email: user.email, password: user.password }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
|
||||||
|
);
|
||||||
|
const ok = check(res, {
|
||||||
|
'login 200': r => r.status === 200,
|
||||||
|
'has access_token': r => r.json('access_token') !== undefined,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
if (!ok) { sleep(1); return; }
|
||||||
|
accessToken = res.json('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessToken) return;
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 2. Read accounts (needed to pick valid account IDs for journal entry) ─
|
||||||
|
let debitAccountId = null;
|
||||||
|
let creditAccountId = null;
|
||||||
|
|
||||||
|
group('accounts:list', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/accounts`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'accounts_list' } }
|
||||||
|
);
|
||||||
|
accountsReadDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'accounts 200': r => r.status === 200,
|
||||||
|
'accounts non-empty': r => Array.isArray(r.json()) && r.json().length > 0,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
const accounts = res.json();
|
||||||
|
// Pick first two distinct accounts for the journal entry
|
||||||
|
debitAccountId = accounts[0]?.id;
|
||||||
|
creditAccountId = accounts[1]?.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!debitAccountId || !creditAccountId) { sleep(1); return; }
|
||||||
|
sleep(1.5);
|
||||||
|
|
||||||
|
// ── 3. Create journal entry (draft) ────────────────────────────────────
|
||||||
|
let journalEntryId = null;
|
||||||
|
|
||||||
|
group('journal:create', () => {
|
||||||
|
const payload = {
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
description: `Load test entry ${uuidv4().slice(0, 8)}`,
|
||||||
|
lines: [
|
||||||
|
{ accountId: debitAccountId, type: 'debit', amount: 100.00, description: 'Load test debit' },
|
||||||
|
{ accountId: creditAccountId, type: 'credit', amount: 100.00, description: 'Load test credit' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/journal-entries`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_create' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
journalEntryDuration.add(res.timings.duration);
|
||||||
|
// Watch for WriteAccessGuard rejections (403)
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, {
|
||||||
|
'journal create 201': r => r.status === 201,
|
||||||
|
'journal has id': r => r.json('id') !== undefined,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
if (ok) journalEntryId = res.json('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 4. Post the journal entry ────────────────────────────────────────────
|
||||||
|
if (journalEntryId) {
|
||||||
|
group('journal:post', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/journal-entries/${journalEntryId}/post`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_post' } }
|
||||||
|
);
|
||||||
|
journalEntryDuration.add(res.timings.duration);
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, { 'journal post 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
sleep(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Generate invoice preview ─────────────────────────────────────────
|
||||||
|
let invoicePreviewOk = false;
|
||||||
|
group('invoice:preview', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/invoices/generate-preview`,
|
||||||
|
JSON.stringify({ period: currentYear() }),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'invoice_preview' } }
|
||||||
|
);
|
||||||
|
invoiceDuration.add(res.timings.duration);
|
||||||
|
invoicePreviewOk = check(res, { 'invoice preview 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!invoicePreviewOk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2); // user reviews invoice preview
|
||||||
|
|
||||||
|
// ── 6. Create a payment record ───────────────────────────────────────────
|
||||||
|
group('payment:create', () => {
|
||||||
|
const payload = {
|
||||||
|
amount: 150.00,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
method: 'check',
|
||||||
|
description: `Load test payment ${uuidv4().slice(0, 8)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/payments`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'payment_create' } }
|
||||||
|
);
|
||||||
|
paymentDuration.add(res.timings.duration);
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, {
|
||||||
|
'payment create 201 or 200': r => r.status === 201 || r.status === 200,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1.5);
|
||||||
|
|
||||||
|
// ── 7. Budget vs actual (typically the heaviest read query) ─────────────
|
||||||
|
group('budget:vs-actual', () => {
|
||||||
|
const year = currentYear();
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/budgets/${year}/vs-actual`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'budget_vs_actual' } }
|
||||||
|
);
|
||||||
|
budgetDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, { 'budget vs-actual 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 8. Trial balance read ────────────────────────────────────────────────
|
||||||
|
group('accounts:trial-balance', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/accounts/trial-balance`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'trial_balance' } }
|
||||||
|
);
|
||||||
|
accountsReadDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'trial balance 200': r => r.status === 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 9. Logout ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:logout', () => {
|
||||||
|
http.post(
|
||||||
|
`${BASE_URL}/api/auth/logout`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'logout' } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
117
load-tests/cycle-template.md
Normal file
117
load-tests/cycle-template.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# HOALedgerIQ – Load Test Improvement Report
|
||||||
|
**Cycle:** 001
|
||||||
|
**Date:** YYYY-MM-DD
|
||||||
|
**Test window:** HH:MM – HH:MM UTC
|
||||||
|
**Environments:** Staging (`staging.hoaledgeriq.com`)
|
||||||
|
**Scenarios run:** `auth-dashboard-flow.js` + `crud-flow.js`
|
||||||
|
**Peak VUs:** 200 (dashboard) / 100 (CRUD)
|
||||||
|
**New Relic app:** `HOALedgerIQ_App`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
> _[One paragraph: what load the system handled, what broke first, at what VU threshold, and the estimated user-facing impact. Written by Claude Code from New Relic data.]_
|
||||||
|
|
||||||
|
**Threshold breaches this cycle:**
|
||||||
|
|
||||||
|
| Metric | Target | Actual | Status |
|
||||||
|
|--------|--------|--------|--------|
|
||||||
|
| login p95 | < 300ms | — | 🔴 / 🟢 |
|
||||||
|
| dashboard p95 | < 1000ms | — | 🔴 / 🟢 |
|
||||||
|
| budget vs-actual p95 | < 1000ms | — | 🔴 / 🟢 |
|
||||||
|
| journal entry write p95 | < 1200ms | — | 🔴 / 🟢 |
|
||||||
|
| error rate | < 1% | — | 🔴 / 🟢 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### 🔴 P0 – Fix Before Next Deploy
|
||||||
|
|
||||||
|
#### Finding 001 – [Short title]
|
||||||
|
- **Symptom:** _e.g., `GET /api/reports/cash-flow-forecast` p95 = 3,400ms at 100 VUs_
|
||||||
|
- **New Relic evidence:** _e.g., DatastoreSegment shows 47 sequential DB calls per request_
|
||||||
|
- **Root cause hypothesis:** _e.g., N+1 on `reserve_components` — each component triggers a separate `SELECT` for `monthly_actuals`_
|
||||||
|
- **File:** `backend/src/modules/reports/cash-flow.service.ts:83`
|
||||||
|
- **Recommended fix:**
|
||||||
|
```typescript
|
||||||
|
// BEFORE – N+1: one query per component
|
||||||
|
for (const component of components) {
|
||||||
|
const actuals = await this.actualsRepo.findBy({ componentId: component.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER – batch load with WHERE IN
|
||||||
|
const actuals = await this.actualsRepo.findBy({
|
||||||
|
componentId: In(components.map(c => c.id))
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- **Expected improvement:** ~70% latency reduction on this endpoint
|
||||||
|
- **Effort:** Low (1–2 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 P1 – Fix Within This Sprint
|
||||||
|
|
||||||
|
#### Finding 002 – [Short title]
|
||||||
|
- **Symptom:**
|
||||||
|
- **New Relic evidence:**
|
||||||
|
- **Root cause hypothesis:**
|
||||||
|
- **File:**
|
||||||
|
- **Recommended fix:**
|
||||||
|
- **Expected improvement:**
|
||||||
|
- **Effort:**
|
||||||
|
|
||||||
|
#### Finding 003 – [Short title]
|
||||||
|
- _(same structure)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 P2 – Backlog
|
||||||
|
|
||||||
|
#### Finding 004 – [Short title]
|
||||||
|
- **Symptom:**
|
||||||
|
- **Root cause hypothesis:**
|
||||||
|
- **Recommended fix:**
|
||||||
|
- **Effort:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regression Net — Re-Test Criteria
|
||||||
|
|
||||||
|
After implementing P0 + P1 fixes, the next BlazeMeter run must pass these gates before merging to staging:
|
||||||
|
|
||||||
|
| Endpoint | Previous p95 | Target p95 | k6 Threshold |
|
||||||
|
|----------|-------------|------------|-------------|
|
||||||
|
| `GET /api/reports/cash-flow-forecast` | — | — | `p(95)<XXX` |
|
||||||
|
| `POST /api/journal-entries` | — | — | `p(95)<XXX` |
|
||||||
|
| `GET /api/budgets/:year/vs-actual` | — | — | `p(95)<XXX` |
|
||||||
|
|
||||||
|
> **Claude Code update command (run after confirming fixes):**
|
||||||
|
> ```bash
|
||||||
|
> claude "Update load-tests/analysis/baseline.json with the p95 values from
|
||||||
|
> load-tests/reports/cycle-001.md findings. Tighten the k6 thresholds in
|
||||||
|
> load-tests/config/environments.json staging block to match. Do not loosen
|
||||||
|
> any threshold that already passes."
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline Delta
|
||||||
|
|
||||||
|
| Endpoint | Cycle 000 p95 | Cycle 001 p95 | Δ |
|
||||||
|
|----------|--------------|--------------|---|
|
||||||
|
| _(populated after first run)_ | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & Observations
|
||||||
|
|
||||||
|
- _Any anomalies, flaky tests, or infrastructure events during the run_
|
||||||
|
- _Redis / BullMQ queue depth observations_
|
||||||
|
- _Rate limiter (Throttler) trip count — if >0, note which endpoints and at what VU count_
|
||||||
|
- _TenantMiddleware cache hit rate (if observable via New Relic custom attributes)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated by Claude Code. Source data in `load-tests/analysis/raw/`. Next cycle target: implement P0+P1, re-run at same peak VUs, update baselines._
|
||||||
38
load-tests/environments.json
Normal file
38
load-tests/environments.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"local": {
|
||||||
|
"baseUrl": "http://localhost:3000",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 500,
|
||||||
|
"refresh_p95": 300,
|
||||||
|
"read_p95": 1000,
|
||||||
|
"write_p95": 1500,
|
||||||
|
"dashboard_p95": 1500,
|
||||||
|
"global_p99": 3000,
|
||||||
|
"error_rate": 0.05
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"baseUrl": "https://staging.hoaledgeriq.com",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 300,
|
||||||
|
"refresh_p95": 200,
|
||||||
|
"read_p95": 800,
|
||||||
|
"write_p95": 1200,
|
||||||
|
"dashboard_p95": 1000,
|
||||||
|
"global_p99": 2000,
|
||||||
|
"error_rate": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"baseUrl": "https://app.hoaledgeriq.com",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 200,
|
||||||
|
"refresh_p95": 150,
|
||||||
|
"read_p95": 500,
|
||||||
|
"write_p95": 800,
|
||||||
|
"dashboard_p95": 700,
|
||||||
|
"global_p99": 1500,
|
||||||
|
"error_rate": 0.005
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
274
load-tests/nrql-queries.sql
Normal file
274
load-tests/nrql-queries.sql
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- HOALedgerIQ – New Relic NRQL Query Library
|
||||||
|
-- App name: HOALedgerIQ_App
|
||||||
|
-- Usage: Run in New Relic Query Builder. Replace time windows as needed.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 1: OVERVIEW HEALTH ────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 1.1 Apdex score over last test window
|
||||||
|
SELECT apdex(duration, t: 0.5) AS 'Apdex'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 1.2 Overall throughput (requests per minute)
|
||||||
|
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 1.3 Error rate over time
|
||||||
|
SELECT percentage(count(*), WHERE error IS true) AS 'Error %'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 2: LATENCY BY ENDPOINT ────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 2.1 p50 / p95 / p99 latency by transaction name
|
||||||
|
SELECT percentile(duration, 50, 95, 99) AS 'ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 30
|
||||||
|
|
||||||
|
-- 2.2 Slowest endpoints (p95) during load test window
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY percentile(duration, 95) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 2.3 Auth endpoint latency breakdown
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%auth%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.4 Report endpoint latency (typically slowest reads)
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%reports%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.5 Write endpoint latency (journal-entries, payments, invoices)
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (name LIKE '%journal-entries%' OR name LIKE '%payments%' OR name LIKE '%invoices%')
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.6 Latency heatmap over time for dashboard load
|
||||||
|
SELECT histogram(duration, width: 100, buckets: 20)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%reports/dashboard%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 3: DATABASE PERFORMANCE ──────────────────────────────────────
|
||||||
|
|
||||||
|
-- 3.1 Slowest database queries (top 20)
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.2 Database call count by operation type
|
||||||
|
SELECT count(*)
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET operation
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 3.3 N+1 detection – high-call-count queries
|
||||||
|
SELECT count(*) AS 'call count', average(duration) AS 'avg ms'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY count(*) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.4 DB time as % of total transaction time (per endpoint)
|
||||||
|
SELECT average(databaseDuration) / average(duration) * 100 AS '% DB time'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND databaseDuration IS NOT NULL
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(databaseDuration) / average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.5 Connection pool pressure (slow queries that may indicate pool exhaustion)
|
||||||
|
SELECT count(*) AS 'slow queries (>500ms)'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND duration > 0.5
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 3.6 Multi-tenant schema switch overhead (TenantMiddleware)
|
||||||
|
SELECT average(duration) AS 'avg ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name NOT LIKE '%auth/login%'
|
||||||
|
AND name NOT LIKE '%auth/refresh%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 4: ERROR ANALYSIS ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 4.1 All errors by class and message
|
||||||
|
SELECT count(*), latest(errorMessage)
|
||||||
|
FROM TransactionError
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET errorClass, errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 30
|
||||||
|
|
||||||
|
-- 4.2 Error rate by HTTP status code
|
||||||
|
SELECT count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode >= 400
|
||||||
|
FACET httpResponseCode
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 4.3 403 errors (WriteAccessGuard rejections under load)
|
||||||
|
SELECT count(*) AS '403 Forbidden'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 403
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.4 429 errors (rate limiter – Throttler)
|
||||||
|
SELECT count(*) AS '429 Rate Limited'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 429
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.5 500 errors by endpoint
|
||||||
|
SELECT count(*), latest(errorMessage)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 500
|
||||||
|
FACET name, errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.6 JWT / auth failures
|
||||||
|
SELECT count(*)
|
||||||
|
FROM TransactionError
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (errorMessage LIKE '%jwt%' OR errorMessage LIKE '%token%' OR errorMessage LIKE '%unauthorized%')
|
||||||
|
FACET errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 5: INFRASTRUCTURE (during test window) ───────────────────────
|
||||||
|
|
||||||
|
-- 5.1 CPU utilization
|
||||||
|
SELECT average(cpuPercent) AS 'CPU %'
|
||||||
|
FROM SystemSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 5.2 Memory utilization
|
||||||
|
SELECT average(memoryUsedPercent) AS 'Memory %'
|
||||||
|
FROM SystemSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 5.3 Network I/O
|
||||||
|
SELECT average(transmitBytesPerSecond) AS 'TX bytes/s',
|
||||||
|
average(receiveBytesPerSecond) AS 'RX bytes/s'
|
||||||
|
FROM NetworkSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 6: REDIS / BULLMQ ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 6.1 External call latency (Redis)
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM ExternalSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (name LIKE '%redis%' OR host LIKE '%redis%')
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 6.2 All external service latency
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM ExternalSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET host
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 7: BASELINE COMPARISON ───────────────────────────────────────
|
||||||
|
|
||||||
|
-- 7.1 Compare this run vs last run (adjust SINCE/UNTIL for your windows)
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 this run'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE '2025-01-01 10:00:00' UNTIL '2025-01-01 11:00:00'
|
||||||
|
-- Run again with previous window dates to compare
|
||||||
|
|
||||||
|
-- 7.2 Regression check – endpoints that crossed p95 threshold
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND percentile(duration, 95) > 800 -- adjust to your staging threshold
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 8: TENANT-AWARE ANALYSIS ──────────────────────────────────────
|
||||||
|
|
||||||
|
-- 8.1 Performance by org (if orgId is in custom attributes)
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms', count(*) AS 'requests'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET custom.orgId
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 8.2 Transactions without orgId (potential TenantMiddleware misses)
|
||||||
|
SELECT count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND custom.orgId IS NULL
|
||||||
|
AND name NOT LIKE '%auth/login%'
|
||||||
|
AND name NOT LIKE '%auth/register%'
|
||||||
|
AND name NOT LIKE '%health%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
15
load-tests/user-pool.csv
Normal file
15
load-tests/user-pool.csv
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
email,password,orgId,role
|
||||||
|
treasurer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,treasurer
|
||||||
|
treasurer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,treasurer
|
||||||
|
treasurer03@loadtest.hoaledgeriq.com,LoadTest123!,org-003,treasurer
|
||||||
|
admin01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,admin
|
||||||
|
admin02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,admin
|
||||||
|
president01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,president
|
||||||
|
president02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,president
|
||||||
|
manager01@loadtest.hoaledgeriq.com,LoadTest123!,org-003,manager
|
||||||
|
manager02@loadtest.hoaledgeriq.com,LoadTest123!,org-004,manager
|
||||||
|
viewer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,viewer
|
||||||
|
viewer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,viewer
|
||||||
|
homeowner01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,homeowner
|
||||||
|
homeowner02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,homeowner
|
||||||
|
member01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,member_at_large
|
||||||
|
Reference in New Issue
Block a user