13 Commits

Author SHA1 Message Date
a550a8d0be Add deployment guide for staging Docker servers with DB backup/restore
Covers fresh server setup, environment configuration, database backup
(full and per-tenant), restore into staged environment, migration
execution, and verification steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:09:32 -05:00
063741adc7 Capital Planning: add Unscheduled bucket for imported projects without target_year
Projects imported via CSV that lack a target_year were invisible in Capital
Planning because findForPlanning() filtered on target_year IS NOT NULL. This
removes that filter and adds an "Unscheduled" Kanban column (orange background,
2-col layout) so users can drag unscheduled projects into year buckets.
Also bumps app version to 2026.3.2 (beta).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:03:39 -05:00
ad2f16d93b Capital Planning: show beyond-window projects in Future bucket, 2-col layout
Projects with target years beyond the 5-year planning window now
appear in the Future column of the Kanban board (previously they
were invisible). Cards for these projects show their specific target
year as a badge. The Future column uses a 2-column grid layout when
it has more than 3 projects to maximize screen utilization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:16:20 -05:00
b0b36df4e4 Reserve health: add projected cash flow with special assessments; add Last Updated to cards
Reserve fund analysis now includes 12-month forward projection with
special assessment income (by frequency), monthly budget data,
capital project costs, and investment maturities. AI prompt updated
to evaluate projected reserve liquidity and timing risks.

Both health score dashboard cards now show a subtle "Last updated"
timestamp at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:18:34 -05:00
aa7f2dab32 Add 12-month projected cash flow to operating health score analysis
The operating health score now includes forward-looking cash flow
projections using monthly budget data, assessment income schedules,
and operating project costs. AI prompt updated to evaluate projected
liquidity, timing risks, and year-end cash position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:10:51 -05:00
d2d553eed6 Fix health scores: use correct invoices column name (amount, not amount_due)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:00:23 -05:00
2ca277b6e6 Phase 8: AI-driven operating and reserve fund health scores
Add daily AI health score calculation (0-100) for both operating and
reserve funds. Scores include trajectory tracking, factor analysis,
recommendations, and data readiness checks. Dashboard displays
graphical RingProgress gauges with color-coded scores, trend
indicators, and expandable detail popovers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:56:56 -05:00
bfcbe086f2 Fix WriteAccessGuard: use req.userRole from middleware (runs before guards)
The global WriteAccessGuard was checking req.user.role, but req.user is
set by JwtAuthGuard (a per-controller guard) which runs AFTER global guards.
TenantMiddleware sets req.userRole from the JWT before guards execute,
so we now check that property first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:21:09 -05:00
c92eb1b57b RBAC: Enforce read-only viewer role across backend and frontend
- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role
- Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations)
- Add useIsReadOnly hook to auth store for frontend role checks
- Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers
- Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers
- Skip onboarding wizard for viewer role users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:18:32 -05:00
07347a644f QoL tweaks: Cash Flow cards, auto-primary accounts, investment projections, Sankey filters
- Dashboard: Remove tenant name/role subtitle
- Cash Flow: Replace Operating/Reserve net cards with inflow vs outflow
  breakdown showing In/Out amounts and signed net; replace Ending Cash
  card with AI Financial Health status from saved recommendation
- Accounts: Auto-set first asset account per fund_type as primary on creation
- Investments: Add 5th summary card for projected annual interest earnings
- Sankey: Add Actuals/Budget/Forecast data source toggle and
  All Funds/Operating/Reserve fund filter SegmentedControls with
  backend support for budget-based and forecast (actuals+budget) queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:22:37 -05:00
f1e66966f3 Phase 7: Add user onboarding tour and tenant setup wizard
Feature 1 - How-To Intro Tour (react-joyride):
- 8-step guided walkthrough highlighting Dashboard, Accounts, Assessments,
  Transactions, Budgets, Reports, and AI Investment Planning
- Runs automatically on first login, tracked via has_seen_intro flag on user
- Centralized step config in config/tourSteps.ts for easy text editing
- data-tour attributes on Sidebar nav items and Dashboard for targeting

Feature 2 - Tenant Onboarding Wizard:
- 3-step modal wizard: create operating account, assessment group + units,
  import budget CSV
- Runs after tour completes, tracked via onboardingComplete in org settings JSONB
- Reuses existing API endpoints (POST /accounts, /assessment-groups, /units,
  /budgets/:year/import)

Backend changes:
- Add has_seen_intro column to shared.users + migration
- Add PATCH /auth/intro-seen endpoint to mark tour complete
- Add PATCH /organizations/settings endpoint for org settings updates
- Include hasSeenIntro in login response, settings in switch-org response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:47:45 -05:00
d1c40c633f Fix dashboard KPIs: resolve nested aggregate and missing column errors
- Wrap account interest query in subquery to avoid SUM(SUM(...)) nesting
- Replace nonexistent interest_earned column with current_value - principal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:11:37 -05:00
0e82e238c1 Bug & tweak sprint: fix financial calculations, add quarterly report, enhance dashboard
- Fix Accounts page: include investment accounts in Est. Monthly Interest calc,
  add Fund column to investment table, split summary cards into Operating/Reserve
- Fix Cash Flow: ending balance now respects includeInvestments toggle
- Fix Budget Manager: separate operating/reserve income in summary cards
- Fix Projects: default sort by planned_date instead of name
- Add Vendors: last_negotiated date field with migration, CSV import/export
- New Quarterly Financial Report: budget vs actuals, over-budget flagging, YTD
- Enhance Dashboard: separate Operating/Reserve fund cards, expanded Quick Stats
  with monthly interest, YTD interest earned, planned capital spend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:17:30 -05:00
53 changed files with 7356 additions and 338 deletions

View File

@@ -14,6 +14,7 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",
@@ -1592,6 +1593,19 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz",
"integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
@@ -2027,6 +2041,12 @@
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
@@ -3432,6 +3452,23 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5916,6 +5953,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "0.2.0",
"version": "2026.3.2-beta",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {
@@ -23,6 +23,7 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",

View File

@@ -1,9 +1,11 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module';
@@ -24,6 +26,8 @@ import { ProjectsModule } from './modules/projects/projects.module';
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [
@@ -62,8 +66,16 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
MonthlyActualsModule,
AttachmentsModule,
InvestmentPlanningModule,
HealthScoresModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ALLOW_VIEWER_KEY = 'allowViewer';
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);

View File

@@ -0,0 +1,35 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator';
@Injectable()
export class WriteAccessGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const method = request.method;
// Allow all read methods
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true;
// Determine role from either req.userRole (set by TenantMiddleware which runs
// before guards) or req.user.role (set by JwtAuthGuard Passport strategy).
const role = request.userRole || request.user?.role;
if (!role) return true; // unauthenticated endpoints like login/register
// Check for @AllowViewer() exemption on handler or class
const allowViewer = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_KEY, [
context.getHandler(),
context.getClass(),
]);
if (allowViewer) return true;
// Block viewer role from write operations
if (role === 'viewer') {
throw new ForbiddenException('Read-only users cannot modify data');
}
return true;
}
}

View File

@@ -202,6 +202,7 @@ export class TenantSchemaService {
default_account_id UUID REFERENCES "${s}".accounts(id),
is_active BOOLEAN DEFAULT TRUE,
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
last_negotiated DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
@@ -327,6 +328,25 @@ export class TenantSchemaService {
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Health Scores (AI-derived operating / reserve fund health)
`CREATE TABLE "${s}".health_scores (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')),
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
previous_score INTEGER,
trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')),
label VARCHAR(30),
summary TEXT,
factors JSONB,
recommendations JSONB,
missing_data JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')),
response_time_ms INTEGER,
calculated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`,
// Attachments (file storage for receipts/invoices)
`CREATE TABLE "${s}".attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

View File

@@ -142,7 +142,21 @@ export class AccountsService {
}
}
return account;
// Auto-set as primary if this is the first asset account for this fund_type
if (dto.accountType === 'asset') {
const existingPrimary = await this.tenant.query(
'SELECT id FROM accounts WHERE fund_type = $1 AND is_primary = true AND id != $2',
[dto.fundType, accountId],
);
if (!existingPrimary.length) {
await this.tenant.query(
'UPDATE accounts SET is_primary = true WHERE id = $1',
[accountId],
);
}
}
return this.findOne(accountId);
}
async update(id: string, dto: UpdateAccountDto) {

View File

@@ -1,6 +1,7 @@
import {
Controller,
Post,
Patch,
Body,
UseGuards,
Request,
@@ -13,6 +14,7 @@ import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { SwitchOrgDto } from './dto/switch-org.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
@ApiTags('auth')
@Controller('auth')
@@ -42,10 +44,21 @@ export class AuthController {
return this.authService.getProfile(req.user.sub);
}
@Patch('intro-seen')
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async markIntroSeen(@Request() req: any) {
await this.authService.markIntroSeen(req.user.sub);
return { success: true };
}
@Post('switch-org')
@ApiOperation({ summary: 'Switch active organization' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent'];

View File

@@ -131,10 +131,15 @@ export class AuthService {
id: membership.organization.id,
name: membership.organization.name,
role: membership.role,
settings: membership.organization.settings || {},
},
};
}
async markIntroSeen(userId: string): Promise<void> {
await this.usersService.markIntroSeen(userId);
}
private async recordLoginHistory(
userId: string,
organizationId: string | null,
@@ -185,6 +190,7 @@ export class AuthService {
lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { HealthScoresService } from './health-scores.service';
@ApiTags('health-scores')
@Controller('health-scores')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class HealthScoresController {
constructor(private service: HealthScoresService) {}
@Get('latest')
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
getLatest(@Req() req: any) {
const schema = req.user?.orgSchema;
return this.service.getLatestScores(schema);
}
@Post('calculate')
@ApiOperation({ summary: 'Trigger health score recalculation for current tenant' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.user?.orgSchema;
const [operating, reserve] = await Promise.all([
this.service.calculateScore(schema, 'operating'),
this.service.calculateScore(schema, 'reserve'),
]);
return { operating, reserve };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthScoresController } from './health-scores.controller';
import { HealthScoresService } from './health-scores.service';
import { HealthScoresScheduler } from './health-scores.scheduler';
@Module({
controllers: [HealthScoresController],
providers: [HealthScoresService, HealthScoresScheduler],
})
export class HealthScoresModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { HealthScoresService } from './health-scores.service';
@Injectable()
export class HealthScoresScheduler {
private readonly logger = new Logger(HealthScoresScheduler.name);
constructor(
private dataSource: DataSource,
private healthScoresService: HealthScoresService,
) {}
/**
* Run daily at 2:00 AM — calculate health scores for all active tenants.
* Uses DataSource directly to list tenants (no HTTP request context needed).
*/
@Cron('0 2 * * *')
async calculateAllTenantScores() {
this.logger.log('Starting daily health score calculation for all tenants...');
const startTime = Date.now();
try {
const orgs = await this.dataSource.query(
`SELECT id, name, schema_name FROM shared.organizations WHERE status = 'active'`,
);
this.logger.log(`Found ${orgs.length} active tenants`);
let successCount = 0;
let errorCount = 0;
for (const org of orgs) {
try {
await this.healthScoresService.calculateScore(org.schema_name, 'operating');
await this.healthScoresService.calculateScore(org.schema_name, 'reserve');
successCount++;
this.logger.log(`Health scores calculated for ${org.name} (${org.schema_name})`);
} catch (err: any) {
errorCount++;
this.logger.error(`Failed to calculate health scores for ${org.name}: ${err.message}`);
}
}
const elapsed = Date.now() - startTime;
this.logger.log(
`Daily health scores complete: ${successCount} success, ${errorCount} errors (${elapsed}ms)`,
);
} catch (err: any) {
this.logger.error(`Health score scheduler failed: ${err.message}`);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning')
@@ -36,6 +37,7 @@ export class InvestmentPlanningController {
@Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
@AllowViewer()
getRecommendations(@Req() req: any) {
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
}

View File

@@ -1,4 +1,4 @@
import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
import { Controller, Post, Get, Put, Patch, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
@@ -23,6 +23,13 @@ export class OrganizationsController {
return this.orgService.findByUser(req.user.sub);
}
@Patch('settings')
@ApiOperation({ summary: 'Update settings for the current organization' })
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
this.requireTenantAdmin(req);
return this.orgService.updateSettings(req.user.orgId, body);
}
// ── Org Member Management ──
private requireTenantAdmin(req: any) {

View File

@@ -78,6 +78,13 @@ export class OrganizationsService {
return this.orgRepository.save(org);
}
async updateSettings(id: string, settings: Record<string, any>) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new NotFoundException('Organization not found');
org.settings = { ...(org.settings || {}), ...settings };
return this.orgRepository.save(org);
}
async findByUser(userId: string) {
const memberships = await this.userOrgRepository.find({
where: { userId, isActive: true },

View File

@@ -7,7 +7,7 @@ export class ProjectsService {
async findAll() {
const projects = await this.tenant.query(
'SELECT * FROM projects WHERE is_active = true ORDER BY name',
'SELECT * FROM projects WHERE is_active = true ORDER BY planned_date NULLS LAST, target_year NULLS LAST, target_month NULLS LAST, name',
);
return this.computeFunding(projects);
}
@@ -20,7 +20,7 @@ export class ProjectsService {
async findForPlanning() {
const projects = await this.tenant.query(
'SELECT * FROM projects WHERE is_active = true AND target_year IS NOT NULL ORDER BY target_year, target_month NULLS LAST, priority',
'SELECT * FROM projects WHERE is_active = true ORDER BY target_year NULLS LAST, target_month NULLS LAST, priority',
);
return this.computeFunding(projects);
}

View File

@@ -24,8 +24,16 @@ export class ReportsController {
}
@Get('cash-flow-sankey')
getCashFlowSankey(@Query('year') year?: string) {
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
getCashFlowSankey(
@Query('year') year?: string,
@Query('source') source?: string,
@Query('fundType') fundType?: string,
) {
return this.reportsService.getCashFlowSankey(
parseInt(year || '') || new Date().getFullYear(),
source || 'actuals',
fundType || 'all',
);
}
@Get('cash-flow')
@@ -66,4 +74,20 @@ export class ReportsController {
const mo = Math.min(parseInt(months || '') || 24, 48);
return this.reportsService.getCashFlowForecast(yr, mo);
}
@Get('quarterly')
getQuarterlyFinancial(
@Query('year') year?: string,
@Query('quarter') quarter?: string,
) {
const now = new Date();
const defaultYear = now.getFullYear();
// Default to last complete quarter
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4;
const defaultQYear = currentQuarter > 1 ? defaultYear : defaultYear - 1;
const yr = parseInt(year || '') || defaultQYear;
const q = Math.min(Math.max(parseInt(quarter || '') || defaultQuarter, 1), 4);
return this.reportsService.getQuarterlyFinancial(yr, q);
}
}

View File

@@ -83,33 +83,151 @@ export class ReportsService {
};
}
async getCashFlowSankey(year: number) {
// Get income accounts with amounts
const income = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, [year]);
async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
let income: any[];
let expenses: any[];
const expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'expense' AND a.is_active = true
GROUP BY a.id, a.name, a.fund_type
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
ORDER BY amount DESC
`, [year]);
const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : '';
const fundParams = fundType !== 'all' ? [year, fundType] : [year];
const monthSum = `COALESCE(b.jan,0)+COALESCE(b.feb,0)+COALESCE(b.mar,0)+COALESCE(b.apr,0)+COALESCE(b.may,0)+COALESCE(b.jun,0)+COALESCE(b.jul,0)+COALESCE(b.aug,0)+COALESCE(b.sep,0)+COALESCE(b.oct,0)+COALESCE(b.nov,0)+COALESCE(b.dec_amt,0)`;
if (source === 'budget') {
income = await this.tenant.query(`
SELECT a.name, SUM(${monthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
HAVING SUM(${monthSum}) > 0
ORDER BY SUM(${monthSum}) DESC
`, fundParams);
expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, SUM(${monthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
HAVING SUM(${monthSum}) > 0
ORDER BY SUM(${monthSum}) DESC
`, fundParams);
} else if (source === 'forecast') {
// Combine actuals (Jan to current date) + budget (remaining months)
const now = new Date();
const currentMonth = now.getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
const remainingMonths = monthNames.slice(currentMonth + 1);
const actualsFundCond = fundType !== 'all' ? ' AND a.fund_type = $2' : '';
const actualsParams: any[] = fundType !== 'all' ? [`${year}-01-01`, fundType] : [`${year}-01-01`];
const actualsIncome = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
WHERE a.account_type = 'income' AND a.is_active = true${actualsFundCond}
GROUP BY a.id, a.name
`, actualsParams);
const actualsExpenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
WHERE a.account_type = 'expense' AND a.is_active = true${actualsFundCond}
GROUP BY a.id, a.name, a.fund_type
`, actualsParams);
// Budget for remaining months
let budgetIncome: any[] = [];
let budgetExpenses: any[] = [];
if (remainingMonths.length > 0) {
const budgetMonthSum = remainingMonths.map(m => `COALESCE(b.${m},0)`).join('+');
budgetIncome = await this.tenant.query(`
SELECT a.name, SUM(${budgetMonthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
`, fundParams);
budgetExpenses = await this.tenant.query(`
SELECT a.name, a.fund_type, SUM(${budgetMonthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
`, fundParams);
}
// Merge actuals + budget by account name
const incomeMap = new Map<string, number>();
for (const a of actualsIncome) {
const amt = parseFloat(a.amount) || 0;
if (amt > 0) incomeMap.set(a.name, (incomeMap.get(a.name) || 0) + amt);
}
for (const b of budgetIncome) {
const amt = parseFloat(b.amount) || 0;
if (amt > 0) incomeMap.set(b.name, (incomeMap.get(b.name) || 0) + amt);
}
income = Array.from(incomeMap.entries())
.map(([name, amount]) => ({ name, amount: String(amount) }))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
const expenseMap = new Map<string, { amount: number; fund_type: string }>();
for (const a of actualsExpenses) {
const amt = parseFloat(a.amount) || 0;
if (amt > 0) {
const existing = expenseMap.get(a.name);
expenseMap.set(a.name, { amount: (existing?.amount || 0) + amt, fund_type: a.fund_type });
}
}
for (const b of budgetExpenses) {
const amt = parseFloat(b.amount) || 0;
if (amt > 0) {
const existing = expenseMap.get(b.name);
expenseMap.set(b.name, { amount: (existing?.amount || 0) + amt, fund_type: b.fund_type });
}
}
expenses = Array.from(expenseMap.entries())
.map(([name, { amount, fund_type }]) => ({ name, amount: String(amount), fund_type }))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
} else {
// Actuals: query journal entries for the year
income = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, fundParams);
expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
ORDER BY amount DESC
`, fundParams);
}
if (!income.length && !expenses.length) {
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
@@ -273,7 +391,8 @@ export class ReportsService {
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance;
// Only include investment balances in ending balance when includeInvestments is toggled on
const endingBalance = parseFloat(endCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
return {
from, to,
@@ -444,24 +563,43 @@ export class ReportsService {
}
async getDashboardKPIs() {
// Total cash: ALL asset accounts (not just those named "Cash")
// Uses proper double-entry balance: debit - credit for assets
const cash = await this.tenant.query(`
// Operating cash (asset accounts, fund_type=operating)
const opCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.is_active = true
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
GROUP BY a.id
) sub
`);
// Also include investment account current_value in total cash
const investmentCash = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE is_active = true
// Reserve cash (asset accounts, fund_type=reserve)
const resCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
GROUP BY a.id
) sub
`);
const totalCash = parseFloat(cash[0]?.total || '0') + parseFloat(investmentCash[0]?.total || '0');
// Investment accounts split by fund type
const opInv = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
`);
const resInv = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
const operatingCash = parseFloat(opCash[0]?.total || '0');
const reserveCash = parseFloat(resCash[0]?.total || '0');
const operatingInvestments = parseFloat(opInv[0]?.total || '0');
const reserveInvestments = parseFloat(resInv[0]?.total || '0');
const totalCash = operatingCash + reserveCash + operatingInvestments + reserveInvestments;
// Receivables: sum of unpaid invoices
const ar = await this.tenant.query(`
@@ -469,9 +607,7 @@ export class ReportsService {
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
`);
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100)
// The equity accounts track the total reserve fund position via double-entry bookkeeping
// This is the standard HOA approach — every reserve contribution/expenditure flows through equity
// Reserve fund balance via equity accounts + reserve investments
const reserves = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
@@ -482,17 +618,43 @@ export class ReportsService {
GROUP BY a.id
) sub
`);
// Add reserve investment account values to the reserve fund total
const reserveInvestments = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
// Delinquent count (overdue invoices)
const delinquent = await this.tenant.query(`
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
`);
// Monthly interest estimate from accounts + investments with rates
const acctInterest = await this.tenant.query(`
SELECT COALESCE(SUM(sub.monthly_interest), 0) as total FROM (
SELECT (COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) * (a.interest_rate / 100) / 12 as monthly_interest
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.is_active = true AND a.interest_rate > 0
GROUP BY a.id, a.interest_rate
) sub
`);
const acctInterestTotal = parseFloat(acctInterest[0]?.total || '0');
const invInterest = await this.tenant.query(`
SELECT COALESCE(SUM(current_value * interest_rate / 100 / 12), 0) as total
FROM investment_accounts WHERE is_active = true AND interest_rate > 0
`);
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
// Interest earned YTD: approximate from current_value - principal (unrealized gains)
const interestEarned = await this.tenant.query(`
SELECT COALESCE(SUM(current_value - principal), 0) as total
FROM investment_accounts WHERE is_active = true AND current_value > principal
`);
// Planned capital spend for current year
const currentYear = new Date().getFullYear();
const capitalSpend = await this.tenant.query(`
SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
`, [currentYear]);
// Recent transactions
const recentTx = await this.tenant.query(`
SELECT je.id, je.entry_date, je.description, je.entry_type,
@@ -504,9 +666,17 @@ export class ReportsService {
return {
total_cash: totalCash.toFixed(2),
total_receivables: ar[0]?.total || '0.00',
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2),
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + reserveInvestments).toFixed(2),
delinquent_units: parseInt(delinquent[0]?.count || '0'),
recent_transactions: recentTx,
// Enhanced split data
operating_cash: operatingCash.toFixed(2),
reserve_cash: reserveCash.toFixed(2),
operating_investments: operatingInvestments.toFixed(2),
reserve_investments: reserveInvestments.toFixed(2),
est_monthly_interest: estMonthlyInterest.toFixed(2),
interest_earned_ytd: interestEarned[0]?.total || '0.00',
planned_capital_spend: capitalSpend[0]?.total || '0.00',
};
}
@@ -795,4 +965,168 @@ export class ReportsService {
datapoints,
};
}
/**
* Quarterly Financial Report: quarter income statement, YTD income statement,
* budget vs actuals for the quarter and YTD, and over-budget items.
*/
async getQuarterlyFinancial(year: number, quarter: number) {
// Quarter date ranges
const qStartMonths = [1, 4, 7, 10];
const qEndMonths = [3, 6, 9, 12];
const qStart = `${year}-${String(qStartMonths[quarter - 1]).padStart(2, '0')}-01`;
const qEndMonth = qEndMonths[quarter - 1];
const qEndDay = [31, 30, 30, 31][quarter - 1]; // Mar=31, Jun=30, Sep=30, Dec=31
const qEnd = `${year}-${String(qEndMonth).padStart(2, '0')}-${qEndDay}`;
const ytdStart = `${year}-01-01`;
// Quarter and YTD income statements (reuse existing method)
const quarterIS = await this.getIncomeStatement(qStart, qEnd);
const ytdIS = await this.getIncomeStatement(ytdStart, qEnd);
// Budget data for the quarter months
const budgetMonthCols = {
1: ['jan', 'feb', 'mar'],
2: ['apr', 'may', 'jun'],
3: ['jul', 'aug', 'sep'],
4: ['oct', 'nov', 'dec_amt'],
} as Record<number, string[]>;
const ytdMonthCols = {
1: ['jan', 'feb', 'mar'],
2: ['jan', 'feb', 'mar', 'apr', 'may', 'jun'],
3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep'],
4: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'],
} as Record<number, string[]>;
const qCols = budgetMonthCols[quarter];
const ytdCols = ytdMonthCols[quarter];
const budgetRows = await this.tenant.query(
`SELECT b.account_id, a.account_number, a.name, a.account_type, a.fund_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1`, [year],
);
// Actual amounts per account for the quarter and YTD
const quarterActuals = await this.tenant.query(`
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
`, [qStart, qEnd]);
const ytdActuals = await this.tenant.query(`
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
`, [ytdStart, qEnd]);
// Build budget vs actual comparison
const actualsByIdQ = new Map<string, number>();
for (const a of quarterActuals) {
actualsByIdQ.set(a.account_id, parseFloat(a.amount) || 0);
}
const actualsByIdYTD = new Map<string, number>();
for (const a of ytdActuals) {
actualsByIdYTD.set(a.account_id, parseFloat(a.amount) || 0);
}
const budgetVsActual: any[] = [];
const overBudgetItems: any[] = [];
for (const b of budgetRows) {
const qBudget = qCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
const ytdBudget = ytdCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
const qActual = actualsByIdQ.get(b.account_id) || 0;
const ytdActual = actualsByIdYTD.get(b.account_id) || 0;
if (qBudget === 0 && ytdBudget === 0 && qActual === 0 && ytdActual === 0) continue;
const qVariance = qActual - qBudget;
const ytdVariance = ytdActual - ytdBudget;
const isExpense = b.account_type === 'expense';
const item = {
account_id: b.account_id,
account_number: b.account_number,
name: b.name,
account_type: b.account_type,
fund_type: b.fund_type,
quarter_budget: qBudget,
quarter_actual: qActual,
quarter_variance: qVariance,
ytd_budget: ytdBudget,
ytd_actual: ytdActual,
ytd_variance: ytdVariance,
};
budgetVsActual.push(item);
// Flag expenses over budget by more than 10%
if (isExpense && qBudget > 0 && qActual > qBudget * 1.1) {
overBudgetItems.push({
...item,
variance_pct: ((qActual / qBudget - 1) * 100).toFixed(1),
});
}
}
// Also include accounts with actuals but no budget
for (const a of quarterActuals) {
if (!budgetRows.find((b: any) => b.account_id === a.account_id)) {
const ytdActual = actualsByIdYTD.get(a.account_id) || 0;
budgetVsActual.push({
account_id: a.account_id,
account_number: a.account_number,
name: a.name,
account_type: a.account_type,
fund_type: a.fund_type,
quarter_budget: 0,
quarter_actual: parseFloat(a.amount) || 0,
quarter_variance: parseFloat(a.amount) || 0,
ytd_budget: 0,
ytd_actual: ytdActual,
ytd_variance: ytdActual,
});
}
}
// Sort: income first, then expenses, both by account number
budgetVsActual.sort((a: any, b: any) => {
if (a.account_type !== b.account_type) return a.account_type === 'income' ? -1 : 1;
return (a.account_number || '').localeCompare(b.account_number || '');
});
return {
year,
quarter,
quarter_label: `Q${quarter} ${year}`,
date_range: { from: qStart, to: qEnd },
quarter_income_statement: quarterIS,
ytd_income_statement: ytdIS,
budget_vs_actual: budgetVsActual,
over_budget_items: overBudgetItems,
};
}
}

View File

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

View File

@@ -57,6 +57,10 @@ export class UsersService {
`);
}
async markIntroSeen(id: string): Promise<void> {
await this.usersRepository.update(id, { hasSeenIntro: true });
}
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
// Protect platform owner from having superadmin removed
const user = await this.usersRepository.findOne({ where: { id: userId } });

View File

@@ -17,10 +17,10 @@ export class VendorsService {
async create(dto: any) {
const rows = await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id, last_negotiated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code,
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null],
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null, dto.last_negotiated || null],
);
return rows[0];
}
@@ -32,24 +32,25 @@ export class VendorsService {
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code),
tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible),
default_account_id = COALESCE($12, default_account_id), updated_at = NOW()
default_account_id = COALESCE($12, default_account_id), last_negotiated = $13, updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id],
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id, dto.last_negotiated || null],
);
return rows[0];
}
async exportCSV(): Promise<string> {
const rows = await this.tenant.query(
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated
FROM vendors WHERE is_active = true ORDER BY name`,
);
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible'];
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible', 'last_negotiated'];
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(headers.map((h) => {
const v = r[h] ?? '';
let v = r[h] ?? '';
if (v instanceof Date) v = v.toISOString().split('T')[0];
const s = String(v);
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
}).join(','));
@@ -80,20 +81,22 @@ export class VendorsService {
zip_code = COALESCE(NULLIF($8, ''), zip_code),
tax_id = COALESCE(NULLIF($9, ''), tax_id),
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
updated_at = NOW()
WHERE id = $1`,
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible],
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible, row.last_negotiated],
);
updated++;
} else {
await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[name, row.contact_name || null, row.email || null, row.phone || null,
row.address_line1 || null, row.city || null, row.state || null,
row.zip_code || null, row.tax_id || null,
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false],
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false,
row.last_negotiated || null],
);
created++;
}

View File

@@ -0,0 +1,16 @@
-- Migration: Add last_negotiated date to vendors table
-- Bug & Tweak Sprint
DO $$
DECLARE
tenant_schema TEXT;
BEGIN
FOR tenant_schema IN
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
LOOP
EXECUTE format(
'ALTER TABLE %I.vendors ADD COLUMN IF NOT EXISTS last_negotiated DATE',
tenant_schema
);
END LOOP;
END $$;

View File

@@ -0,0 +1,9 @@
-- Migration: Add onboarding tracking flag to users table
-- Phase 7: Onboarding Features
BEGIN;
ALTER TABLE shared.users
ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
COMMIT;

View File

@@ -0,0 +1,34 @@
-- Migration: Add health_scores table to all tenant schemas
-- This table stores AI-derived operating and reserve fund health scores
DO $$
DECLARE
tenant RECORD;
BEGIN
FOR tenant IN
SELECT schema_name FROM shared.organizations WHERE status = 'active'
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I.health_scores (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
score_type VARCHAR(20) NOT NULL CHECK (score_type IN (''operating'', ''reserve'')),
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
previous_score INTEGER,
trajectory VARCHAR(20) CHECK (trajectory IN (''improving'', ''stable'', ''declining'')),
label VARCHAR(30),
summary TEXT,
factors JSONB,
recommendations JSONB,
missing_data JSONB,
status VARCHAR(20) NOT NULL DEFAULT ''complete'' CHECK (status IN (''complete'', ''pending'', ''error'')),
response_time_ms INTEGER,
calculated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
)', tenant.schema_name
);
EXECUTE format(
'CREATE INDEX IF NOT EXISTS idx_%s_hs_type_calc ON %I.health_scores(score_type, calculated_at DESC)',
replace(tenant.schema_name, '.', '_'), tenant.schema_name
);
END LOOP;
END $$;

375
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,375 @@
# HOA LedgerIQ — Deployment Guide
**Version:** 2026.3.2 (beta)
**Last updated:** 2026-03-02
---
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Deploy to a Fresh Docker Server](#deploy-to-a-fresh-docker-server)
3. [Backup the Local Test Database](#backup-the-local-test-database)
4. [Restore a Backup into the Staged Environment](#restore-a-backup-into-the-staged-environment)
5. [Running Migrations on the Staged Environment](#running-migrations-on-the-staged-environment)
6. [Verifying the Deployment](#verifying-the-deployment)
7. [Environment Variable Reference](#environment-variable-reference)
---
## Prerequisites
On the **target server**, ensure the following are installed:
| Tool | Minimum Version |
|-----------------|-----------------|
| Docker Engine | 24+ |
| Docker Compose | v2+ |
| Git | 2.x |
| `psql` (client) | 15+ *(optional, for manual DB work)* |
The app runs five containers — nginx, backend (NestJS), frontend (Vite/React),
PostgreSQL 15, and Redis 7. Total memory footprint is roughly **12 GB** idle.
---
## Deploy to a Fresh Docker Server
### 1. Clone the repository
```bash
ssh your-staging-server
git clone <repo-url> /opt/hoa-ledgeriq
cd /opt/hoa-ledgeriq
```
### 2. Create the environment file
Copy the example and fill in real values:
```bash
cp .env.example .env
nano .env # or vi, your choice
```
**Required changes from defaults:**
```dotenv
# --- CHANGE THESE ---
POSTGRES_PASSWORD=<strong-random-password>
JWT_SECRET=<random-64-char-string>
# Database URL must match the password above
DATABASE_URL=postgresql://hoafinance:<same-password>@postgres:5432/hoafinance
# AI features (get a key from build.nvidia.com)
AI_API_KEY=nvapi-xxxxxxxxxxxx
# --- Usually fine as-is ---
POSTGRES_USER=hoafinance
POSTGRES_DB=hoafinance
REDIS_URL=redis://redis:6379
NODE_ENV=development # keep as development for staging
AI_API_URL=https://integrate.api.nvidia.com/v1
AI_MODEL=qwen/qwen3.5-397b-a17b
AI_DEBUG=false
```
> **Tip:** Generate secrets quickly:
> ```bash
> openssl rand -hex 32 # good for JWT_SECRET
> openssl rand -base64 24 # good for POSTGRES_PASSWORD
> ```
### 3. Build and start the stack
```bash
docker compose up -d --build
```
This will:
- Build the backend and frontend images
- Pull `postgres:15-alpine`, `redis:7-alpine`, and `nginx:alpine`
- Initialize the PostgreSQL database with the shared schema (`db/init/00-init.sql`)
- Start all five services on the `hoanet` bridge network
### 4. Wait for healthy services
```bash
docker compose ps
```
All five containers should show `Up` (postgres and redis should also show
`(healthy)`). If the backend is restarting, check logs:
```bash
docker compose logs backend --tail=50
```
### 5. (Optional) Seed with demo data
If deploying a fresh environment for testing and you want the Sunrise Valley
HOA demo tenant:
```bash
docker compose exec -T postgres psql -U hoafinance -d hoafinance < db/seed/seed.sql
```
This creates:
- Platform admin: `admin@hoaledgeriq.com` / `password123`
- Tenant admin: `admin@sunrisevalley.org` / `password123`
- Tenant viewer: `viewer@sunrisevalley.org` / `password123`
### 6. Access the application
| Service | URL |
|-----------|--------------------------------|
| App (UI) | `http://<server-ip>` |
| API | `http://<server-ip>/api` |
| Postgres | `<server-ip>:5432` (direct) |
> **Note:** For production, add an SSL-terminating proxy (Caddy, Traefik, or
> an nginx TLS config) in front of port 80.
---
## Backup the Local Test Database
### Full database dump (recommended)
From your **local development machine** where the app is currently running:
```bash
cd /path/to/HOA_Financial_Platform
# Dump the entire database (all schemas, roles, data)
docker compose exec -T postgres pg_dump \
-U hoafinance \
-d hoafinance \
--no-owner \
--no-privileges \
--format=custom \
-f /tmp/hoafinance_backup.dump
# Copy the dump file out of the container
docker compose cp postgres:/tmp/hoafinance_backup.dump ./hoafinance_backup.dump
```
The `--format=custom` flag produces a compressed binary format that supports
selective restore. The file is typically 5080% smaller than plain SQL.
### Alternative: Plain SQL dump
If you prefer a human-readable SQL file:
```bash
docker compose exec -T postgres pg_dump \
-U hoafinance \
-d hoafinance \
--no-owner \
--no-privileges \
> hoafinance_backup.sql
```
### Backup a single tenant schema
To export just one tenant (e.g., Pine Creek HOA):
```bash
docker compose exec -T postgres pg_dump \
-U hoafinance \
-d hoafinance \
--no-owner \
--no-privileges \
--schema=tenant_pine_creek_hoa_q33i \
> pine_creek_backup.sql
```
> **Finding a tenant's schema name:**
> ```bash
> docker compose exec -T postgres psql -U hoafinance -d hoafinance \
> -c "SELECT name, schema_name FROM shared.organizations WHERE status = 'active';"
> ```
---
## Restore a Backup into the Staged Environment
### 1. Transfer the backup to the staging server
```bash
scp hoafinance_backup.dump user@staging-server:/opt/hoa-ledgeriq/
```
### 2. Ensure the stack is running
```bash
cd /opt/hoa-ledgeriq
docker compose up -d
```
### 3. Drop and recreate the database (clean slate)
```bash
# Connect to postgres and reset the database
docker compose exec -T postgres psql -U hoafinance -d postgres -c "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'hoafinance' AND pid <> pg_backend_pid();
"
docker compose exec -T postgres dropdb -U hoafinance hoafinance
docker compose exec -T postgres createdb -U hoafinance hoafinance
```
### 4a. Restore from custom-format dump
```bash
# Copy the dump into the container
docker compose cp hoafinance_backup.dump postgres:/tmp/hoafinance_backup.dump
# Restore
docker compose exec -T postgres pg_restore \
-U hoafinance \
-d hoafinance \
--no-owner \
--no-privileges \
/tmp/hoafinance_backup.dump
```
### 4b. Restore from plain SQL dump
```bash
docker compose exec -T postgres psql \
-U hoafinance \
-d hoafinance \
< hoafinance_backup.sql
```
### 5. Restart the backend
After restoring, restart the backend so NestJS re-establishes its connection
pool and picks up the restored schemas:
```bash
docker compose restart backend
```
---
## Running Migrations on the Staged Environment
Migrations live in `db/migrations/` and are numbered sequentially. After
restoring an older backup, you may need to apply newer migrations.
Check which migrations exist:
```bash
ls -la db/migrations/
```
Apply them in order:
```bash
# Run all migrations sequentially
for f in db/migrations/*.sql; do
echo "Applying $f ..."
docker compose exec -T postgres psql \
-U hoafinance \
-d hoafinance \
< "$f"
done
```
Or apply a specific migration:
```bash
docker compose exec -T postgres psql \
-U hoafinance \
-d hoafinance \
< db/migrations/010-health-scores.sql
```
> **Note:** Migrations are idempotent where possible (`IF NOT EXISTS`,
> `DO $$ ... $$` blocks), so re-running one that has already been applied
> is generally safe.
---
## Verifying the Deployment
### Quick health checks
```bash
# Backend is responding
curl -s http://localhost/api/auth/login | head -c 100
# Database is accessible
docker compose exec -T postgres psql -U hoafinance -d hoafinance \
-c "SELECT count(*) AS tenants FROM shared.organizations WHERE status = 'active';"
# Redis is working
docker compose exec -T redis redis-cli ping
```
### Full smoke test
1. Open `http://<server-ip>` in a browser
2. Log in with a known account
3. Navigate to Dashboard — verify health scores load
4. Navigate to Capital Planning — verify Kanban columns render
5. Navigate to Projects — verify project list loads
6. Check the Settings page — version should read **2026.3.2 (beta)**
### View logs
```bash
docker compose logs -f # all services
docker compose logs -f backend # backend only
docker compose logs -f postgres # database only
```
---
## Environment Variable Reference
| Variable | Required | Description |
|-------------------|----------|----------------------------------------------------|
| `POSTGRES_USER` | Yes | PostgreSQL username |
| `POSTGRES_PASSWORD`| Yes | PostgreSQL password (**change from default**) |
| `POSTGRES_DB` | Yes | Database name |
| `DATABASE_URL` | Yes | Full connection string for the backend |
| `REDIS_URL` | Yes | Redis connection string |
| `JWT_SECRET` | Yes | Secret for signing JWT tokens (**change from default**) |
| `NODE_ENV` | Yes | `development` or `production` |
| `AI_API_URL` | Yes | OpenAI-compatible inference endpoint |
| `AI_API_KEY` | Yes | API key for AI provider (Nvidia) |
| `AI_MODEL` | Yes | Model identifier for AI calls |
| `AI_DEBUG` | No | Set `true` to log raw AI prompts/responses |
---
## Architecture Overview
```
┌─────────────┐
Browser ────────► │ nginx :80 │
└──────┬──────┘
┌────────┴────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ backend :3000│ │frontend :5173│
│ (NestJS) │ │ (Vite/React) │
└──────┬───────┘ └──────────────┘
┌────┴────┐
▼ ▼
┌────────────┐ ┌───────────┐
│postgres:5432│ │redis :6379│
│ (PG 15) │ │ (Redis 7) │
└────────────┘ └───────────┘
```
**Multi-tenant isolation:** Each HOA organization gets its own PostgreSQL
schema (e.g., `tenant_pine_creek_hoa_q33i`). The `shared` schema holds
cross-tenant tables (users, organizations, market rates). Tenant context
is resolved from the JWT token on every API request.

3192
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "0.2.0",
"version": "2026.3.2-beta",
"private": true,
"type": "module",
"scripts": {

View File

@@ -22,6 +22,7 @@ import { SankeyPage } from './pages/reports/SankeyPage';
import { CashFlowPage } from './pages/reports/CashFlowPage';
import { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage';
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
@@ -135,6 +136,7 @@ export function App() {
<Route path="reports/aging" element={<AgingReportPage />} />
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} />
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} />

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
@@ -9,17 +10,53 @@ import {
IconUsersGroup,
IconEyeOff,
} from '@tabler/icons-react';
import { Outlet, useNavigate } from 'react-router-dom';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import logoSrc from '../../assets/logo.svg';
export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const isImpersonating = !!impersonationOriginal;
// ── Onboarding State ──
const [showTour, setShowTour] = useState(false);
const [showWizard, setShowWizard] = useState(false);
useEffect(() => {
// Only run for non-impersonating users with an org selected, on dashboard
if (isImpersonating || !currentOrg || !user) return;
if (!location.pathname.startsWith('/dashboard')) return;
// Read-only users (viewers) skip onboarding entirely
if (currentOrg.role === 'viewer') return;
if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) {
// Delay to ensure DOM elements are rendered for tour targeting
const timer = setTimeout(() => setShowTour(true), 800);
return () => clearTimeout(timer);
} else if (currentOrg.settings?.onboardingComplete !== true) {
setShowWizard(true);
}
}, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.role, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]);
const handleTourComplete = () => {
setShowTour(false);
// After tour, check if onboarding wizard should run
if (currentOrg && currentOrg.settings?.onboardingComplete !== true) {
// Small delay before showing wizard
setTimeout(() => setShowWizard(true), 500);
}
};
const handleWizardComplete = () => {
setShowWizard(false);
};
const handleLogout = () => {
logout();
navigate('/login');
@@ -145,6 +182,10 @@ export function AppLayout() {
<AppShell.Main>
<Outlet />
</AppShell.Main>
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
</AppShell>
);
}

View File

@@ -30,23 +30,23 @@ const navSections = [
{
label: 'Financials',
items: [
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
],
},
{
label: 'Assessments',
items: [
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
],
},
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
],
@@ -56,7 +56,7 @@ const navSections = [
items: [
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning' },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
],
},
@@ -66,6 +66,7 @@ const navSections = [
{
label: 'Reports',
icon: IconChartSankey,
tourId: 'nav-reports',
children: [
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
{ label: 'Income Statement', path: '/reports/income-statement' },
@@ -74,6 +75,7 @@ const navSections = [
{ label: 'Aging Report', path: '/reports/aging' },
{ label: 'Sankey Diagram', path: '/reports/sankey' },
{ label: 'Year-End', path: '/reports/year-end' },
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
],
},
],
@@ -127,7 +129,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
}
return (
<ScrollArea p="sm">
<ScrollArea p="sm" data-tour="sidebar-nav">
{navSections.map((section, sIdx) => (
<div key={sIdx}>
{section.label && (
@@ -147,6 +149,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
defaultOpened={item.children.some((c: any) =>
location.pathname.startsWith(c.path),
)}
data-tour={item.tourId || undefined}
>
{item.children.map((child: any) => (
<NavLink
@@ -164,6 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
leftSection={<item.icon size={18} />}
active={location.pathname === item.path}
onClick={() => go(item.path!)}
data-tour={item.tourId || undefined}
/>
),
)}

View File

@@ -0,0 +1,93 @@
import { useState, useCallback } from 'react';
import Joyride, { type CallBackProps, STATUS, ACTIONS, EVENTS } from 'react-joyride';
import { TOUR_STEPS } from '../../config/tourSteps';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
interface AppTourProps {
run: boolean;
onComplete: () => void;
}
export function AppTour({ run, onComplete }: AppTourProps) {
const [stepIndex, setStepIndex] = useState(0);
const setUserIntroSeen = useAuthStore((s) => s.setUserIntroSeen);
const handleCallback = useCallback(
async (data: CallBackProps) => {
const { status, action, type } = data;
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
if (finishedStatuses.includes(status)) {
// Mark intro as seen on backend (fire-and-forget)
api.patch('/auth/intro-seen').catch(() => {});
setUserIntroSeen();
onComplete();
return;
}
// Handle step navigation
if (type === EVENTS.STEP_AFTER) {
setStepIndex((prev) =>
action === ACTIONS.PREV ? prev - 1 : prev + 1,
);
}
},
[onComplete, setUserIntroSeen],
);
if (!run) return null;
return (
<Joyride
steps={TOUR_STEPS}
run={run}
stepIndex={stepIndex}
continuous
showProgress
showSkipButton
scrollToFirstStep
disableOverlayClose
callback={handleCallback}
styles={{
options: {
primaryColor: '#228be6',
zIndex: 10000,
arrowColor: '#fff',
backgroundColor: '#fff',
textColor: '#333',
overlayColor: 'rgba(0, 0, 0, 0.5)',
},
tooltip: {
borderRadius: 8,
fontSize: 14,
padding: 20,
},
tooltipTitle: {
fontSize: 16,
fontWeight: 600,
},
buttonNext: {
borderRadius: 6,
fontSize: 14,
padding: '8px 16px',
},
buttonBack: {
borderRadius: 6,
fontSize: 14,
marginRight: 8,
},
buttonSkip: {
fontSize: 13,
},
}}
locale={{
back: 'Previous',
close: 'Close',
last: 'Finish Tour',
next: 'Next',
skip: 'Skip Tour',
}}
/>
);
}

View File

@@ -0,0 +1,646 @@
import { useState } from 'react';
import {
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconBuildingBank, IconUsers, IconFileSpreadsheet,
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
IconAlertCircle,
} from '@tabler/icons-react';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
interface OnboardingWizardProps {
opened: boolean;
onComplete: () => void;
}
interface UnitRow {
unitNumber: string;
ownerName: string;
ownerEmail: string;
}
// ── CSV Parsing (reused from BudgetsPage pattern) ──
function parseCSV(text: string): Record<string, string>[] {
const lines = text.split('\n').filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
return lines.slice(1).map((line) => {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes; }
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += char; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, i) => { row[h] = values[i] || ''; });
return row;
});
}
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
// ── Step 1: Account State ──
const [accountCreated, setAccountCreated] = useState(false);
const [accountName, setAccountName] = useState('Operating Checking');
const [accountNumber, setAccountNumber] = useState('1000');
const [accountDescription, setAccountDescription] = useState('');
const [initialBalance, setInitialBalance] = useState<number | string>(0);
// ── Step 2: Assessment Group State ──
const [groupCreated, setGroupCreated] = useState(false);
const [groupName, setGroupName] = useState('Standard Assessment');
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
const [frequency, setFrequency] = useState('monthly');
const [units, setUnits] = useState<UnitRow[]>([]);
const [unitsCreated, setUnitsCreated] = useState(false);
// ── Step 3: Budget State ──
const [budgetFile, setBudgetFile] = useState<File | null>(null);
const [budgetUploaded, setBudgetUploaded] = useState(false);
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
const currentYear = new Date().getFullYear();
// ── Step 1: Create Account ──
const handleCreateAccount = async () => {
if (!accountName.trim()) {
setError('Account name is required');
return;
}
if (!accountNumber.trim()) {
setError('Account number is required');
return;
}
const balance = typeof initialBalance === 'string' ? parseFloat(initialBalance) : initialBalance;
if (isNaN(balance)) {
setError('Initial balance must be a valid number');
return;
}
setLoading(true);
setError(null);
try {
await api.post('/accounts', {
accountNumber: accountNumber.trim(),
name: accountName.trim(),
description: accountDescription.trim(),
accountType: 'asset',
fundType: 'operating',
initialBalance: balance,
});
setAccountCreated(true);
notifications.show({
title: 'Account Created',
message: `${accountName} has been created with an initial balance of $${balance.toLocaleString()}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create account';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Step 2: Create Assessment Group ──
const handleCreateGroup = async () => {
if (!groupName.trim()) {
setError('Group name is required');
return;
}
const assessment = typeof regularAssessment === 'string' ? parseFloat(regularAssessment) : regularAssessment;
if (isNaN(assessment) || assessment <= 0) {
setError('Assessment amount must be greater than zero');
return;
}
setLoading(true);
setError(null);
try {
const { data: group } = await api.post('/assessment-groups', {
name: groupName.trim(),
regularAssessment: assessment,
frequency,
isDefault: true,
});
setGroupCreated(true);
// Create units if any were added
if (units.length > 0) {
let created = 0;
for (const unit of units) {
if (!unit.unitNumber.trim()) continue;
try {
await api.post('/units', {
unitNumber: unit.unitNumber.trim(),
ownerName: unit.ownerName.trim() || null,
ownerEmail: unit.ownerEmail.trim() || null,
assessmentGroupId: group.id,
});
created++;
} catch {
// Continue even if a unit fails
}
}
setUnitsCreated(true);
notifications.show({
title: 'Assessment Group Created',
message: `${groupName} created with ${created} unit(s)`,
color: 'green',
});
} else {
notifications.show({
title: 'Assessment Group Created',
message: `${groupName} created successfully`,
color: 'green',
});
}
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create assessment group';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Step 3: Budget Import ──
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${currentYear}/template`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `budget_template_${currentYear}.csv`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch {
notifications.show({
title: 'Error',
message: 'Failed to download template',
color: 'red',
});
}
};
const handleUploadBudget = async () => {
if (!budgetFile) {
setError('Please select a CSV file');
return;
}
setLoading(true);
setError(null);
try {
const text = await budgetFile.text();
const rows = parseCSV(text);
if (rows.length === 0) {
setError('CSV file appears to be empty or invalid');
setLoading(false);
return;
}
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
setBudgetUploaded(true);
setBudgetImportResult(data);
notifications.show({
title: 'Budget Imported',
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to import budget';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Finish Wizard ──
const handleFinish = async () => {
setLoading(true);
try {
await api.patch('/organizations/settings', { onboardingComplete: true });
setOrgSettings({ onboardingComplete: true });
onComplete();
} catch {
// Even if API fails, close the wizard — onboarding data is already created
onComplete();
} finally {
setLoading(false);
}
};
// ── Unit Rows ──
const addUnit = () => {
setUnits([...units, { unitNumber: '', ownerName: '', ownerEmail: '' }]);
};
const updateUnit = (index: number, field: keyof UnitRow, value: string) => {
const updated = [...units];
updated[index] = { ...updated[index], [field]: value };
setUnits(updated);
};
const removeUnit = (index: number) => {
setUnits(units.filter((_, i) => i !== index));
};
// ── Navigation ──
const canGoNext = () => {
if (active === 0) return accountCreated;
if (active === 1) return groupCreated;
if (active === 2) return true; // Budget is optional
return false;
};
const nextStep = () => {
setError(null);
if (active < 3) setActive(active + 1);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing without completing
withCloseButton={false}
size="xl"
centered
overlayProps={{ opacity: 0.6, blur: 3 }}
styles={{
body: { padding: 0 },
}}
>
{/* Header */}
<Box px="xl" pt="xl" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
<Group>
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
<IconRocket size={24} />
</ThemeIcon>
<div>
<Title order={3}>Set Up Your Organization</Title>
<Text c="dimmed" size="sm">
Let&apos;s get the essentials configured so you can start managing your HOA finances.
</Text>
</div>
</Group>
</Box>
<Box px="xl" py="lg">
<Stepper active={active} size="sm" mb="xl">
<Stepper.Step
label="Operating Account"
description="Set up your primary bank account"
icon={<IconBuildingBank size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Assessment Group"
description="Define homeowner assessments"
icon={<IconUsers size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Budget"
description="Import your annual budget"
icon={<IconFileSpreadsheet size={18} />}
completedIcon={<IconCheck size={18} />}
/>
</Stepper>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md" withCloseButton onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* ── Step 1: Create Operating Account ── */}
{active === 0 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create Your Primary Operating Account</Text>
<Text size="sm" c="dimmed" mb="md">
This is your HOA&apos;s main bank account for day-to-day operations. You can add more accounts later.
</Text>
{accountCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{accountName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={2} mb="md">
<TextInput
label="Account Name"
placeholder="e.g. Operating Checking"
value={accountName}
onChange={(e) => setAccountName(e.currentTarget.value)}
required
/>
<TextInput
label="Account Number"
placeholder="e.g. 1000"
value={accountNumber}
onChange={(e) => setAccountNumber(e.currentTarget.value)}
required
/>
</SimpleGrid>
<Textarea
label="Description"
placeholder="Optional description"
value={accountDescription}
onChange={(e) => setAccountDescription(e.currentTarget.value)}
mb="md"
autosize
minRows={2}
/>
<NumberInput
label="Current Balance"
description="Enter the current balance of this bank account"
placeholder="0.00"
value={initialBalance}
onChange={setInitialBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
mb="md"
/>
<Button
onClick={handleCreateAccount}
loading={loading}
leftSection={<IconBuildingBank size={16} />}
>
Create Account
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 2: Assessment Group + Units ── */}
{active === 1 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create an Assessment Group</Text>
<Text size="sm" c="dimmed" mb="md">
Assessment groups define how much each homeowner pays and how often. You can create additional groups later for different unit types.
</Text>
{groupCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{groupName} created successfully!</Text>
<Text size="sm" c="dimmed">
${(typeof regularAssessment === 'number' ? regularAssessment : parseFloat(regularAssessment as string) || 0).toLocaleString()} {frequency}
{unitsCreated && ` with ${units.length} unit(s)`}
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={3} mb="md">
<TextInput
label="Group Name"
placeholder="e.g. Standard Assessment"
value={groupName}
onChange={(e) => setGroupName(e.currentTarget.value)}
required
/>
<NumberInput
label="Assessment Amount"
placeholder="0.00"
value={regularAssessment}
onChange={setRegularAssessment}
thousandSeparator=","
prefix="$"
decimalScale={2}
required
/>
<Select
label="Frequency"
value={frequency}
onChange={(v) => setFrequency(v || 'monthly')}
data={[
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' },
]}
/>
</SimpleGrid>
<Divider my="md" label="Add Homeowner Units (Optional)" labelPosition="center" />
{units.length > 0 && (
<Table mb="md" striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Unit Number</Table.Th>
<Table.Th>Owner Name</Table.Th>
<Table.Th>Owner Email</Table.Th>
<Table.Th w={40}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((unit, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<TextInput
size="xs"
placeholder="e.g. 101"
value={unit.unitNumber}
onChange={(e) => updateUnit(idx, 'unitNumber', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="John Smith"
value={unit.ownerName}
onChange={(e) => updateUnit(idx, 'ownerName', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="john@example.com"
value={unit.ownerEmail}
onChange={(e) => updateUnit(idx, 'ownerEmail', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<ActionIcon color="red" variant="subtle" size="sm" onClick={() => removeUnit(idx)}>
<IconTrash size={14} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group mb="md">
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={addUnit}
>
Add Unit
</Button>
<Text size="xs" c="dimmed">You can also import units in bulk later from the Units page.</Text>
</Group>
<Button
onClick={handleCreateGroup}
loading={loading}
leftSection={<IconUsers size={16} />}
>
Create Assessment Group
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 3: Budget Upload ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
<Text size="sm" c="dimmed" mb="md">
Upload a CSV file with your annual budget. If you don&apos;t have one ready, you can download a template
or skip this step and set it up later from the Budgets page.
</Text>
{budgetUploaded ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>Budget imported successfully!</Text>
{budgetImportResult && (
<Text size="sm" c="dimmed">
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
</Text>
)}
</Alert>
) : (
<>
<Group mb="md">
<Button
variant="light"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download CSV Template
</Button>
</Group>
<FileInput
label="Upload Budget CSV"
placeholder="Click to select a .csv file"
accept=".csv"
value={budgetFile}
onChange={setBudgetFile}
mb="md"
leftSection={<IconFileSpreadsheet size={16} />}
/>
<Button
onClick={handleUploadBudget}
loading={loading}
leftSection={<IconFileSpreadsheet size={16} />}
disabled={!budgetFile}
>
Import Budget
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Completion Screen ── */}
{active === 3 && (
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
<IconCheck size={32} />
</ThemeIcon>
<Title order={3} mb="xs">You&apos;re All Set!</Title>
<Text c="dimmed" mb="lg" maw={400} mx="auto">
Your organization is configured and ready to go. You can always update your accounts,
assessment groups, and budgets from the sidebar navigation.
</Text>
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconBuildingBank size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Account</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconUsers size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Assessments</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} />
</ThemeIcon>
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
{budgetUploaded ? 'Done' : 'Skipped'}
</Badge>
<Text size="xs" mt={4}>Budget</Text>
</Card>
</SimpleGrid>
<Button
size="lg"
onClick={handleFinish}
loading={loading}
leftSection={<IconRocket size={18} />}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Start Using LedgerIQ
</Button>
</Card>
)}
{/* ── Navigation Buttons ── */}
{active < 3 && (
<Group justify="flex-end" mt="xl">
{active === 2 && !budgetUploaded && (
<Button variant="subtle" onClick={nextStep}>
Skip for now
</Button>
)}
<Button
onClick={nextStep}
disabled={!canGoNext()}
>
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
</Button>
</Group>
)}
</Box>
</Modal>
);
}

View File

@@ -0,0 +1,68 @@
/**
* How-To Intro Tour Steps
*
* Centralized configuration for the react-joyride walkthrough.
* Edit the title and content fields below to change tour text.
* Steps are ordered to mirror the natural workflow of the platform.
*/
import type { Step } from 'react-joyride';
export const TOUR_STEPS: Step[] = [
{
target: '[data-tour="dashboard-content"]',
title: 'Your Financial Dashboard',
content:
'Welcome to LedgerIQ! This dashboard gives you an at-a-glance view of your HOA\'s financial health — operating funds, reserve funds, receivables, delinquencies, and recent transactions. It updates automatically as you record activity.',
placement: 'center',
disableBeacon: true,
},
{
target: '[data-tour="sidebar-nav"]',
title: 'Navigation',
content:
'The sidebar organizes all your tools into five sections: Financials, Assessments, Transactions, Planning, and Reports. Click any item to navigate directly to that module.',
placement: 'right',
},
{
target: '[data-tour="nav-accounts"]',
title: 'Chart of Accounts',
content:
'Manage your Chart of Accounts here. Set up operating and reserve fund bank accounts, track balances, record opening balances, and manage your investment accounts — all separated by fund type.',
placement: 'right',
},
{
target: '[data-tour="nav-assessment-groups"]',
title: 'Assessments & Homeowners',
content:
'Create assessment groups to define your monthly, quarterly, or annual HOA dues. Add homeowner units, assign them to groups, and generate invoices automatically based on your assessment schedule.',
placement: 'right',
},
{
target: '[data-tour="nav-transactions"]',
title: 'Transactions & Journal Entries',
content:
'Record all financial activity here through double-entry journal entries. The system also automatically creates entries when you record payments, generate invoices, or set opening balances.',
placement: 'right',
},
{
target: '[data-tour="nav-budgets"]',
title: 'Budget Management',
content:
'Create and manage annual budgets for every income and expense account. You can enter amounts manually by month or import your budget from a CSV file for quick setup.',
placement: 'right',
},
{
target: '[data-tour="nav-reports"]',
title: 'Financial Reports',
content:
'Generate comprehensive reports including Balance Sheet, Income Statement, Cash Flow Statement, Budget vs Actual, Aging Report, and more. All reports are generated in real-time from your journal data.',
placement: 'right',
},
{
target: '[data-tour="nav-investment-planning"]',
title: 'AI Investment Planning',
content:
'Use AI-powered recommendations to optimize your reserve fund investments. The system analyzes current market rates for CDs, money market accounts, and high-yield savings to suggest the best allocation strategy.',
placement: 'right',
},
];

View File

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

View File

@@ -11,6 +11,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface AssessmentGroup {
id: string;
@@ -52,6 +53,7 @@ export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'],
@@ -156,9 +158,11 @@ export function AssessmentGroupsPage() {
<Title order={2}>Assessment Groups</Title>
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Group
</Button>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Group
</Button>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
@@ -274,28 +278,30 @@ export function AssessmentGroupsPage() {
</Badge>
</Table.Td>
<Table.Td>
<Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
{!isReadOnly && (
<Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
<ActionIcon
variant="subtle"
color={g.is_default ? 'yellow' : 'gray'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
disabled={g.is_default}
>
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={g.is_default ? 'yellow' : 'gray'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
disabled={g.is_default}
color={g.is_active ? 'gray' : 'green'}
onClick={() => archiveMutation.mutate(g)}
>
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
<IconArchive size={16} />
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={g.is_active ? 'gray' : 'green'}
onClick={() => archiveMutation.mutate(g)}
>
<IconArchive size={16} />
</ActionIcon>
</Group>
</Group>
)}
</Table.Td>
</Table.Tr>
))}

View File

@@ -7,6 +7,7 @@ import { notifications } from '@mantine/notifications';
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface BudgetLine {
account_id: string;
@@ -96,6 +97,7 @@ export function BudgetsPage() {
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year],
@@ -236,8 +238,12 @@ export function BudgetsPage() {
if (isLoading) return <Center h={300}><Loader /></Center>;
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
const totalIncome = incomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalIncome = totalOperatingIncome + totalReserveIncome;
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return (
@@ -253,24 +259,26 @@ export function BudgetsPage() {
>
Download Template
</Button>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import CSV
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>
{!isReadOnly && (<>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import CSV
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>
</>)}
</Group>
</Group>
@@ -284,17 +292,23 @@ export function BudgetsPage() {
<Group>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Income</Text>
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
<Text size="xs" c="dimmed">Operating Income</Text>
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
</Card>
{totalReserveIncome > 0 && (
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Reserve Income</Text>
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
</Card>
)}
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Expenses</Text>
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
</Card>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Net</Text>
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalIncome - totalExpense)}
<Text size="xs" c="dimmed">Net (Operating)</Text>
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalOperatingIncome - totalExpense)}
</Text>
</Card>
</Group>
@@ -384,6 +398,7 @@ export function BudgetsPage() {
hideControls
decimalScale={2}
min={0}
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</Table.Td>

View File

@@ -14,6 +14,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ---------------------------------------------------------------------------
// Types & constants
@@ -29,7 +30,7 @@ interface Project {
fund_source: string;
funded_percentage: string;
planned_date: string;
target_year: number;
target_year: number | null;
target_month: number;
status: string;
priority: number;
@@ -37,6 +38,7 @@ interface Project {
}
const FUTURE_YEAR = 9999;
const UNSCHEDULED = -1; // sentinel for projects with no target_year
const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow',
@@ -48,7 +50,8 @@ const priorityColor = (p: number) => (p <= 2 ? 'red' : p <= 3 ? 'yellow' : 'gray
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year));
const yearLabel = (year: number) =>
year === FUTURE_YEAR ? 'Future' : year === UNSCHEDULED ? 'Unscheduled' : String(year);
const formatPlannedDate = (d: string | null | undefined) => {
if (!d) return null;
@@ -73,6 +76,9 @@ interface KanbanCardProps {
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear();
const isBeyondWindow = project.target_year > currentYear + 4 && project.target_year !== FUTURE_YEAR;
return (
<Card
@@ -104,6 +110,11 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
P{project.priority}
</Badge>
{isBeyondWindow && (
<Badge size="xs" variant="light" color="gray">
{project.target_year}
</Badge>
)}
</Group>
<Text size="xs" ff="monospace" fw={500} mb={4}>
@@ -144,19 +155,26 @@ function KanbanColumn({
isDragOver, onDragOverHandler, onDragLeave,
}: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR;
const isUnscheduled = year === UNSCHEDULED;
const useWideLayout = (isFuture || isUnscheduled) && projects.length > 3;
return (
<Paper
withBorder
radius="md"
p="sm"
miw={280}
maw={320}
miw={useWideLayout ? 580 : 280}
maw={useWideLayout ? 640 : 320}
style={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: isDragOver ? 'var(--mantine-color-blue-0)' : undefined,
backgroundColor: isDragOver
? 'var(--mantine-color-blue-0)'
: isUnscheduled
? 'var(--mantine-color-orange-0)'
: undefined,
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease',
}}
@@ -166,7 +184,12 @@ function KanbanColumn({
>
<Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title>
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
<Group gap={6}>
{isUnscheduled && projects.length > 0 && (
<Badge size="xs" variant="light" color="orange">needs scheduling</Badge>
)}
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
</Group>
</Group>
<Text size="xs" c="dimmed" mb="xs">
@@ -178,6 +201,16 @@ function KanbanColumn({
<Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here
</Text>
) : useWideLayout ? (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 'var(--mantine-spacing-xs)',
}}>
{projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
))}
</div>
) : (
projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
@@ -215,6 +248,7 @@ export function CapitalProjectsPage() {
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ---- Data fetching ----
@@ -287,10 +321,10 @@ export function CapitalProjectsPage() {
});
const moveProjectMutation = useMutation({
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number; target_month: number }) => {
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number | null; target_month: number }) => {
const payload: Record<string, unknown> = { target_year };
// Derive planned_date based on the new year
if (target_year === FUTURE_YEAR) {
if (target_year === null || target_year === FUTURE_YEAR) {
payload.planned_date = null;
} else {
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
@@ -329,7 +363,7 @@ export function CapitalProjectsPage() {
form.setValues({
status: p.status || 'planned',
priority: p.priority || 3,
target_year: p.target_year,
target_year: p.target_year ?? currentYear,
target_month: p.target_month || 6,
planned_date: p.planned_date || '',
notes: p.notes || '',
@@ -352,7 +386,7 @@ export function CapitalProjectsPage() {
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
e.dataTransfer.setData('application/json', JSON.stringify({
id: project.id,
source_year: project.target_year,
source_year: project.target_year ?? UNSCHEDULED,
target_month: project.target_month,
}));
e.dataTransfer.effectAllowed = 'move';
@@ -376,7 +410,7 @@ export function CapitalProjectsPage() {
if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({
id: payload.id,
target_year: targetYear,
target_year: targetYear === UNSCHEDULED ? null : targetYear,
target_month: payload.target_month || 6,
});
}
@@ -389,15 +423,20 @@ export function CapitalProjectsPage() {
// Always show current year through current+4, plus FUTURE_YEAR if any projects have it
const baseYears = Array.from({ length: 5 }, (_, i) => currentYear + i);
const projectYears = [...new Set(projects.map((p) => p.target_year))];
const projectYears = [...new Set(projects.map((p) => p.target_year).filter((y): y is number => y !== null))];
const hasFutureProjects = projectYears.includes(FUTURE_YEAR);
const hasUnscheduledProjects = projects.some((p) => p.target_year === null);
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
const years = hasFutureProjects ? [...regularYears, FUTURE_YEAR] : regularYears;
const years = [
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
...regularYears,
...(hasFutureProjects ? [FUTURE_YEAR] : []),
];
// Kanban columns: always current..current+4 plus Future
const kanbanYears = [...baseYears, FUTURE_YEAR];
// Kanban columns: Unscheduled + current..current+4 + Future
const kanbanYears = [UNSCHEDULED, ...baseYears, FUTURE_YEAR];
// ---- Loading state ----
@@ -417,12 +456,11 @@ export function CapitalProjectsPage() {
<Stack align="center" gap="md" maw={420}>
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
<Title order={3} c="dimmed" ta="center">
No projects in the capital plan
No projects yet
</Title>
<Text c="dimmed" ta="center" size="sm">
Capital Planning displays projects that have a target year assigned.
Head over to the Projects page to define your reserve and operating
projects, then assign target years to see them here.
projects. They'll appear here for capital planning and scheduling.
</Text>
<Button
variant="light"
@@ -448,7 +486,9 @@ export function CapitalProjectsPage() {
</Text>
) : (
years.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year);
const yearProjects = year === UNSCHEDULED
? projects.filter((p) => p.target_year === null)
: projects.filter((p) => p.target_year === year);
if (yearProjects.length === 0) return null;
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
@@ -479,16 +519,18 @@ export function CapitalProjectsPage() {
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.category || '-'}</Table.Td>
<Table.Td>
{p.target_year === FUTURE_YEAR
? 'Future'
: (
<>
{p.target_month
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
: ''}{' '}
{p.target_year}
</>
)
{p.target_year === null
? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
: p.target_year === FUTURE_YEAR
? 'Future'
: (
<>
{p.target_month
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
: ''}{' '}
{p.target_year}
</>
)
}
</Table.Td>
<Table.Td>
@@ -511,9 +553,9 @@ export function CapitalProjectsPage() {
</Table.Td>
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
<Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</ActionIcon>}
</Table.Td>
</Table.Tr>
))}
@@ -528,11 +570,20 @@ export function CapitalProjectsPage() {
// ---- Render: Kanban view ----
const maxPlannedYear = currentYear + 4; // last year in the 5-year window
const renderKanbanView = () => (
<ScrollArea type="auto" offsetScrollbars>
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
{kanbanYears.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year);
// Unscheduled: projects with no target_year
// Future: projects with target_year === 9999 OR beyond the 5-year window
// Otherwise: exact year match
const yearProjects = year === UNSCHEDULED
? projects.filter((p) => p.target_year === null)
: year === FUTURE_YEAR
? projects.filter((p) => p.target_year === FUTURE_YEAR || (p.target_year !== null && p.target_year > maxPlannedYear))
: projects.filter((p) => p.target_year === year);
return (
<KanbanColumn
key={year}

View File

@@ -1,17 +1,228 @@
import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center,
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
Popover, List,
} from '@mantine/core';
import {
IconCash,
IconFileInvoice,
IconShieldCheck,
IconAlertTriangle,
IconBuildingBank,
IconTrendingUp,
IconTrendingDown,
IconMinus,
IconHeartbeat,
IconRefresh,
IconInfoCircle,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
interface HealthScore {
id: string;
score_type: string;
score: number;
previous_score: number | null;
trajectory: string | null;
label: string;
summary: string;
factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>;
recommendations: Array<{ priority: string; text: string }>;
missing_data: string[] | null;
status: string;
response_time_ms: number | null;
calculated_at: string;
}
interface HealthScoresData {
operating: HealthScore | null;
reserve: HealthScore | null;
}
function getScoreColor(score: number): string {
if (score >= 75) return 'green';
if (score >= 60) return 'yellow';
if (score >= 40) return 'orange';
return 'red';
}
function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
if (trajectory === 'improving') return <IconTrendingUp size={16} color="var(--mantine-color-green-6)" />;
if (trajectory === 'declining') return <IconTrendingDown size={16} color="var(--mantine-color-red-6)" />;
if (trajectory === 'stable') return <IconMinus size={16} color="var(--mantine-color-gray-6)" />;
return null;
}
function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; title: string; icon: React.ReactNode }) {
if (!score) {
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
{icon}
</Group>
<Center h={100}>
<Text c="dimmed" size="sm">No health score yet</Text>
</Center>
</Card>
);
}
if (score.status === 'pending') {
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
{icon}
</Group>
<Center>
<Stack align="center" gap="xs">
<Badge color="gray" variant="light" size="lg">Pending</Badge>
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
{missingItems.map((item: string, i: number) => (
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
))}
</Stack>
</Center>
</Card>
);
}
if (score.status === 'error') {
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
{icon}
</Group>
<Center h={100}>
<Badge color="red" variant="light">Error calculating score</Badge>
</Center>
</Card>
);
}
const color = getScoreColor(score.score);
const factors = Array.isArray(score.factors) ? score.factors :
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
const recommendations = Array.isArray(score.recommendations) ? score.recommendations :
(typeof score.recommendations === 'string' ? JSON.parse(score.recommendations) : []);
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
{icon}
</Group>
<Group align="flex-start" gap="lg">
<RingProgress
size={120}
thickness={12}
roundCaps
sections={[{ value: score.score, color }]}
label={
<Stack align="center" gap={0}>
<Text fw={700} size="xl" ta="center" lh={1}>{score.score}</Text>
<Text size="xs" c="dimmed" ta="center">/100</Text>
</Stack>
}
/>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6}>
<Badge color={color} variant="light" size="sm">{score.label}</Badge>
{score.trajectory && (
<Tooltip label={`Trend: ${score.trajectory}`}>
<Group gap={2}>
<TrajectoryIcon trajectory={score.trajectory} />
<Text size="xs" c="dimmed">{score.trajectory}</Text>
</Group>
</Tooltip>
)}
{score.previous_score !== null && (
<Text size="xs" c="dimmed">(prev: {score.previous_score})</Text>
)}
</Group>
<Text size="sm" lineClamp={2}>{score.summary}</Text>
<Group gap={4} mt={2}>
{factors.slice(0, 3).map((f: any, i: number) => (
<Tooltip key={i} label={f.detail} multiline w={280}>
<Badge
size="xs"
variant="dot"
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
>
{f.name}
</Badge>
</Tooltip>
))}
{(factors.length > 3 || recommendations.length > 0) && (
<Popover width={350} position="bottom" shadow="md">
<Popover.Target>
<Badge size="xs" variant="light" color="blue" style={{ cursor: 'pointer' }}>
<IconInfoCircle size={10} /> Details
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
{factors.length > 0 && (
<>
<Text fw={600} size="xs">Factors</Text>
{factors.map((f: any, i: number) => (
<Group key={i} gap={6} wrap="nowrap">
<Badge
size="xs"
variant="dot"
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
style={{ flexShrink: 0 }}
>
{f.name}
</Badge>
<Text size="xs" c="dimmed">{f.detail}</Text>
</Group>
))}
</>
)}
{recommendations.length > 0 && (
<>
<Divider my={4} />
<Text fw={600} size="xs">Recommendations</Text>
<List size="xs" spacing={4}>
{recommendations.map((r: any, i: number) => (
<List.Item key={i}>
<Badge size="xs" color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'} variant="light" mr={4}>
{r.priority}
</Badge>
{r.text}
</List.Item>
))}
</List>
</>
)}
{score.calculated_at && (
<Text size="xs" c="dimmed" ta="right" mt={4}>
Updated: {new Date(score.calculated_at).toLocaleString()}
</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
)}
</Group>
</Stack>
</Group>
{score.calculated_at && (
<Text size="10px" c="dimmed" ta="right" mt={6} style={{ opacity: 0.7 }}>
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
)}
</Card>
);
}
interface DashboardData {
total_cash: string;
total_receivables: string;
@@ -20,10 +231,19 @@ interface DashboardData {
recent_transactions: {
id: string; entry_date: string; description: string; entry_type: string; amount: string;
}[];
// Enhanced split data
operating_cash: string;
reserve_cash: string;
operating_investments: string;
reserve_investments: string;
est_monthly_interest: string;
interest_earned_ytd: string;
planned_capital_spend: string;
}
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<DashboardData>({
queryKey: ['dashboard'],
@@ -31,15 +251,24 @@ export function DashboardPage() {
enabled: !!currentOrg,
});
const { data: healthScores } = useQuery<HealthScoresData>({
queryKey: ['health-scores'],
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
enabled: !!currentOrg,
});
const recalcMutation = useMutation({
mutationFn: () => api.post('/health-scores/calculate'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
});
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const stats = [
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' },
{ title: 'Total Receivables', value: fmt(data?.total_receivables || '0'), icon: IconFileInvoice, color: 'blue' },
{ title: 'Reserve Fund', value: fmt(data?.reserve_fund_balance || '0'), icon: IconShieldCheck, color: 'violet' },
{ title: 'Delinquent Accounts', value: String(data?.delinquent_units || 0), icon: IconAlertTriangle, color: 'orange' },
];
const opInv = parseFloat(data?.operating_investments || '0');
const resInv = parseFloat(data?.reserve_investments || '0');
const entryTypeColors: Record<string, string> = {
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
@@ -47,13 +276,8 @@ export function DashboardPage() {
};
return (
<Stack>
<div>
<Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
</Text>
</div>
<Stack data-tour="dashboard-content">
<Title order={2}>Dashboard</Title>
{!currentOrg ? (
<Card withBorder p="xl" ta="center">
@@ -66,24 +290,88 @@ export function DashboardPage() {
<Center h={200}><Loader /></Center>
) : (
<>
<Group justify="space-between" align="center">
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
<Tooltip label="Recalculate health scores now">
<Button
variant="subtle"
size="compact-xs"
leftSection={<IconRefresh size={14} />}
loading={recalcMutation.isPending}
onClick={() => recalcMutation.mutate()}
>
Refresh
</Button>
</Tooltip>
</Group>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<HealthScoreCard
score={healthScores?.operating || null}
title="Operating Fund"
icon={
<ThemeIcon color="green" variant="light" size={36} radius="md">
<IconHeartbeat size={20} />
</ThemeIcon>
}
/>
<HealthScoreCard
score={healthScores?.reserve || null}
title="Reserve Fund"
icon={
<ThemeIcon color="violet" variant="light" size={36} radius="md">
<IconHeartbeat size={20} />
</ThemeIcon>
}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
{stats.map((stat) => (
<Card key={stat.title} withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{stat.title}
</Text>
<Text fw={700} size="xl">
{stat.value}
</Text>
</div>
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
<stat.icon size={28} />
</ThemeIcon>
</Group>
</Card>
))}
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
</div>
<ThemeIcon color="green" variant="light" size={48} radius="md">
<IconCash size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Fund</Text>
<Text fw={700} size="xl">{fmt(data?.reserve_cash || '0')}</Text>
{resInv > 0 && <Text size="xs" c="teal">Investments: {fmt(resInv)}</Text>}
</div>
<ThemeIcon color="violet" variant="light" size={48} radius="md">
<IconShieldCheck size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Receivables</Text>
<Text fw={700} size="xl">{fmt(data?.total_receivables || '0')}</Text>
</div>
<ThemeIcon color="blue" variant="light" size={48} radius="md">
<IconFileInvoice size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Delinquent Accounts</Text>
<Text fw={700} size="xl">{String(data?.delinquent_units || 0)}</Text>
</div>
<ThemeIcon color="orange" variant="light" size={48} radius="md">
<IconAlertTriangle size={28} />
</ThemeIcon>
</Group>
</Card>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }}>
@@ -120,17 +408,31 @@ export function DashboardPage() {
<Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Cash Position</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
<Text size="sm" c="dimmed">Operating Cash</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Reserve Cash</Text>
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
</Group>
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Planned Capital Spend</Text>
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
</Group>
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" c="dimmed">Outstanding AR</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Reserve Funding</Text>
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_fund_balance || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Delinquent Units</Text>
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>

View File

@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Investment {
id: string; name: string; institution: string; account_number_last4: string;
@@ -25,6 +26,7 @@ export function InvestmentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Investment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: investments = [], isLoading } = useQuery<Investment[]>({
queryKey: ['investments'],
@@ -76,6 +78,11 @@ export function InvestmentsPage() {
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0);
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
const projectedInterest = investments.reduce((s, i) => {
const value = parseFloat(i.current_value || i.principal || '0');
const rate = parseFloat(i.interest_rate || '0');
return s + (value * rate / 100);
}, 0);
const daysRemainingColor = (days: number | null) => {
if (days === null) return 'gray';
@@ -90,12 +97,13 @@ export function InvestmentsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Investment Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>}
</Group>
<SimpleGrid cols={{ base: 1, sm: 4 }}>
<SimpleGrid cols={{ base: 1, sm: 3, lg: 5 }}>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Projected Annual Interest</Text><Text fw={700} size="xl" c="blue">{fmt(projectedInterest)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
</SimpleGrid>
<Table striped highlightOnHover>
@@ -133,7 +141,7 @@ export function InvestmentsPage() {
) : '-'}
</Table.Td>
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td>
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr>
))}
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}

View File

@@ -9,6 +9,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine {
@@ -64,6 +65,7 @@ export function MonthlyActualsPage() {
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
const [savedJEId, setSavedJEId] = useState<string | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
@@ -204,6 +206,7 @@ export function MonthlyActualsPage() {
hideControls
decimalScale={2}
allowNegative
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</Table.Td>
@@ -229,14 +232,16 @@ export function MonthlyActualsPage() {
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={lines.length === 0}
>
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
</Button>
{!isReadOnly && (
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={lines.length === 0}
>
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
</Button>
)}
</Group>
</Group>

View File

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

View File

@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: string;
@@ -20,6 +21,7 @@ interface Payment {
export function PaymentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: payments = [], isLoading } = useQuery<Payment[]>({
queryKey: ['payments'],
@@ -74,7 +76,7 @@ export function PaymentsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Payments</Title>
<Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>}
</Group>
<Table striped highlightOnHover>
<Table.Thead>

View File

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

View File

@@ -6,7 +6,7 @@ import {
import { useQuery } from '@tanstack/react-query';
import {
IconCash, IconArrowUpRight, IconArrowDownRight,
IconWallet, IconReportMoney, IconSearch,
IconWallet, IconReportMoney, IconSearch, IconHeartRateMonitor,
} from '@tabler/icons-react';
import api from '../../services/api';
@@ -58,6 +58,16 @@ export function CashFlowPage() {
},
});
const { data: aiRec } = useQuery<{ overall_assessment?: string; risk_notes?: string[] } | null>({
queryKey: ['saved-recommendation'],
queryFn: async () => {
try {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
} catch { return null; }
},
});
const handleApply = () => {
setQueryFrom(fromDate);
setQueryTo(toDate);
@@ -68,6 +78,10 @@ export function CashFlowPage() {
const totalOperating = parseFloat(data?.total_operating || '0');
const totalReserve = parseFloat(data?.total_reserve || '0');
const opInflows = (data?.operating_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0);
const opOutflows = Math.abs((data?.operating_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0));
const resInflows = (data?.reserve_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0);
const resOutflows = Math.abs((data?.reserve_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0));
const beginningCash = parseFloat(data?.beginning_cash || '0');
const endingCash = parseFloat(data?.ending_cash || '0');
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
@@ -132,10 +146,14 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Operating</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Activity</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
{fmt(totalOperating)}
<Group justify="space-between" mb={4}>
<Text size="xs" c="green">In: {fmt(opInflows)}</Text>
<Text size="xs" c="red">Out: {fmt(opOutflows)}</Text>
</Group>
<Text fw={700} size="lg" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
{totalOperating >= 0 ? '+' : ''}{fmt(totalOperating)}
</Text>
</Card>
<Card withBorder p="md">
@@ -143,20 +161,31 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
<IconReportMoney size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Reserve</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Activity</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
{fmt(totalReserve)}
<Group justify="space-between" mb={4}>
<Text size="xs" c="green">In: {fmt(resInflows)}</Text>
<Text size="xs" c="red">Out: {fmt(resOutflows)}</Text>
</Group>
<Text fw={700} size="lg" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
{totalReserve >= 0 ? '+' : ''}{fmt(totalReserve)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="teal" size="sm">
<IconCash size={14} />
<ThemeIcon variant="light" color={aiRec?.overall_assessment ? 'teal' : 'gray'} size="sm">
<IconHeartRateMonitor size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending {balanceLabel}</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Financial Health</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
{aiRec?.overall_assessment ? (
<Text fw={600} size="sm" lineClamp={3}>{aiRec.overall_assessment}</Text>
) : (
<>
<Text fw={700} size="xl" c="dimmed">TBD</Text>
<Text size="xs" c="dimmed">Pending AI Analysis</Text>
</>
)}
</Card>
</SimpleGrid>

View File

@@ -0,0 +1,292 @@
import { useState } from 'react';
import {
Title, Table, Group, Stack, Text, Card, Loader, Center,
Badge, SimpleGrid, Select, ThemeIcon, Alert,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
} from '@tabler/icons-react';
import api from '../../services/api';
interface BudgetVsActualItem {
account_id: string;
account_number: string;
name: string;
account_type: string;
fund_type: string;
quarter_budget: number;
quarter_actual: number;
quarter_variance: number;
ytd_budget: number;
ytd_actual: number;
ytd_variance: number;
variance_pct?: string;
}
interface IncomeStatement {
income: { name: string; amount: string; fund_type: string }[];
expenses: { name: string; amount: string; fund_type: string }[];
total_income: string;
total_expenses: string;
net_income: string;
}
interface QuarterlyData {
year: number;
quarter: number;
quarter_label: string;
date_range: { from: string; to: string };
quarter_income_statement: IncomeStatement;
ytd_income_statement: IncomeStatement;
budget_vs_actual: BudgetVsActualItem[];
over_budget_items: BudgetVsActualItem[];
}
export function QuarterlyReportPage() {
const now = new Date();
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4;
const defaultYear = currentQuarter > 1 ? now.getFullYear() : now.getFullYear() - 1;
const [year, setYear] = useState(String(defaultYear));
const [quarter, setQuarter] = useState(String(defaultQuarter));
const { data, isLoading } = useQuery<QuarterlyData>({
queryKey: ['quarterly-report', year, quarter],
queryFn: async () => {
const { data } = await api.get(`/reports/quarterly?year=${year}&quarter=${quarter}`);
return data;
},
});
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = now.getFullYear() - 2 + i;
return { value: String(y), label: String(y) };
});
const quarterOptions = [
{ value: '1', label: 'Q1 (Jan-Mar)' },
{ value: '2', label: 'Q2 (Apr-Jun)' },
{ value: '3', label: 'Q3 (Jul-Sep)' },
{ value: '4', label: 'Q4 (Oct-Dec)' },
];
if (isLoading) return <Center h={300}><Loader /></Center>;
const qIS = data?.quarter_income_statement;
const ytdIS = data?.ytd_income_statement;
const bva = data?.budget_vs_actual || [];
const overBudget = data?.over_budget_items || [];
const qRevenue = parseFloat(qIS?.total_income || '0');
const qExpenses = parseFloat(qIS?.total_expenses || '0');
const qNet = parseFloat(qIS?.net_income || '0');
const ytdNet = parseFloat(ytdIS?.net_income || '0');
const incomeItems = bva.filter((b) => b.account_type === 'income');
const expenseItems = bva.filter((b) => b.account_type === 'expense');
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Quarterly Financial Report</Title>
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={quarterOptions} value={quarter} onChange={(v) => v && setQuarter(v)} w={160} />
</Group>
</Group>
{data && (
<Text size="sm" c="dimmed">
{data.quarter_label} &middot; {new Date(data.date_range.from).toLocaleDateString()} {new Date(data.date_range.to).toLocaleDateString()}
</Text>
)}
{/* Summary Cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }}>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="green" size="sm"><IconTrendingUp size={14} /></ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Revenue</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c="green">{fmt(qRevenue)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="red" size="sm"><IconTrendingDown size={14} /></ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Expenses</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c="red">{fmt(qExpenses)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color={qNet >= 0 ? 'green' : 'red'} size="sm">
<IconChartBar size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Net</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={qNet >= 0 ? 'green' : 'red'}>{fmt(qNet)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color={ytdNet >= 0 ? 'green' : 'red'} size="sm">
<IconChartBar size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>YTD Net</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={ytdNet >= 0 ? 'green' : 'red'}>{fmt(ytdNet)}</Text>
</Card>
</SimpleGrid>
{/* Over-Budget Alert */}
{overBudget.length > 0 && (
<Card withBorder>
<Group mb="md">
<IconAlertTriangle size={20} color="var(--mantine-color-orange-6)" />
<Title order={4}>Over-Budget Items ({overBudget.length})</Title>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Budget</Table.Th>
<Table.Th ta="right">Actual</Table.Th>
<Table.Th ta="right">Over By</Table.Th>
<Table.Th ta="right">% Over</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{overBudget.map((item) => (
<Table.Tr key={item.account_id}>
<Table.Td>
<Text size="sm" fw={500}>{item.name}</Text>
<Text size="xs" c="dimmed">{item.account_number}</Text>
</Table.Td>
<Table.Td>
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{item.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_variance)}</Table.Td>
<Table.Td ta="right">
<Badge color="red" variant="light" size="sm">+{item.variance_pct}%</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
{/* Budget vs Actuals */}
<Card withBorder>
<Title order={4} mb="md">Budget vs Actuals</Title>
{bva.length === 0 ? (
<Alert variant="light" color="blue">No budget or actual data for this quarter.</Alert>
) : (
<div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 900 }}>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Q Budget</Table.Th>
<Table.Th ta="right">Q Actual</Table.Th>
<Table.Th ta="right">Q Variance</Table.Th>
<Table.Th ta="right">YTD Budget</Table.Th>
<Table.Th ta="right">YTD Actual</Table.Th>
<Table.Th ta="right">YTD Variance</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{incomeItems.length > 0 && (
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
</Table.Tr>
)}
{incomeItems.map((item) => (
<BVARow key={item.account_id} item={item} isExpense={false} />
))}
{incomeItems.length > 0 && (
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
</Table.Tr>
)}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
</Table.Tr>
)}
{expenseItems.map((item) => (
<BVARow key={item.account_id} item={item} isExpense={true} />
))}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
)}
</Card>
</Stack>
);
}
function BVARow({ item, isExpense }: { item: BudgetVsActualItem; isExpense: boolean }) {
const fmt = (v: number) =>
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
// For expenses, over budget (positive variance) is bad (red)
// For income, under budget (negative variance) is bad (red)
const qVarianceColor = isExpense
? (item.quarter_variance > 0 ? 'red' : 'green')
: (item.quarter_variance < 0 ? 'red' : 'green');
const ytdVarianceColor = isExpense
? (item.ytd_variance > 0 ? 'red' : 'green')
: (item.ytd_variance < 0 ? 'red' : 'green');
return (
<Table.Tr>
<Table.Td>
<Text size="sm">{item.name}</Text>
<Text size="xs" c="dimmed">{item.account_number}</Text>
</Table.Td>
<Table.Td>
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{item.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c={item.quarter_variance !== 0 ? qVarianceColor : undefined}>
{fmt(item.quarter_variance)}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c={item.ytd_variance !== 0 ? ytdVarianceColor : undefined}>
{fmt(item.ytd_variance)}
</Table.Td>
</Table.Tr>
);
}

View File

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

View File

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

View File

@@ -117,7 +117,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">0.2.0 MVP_P2</Badge>
<Badge variant="light">2026.3.2 (beta)</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ interface Organization {
role: string;
schemaName?: string;
status?: string;
settings?: Record<string, any>;
}
interface User {
@@ -16,6 +17,7 @@ interface User {
lastName: string;
isSuperadmin?: boolean;
isPlatformOwner?: boolean;
hasSeenIntro?: boolean;
}
interface ImpersonationOriginal {
@@ -33,11 +35,16 @@ interface AuthState {
impersonationOriginal: ImpersonationOriginal | null;
setAuth: (token: string, user: User, organizations: Organization[]) => void;
setCurrentOrg: (org: Organization, token?: string) => void;
setUserIntroSeen: () => void;
setOrgSettings: (settings: Record<string, any>) => void;
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
stopImpersonation: () => void;
logout: () => void;
}
/** Hook to check if the current user has read-only (viewer) access */
export const useIsReadOnly = () => useAuthStore((s) => s.currentOrg?.role === 'viewer');
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -59,6 +66,16 @@ export const useAuthStore = create<AuthState>()(
currentOrg: org,
token: token || state.token,
})),
setUserIntroSeen: () =>
set((state) => ({
user: state.user ? { ...state.user, hasSeenIntro: true } : null,
})),
setOrgSettings: (settings) =>
set((state) => ({
currentOrg: state.currentOrg
? { ...state.currentOrg, settings: { ...(state.currentOrg.settings || {}), ...settings } }
: null,
})),
startImpersonation: (token, user, organizations) => {
const state = get();
set({
@@ -97,7 +114,7 @@ export const useAuthStore = create<AuthState>()(
}),
{
name: 'ledgeriq-auth',
version: 4,
version: 5,
migrate: () => ({
token: null,
user: null,