Compare commits
13 Commits
2fed5d6ce1
...
a550a8d0be
| Author | SHA1 | Date | |
|---|---|---|---|
| a550a8d0be | |||
| 063741adc7 | |||
| ad2f16d93b | |||
| b0b36df4e4 | |||
| aa7f2dab32 | |||
| d2d553eed6 | |||
| 2ca277b6e6 | |||
| bfcbe086f2 | |||
| c92eb1b57b | |||
| 07347a644f | |||
| f1e66966f3 | |||
| d1c40c633f | |||
| 0e82e238c1 |
46
backend/package-lock.json
generated
46
backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -1592,6 +1593,19 @@
|
|||||||
"@nestjs/core": "^10.0.0"
|
"@nestjs/core": "^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/schedule": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron": "4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "10.2.3",
|
"version": "10.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||||
@@ -2027,6 +2041,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
|
||||||
|
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/multer": {
|
"node_modules/@types/multer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||||
@@ -3432,6 +3452,23 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cron": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/luxon": "~3.7.0",
|
||||||
|
"luxon": "~3.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.x"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "ko-fi",
|
||||||
|
"url": "https://ko-fi.com/intcreator"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -5916,6 +5953,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.8",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "0.2.0",
|
"version": "2026.3.2-beta",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
|
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||||
import { UsersModule } from './modules/users/users.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 { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
||||||
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||||
|
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -62,8 +66,16 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
|
|||||||
MonthlyActualsModule,
|
MonthlyActualsModule,
|
||||||
AttachmentsModule,
|
AttachmentsModule,
|
||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
|
HealthScoresModule,
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: WriteAccessGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ALLOW_VIEWER_KEY = 'allowViewer';
|
||||||
|
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);
|
||||||
35
backend/src/common/guards/write-access.guard.ts
Normal file
35
backend/src/common/guards/write-access.guard.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WriteAccessGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const method = request.method;
|
||||||
|
|
||||||
|
// Allow all read methods
|
||||||
|
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true;
|
||||||
|
|
||||||
|
// Determine role from either req.userRole (set by TenantMiddleware which runs
|
||||||
|
// before guards) or req.user.role (set by JwtAuthGuard Passport strategy).
|
||||||
|
const role = request.userRole || request.user?.role;
|
||||||
|
if (!role) return true; // unauthenticated endpoints like login/register
|
||||||
|
|
||||||
|
// Check for @AllowViewer() exemption on handler or class
|
||||||
|
const allowViewer = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (allowViewer) return true;
|
||||||
|
|
||||||
|
// Block viewer role from write operations
|
||||||
|
if (role === 'viewer') {
|
||||||
|
throw new ForbiddenException('Read-only users cannot modify data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,6 +202,7 @@ export class TenantSchemaService {
|
|||||||
default_account_id UUID REFERENCES "${s}".accounts(id),
|
default_account_id UUID REFERENCES "${s}".accounts(id),
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
|
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
|
||||||
|
last_negotiated DATE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
@@ -327,6 +328,25 @@ export class TenantSchemaService {
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
// Health Scores (AI-derived operating / reserve fund health)
|
||||||
|
`CREATE TABLE "${s}".health_scores (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')),
|
||||||
|
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||||
|
previous_score INTEGER,
|
||||||
|
trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')),
|
||||||
|
label VARCHAR(30),
|
||||||
|
summary TEXT,
|
||||||
|
factors JSONB,
|
||||||
|
recommendations JSONB,
|
||||||
|
missing_data JSONB,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')),
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`,
|
||||||
|
|
||||||
// Attachments (file storage for receipts/invoices)
|
// Attachments (file storage for receipts/invoices)
|
||||||
`CREATE TABLE "${s}".attachments (
|
`CREATE TABLE "${s}".attachments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|||||||
@@ -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) {
|
async update(id: string, dto: UpdateAccountDto) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
@@ -13,6 +14,7 @@ import { RegisterDto } from './dto/register.dto';
|
|||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { SwitchOrgDto } from './dto/switch-org.dto';
|
import { SwitchOrgDto } from './dto/switch-org.dto';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -42,10 +44,21 @@ export class AuthController {
|
|||||||
return this.authService.getProfile(req.user.sub);
|
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')
|
@Post('switch-org')
|
||||||
@ApiOperation({ summary: 'Switch active organization' })
|
@ApiOperation({ summary: 'Switch active organization' })
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@AllowViewer()
|
||||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
const ua = req.headers['user-agent'];
|
const ua = req.headers['user-agent'];
|
||||||
|
|||||||
@@ -131,10 +131,15 @@ export class AuthService {
|
|||||||
id: membership.organization.id,
|
id: membership.organization.id,
|
||||||
name: membership.organization.name,
|
name: membership.organization.name,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
|
settings: membership.organization.settings || {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markIntroSeen(userId: string): Promise<void> {
|
||||||
|
await this.usersService.markIntroSeen(userId);
|
||||||
|
}
|
||||||
|
|
||||||
private async recordLoginHistory(
|
private async recordLoginHistory(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string | null,
|
organizationId: string | null,
|
||||||
@@ -185,6 +190,7 @@ export class AuthService {
|
|||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
isSuperadmin: user.isSuperadmin || false,
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
isPlatformOwner: user.isPlatformOwner || false,
|
isPlatformOwner: user.isPlatformOwner || false,
|
||||||
|
hasSeenIntro: user.hasSeenIntro || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { HealthScoresService } from './health-scores.service';
|
||||||
|
|
||||||
|
@ApiTags('health-scores')
|
||||||
|
@Controller('health-scores')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class HealthScoresController {
|
||||||
|
constructor(private service: HealthScoresService) {}
|
||||||
|
|
||||||
|
@Get('latest')
|
||||||
|
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||||
|
getLatest(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
return this.service.getLatestScores(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
@ApiOperation({ summary: 'Trigger health score recalculation for current tenant' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculate(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
const [operating, reserve] = await Promise.all([
|
||||||
|
this.service.calculateScore(schema, 'operating'),
|
||||||
|
this.service.calculateScore(schema, 'reserve'),
|
||||||
|
]);
|
||||||
|
return { operating, reserve };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/modules/health-scores/health-scores.module.ts
Normal file
10
backend/src/modules/health-scores/health-scores.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthScoresController } from './health-scores.controller';
|
||||||
|
import { HealthScoresService } from './health-scores.service';
|
||||||
|
import { HealthScoresScheduler } from './health-scores.scheduler';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthScoresController],
|
||||||
|
providers: [HealthScoresService, HealthScoresScheduler],
|
||||||
|
})
|
||||||
|
export class HealthScoresModule {}
|
||||||
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal file
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { HealthScoresService } from './health-scores.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthScoresScheduler {
|
||||||
|
private readonly logger = new Logger(HealthScoresScheduler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dataSource: DataSource,
|
||||||
|
private healthScoresService: HealthScoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run daily at 2:00 AM — calculate health scores for all active tenants.
|
||||||
|
* Uses DataSource directly to list tenants (no HTTP request context needed).
|
||||||
|
*/
|
||||||
|
@Cron('0 2 * * *')
|
||||||
|
async calculateAllTenantScores() {
|
||||||
|
this.logger.log('Starting daily health score calculation for all tenants...');
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgs = await this.dataSource.query(
|
||||||
|
`SELECT id, name, schema_name FROM shared.organizations WHERE status = 'active'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Found ${orgs.length} active tenants`);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const org of orgs) {
|
||||||
|
try {
|
||||||
|
await this.healthScoresService.calculateScore(org.schema_name, 'operating');
|
||||||
|
await this.healthScoresService.calculateScore(org.schema_name, 'reserve');
|
||||||
|
successCount++;
|
||||||
|
this.logger.log(`Health scores calculated for ${org.name} (${org.schema_name})`);
|
||||||
|
} catch (err: any) {
|
||||||
|
errorCount++;
|
||||||
|
this.logger.error(`Failed to calculate health scores for ${org.name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
this.logger.log(
|
||||||
|
`Daily health scores complete: ${successCount} success, ${errorCount} errors (${elapsed}ms)`,
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Health score scheduler failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1070
backend/src/modules/health-scores/health-scores.service.ts
Normal file
1070
backend/src/modules/health-scores/health-scores.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
import { InvestmentPlanningService } from './investment-planning.service';
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
|
|
||||||
@ApiTags('investment-planning')
|
@ApiTags('investment-planning')
|
||||||
@@ -36,6 +37,7 @@ export class InvestmentPlanningController {
|
|||||||
|
|
||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
||||||
|
@AllowViewer()
|
||||||
getRecommendations(@Req() req: any) {
|
getRecommendations(@Req() req: any) {
|
||||||
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { OrganizationsService } from './organizations.service';
|
import { OrganizationsService } from './organizations.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
@@ -23,6 +23,13 @@ export class OrganizationsController {
|
|||||||
return this.orgService.findByUser(req.user.sub);
|
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 ──
|
// ── Org Member Management ──
|
||||||
|
|
||||||
private requireTenantAdmin(req: any) {
|
private requireTenantAdmin(req: any) {
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export class OrganizationsService {
|
|||||||
return this.orgRepository.save(org);
|
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) {
|
async findByUser(userId: string) {
|
||||||
const memberships = await this.userOrgRepository.find({
|
const memberships = await this.userOrgRepository.find({
|
||||||
where: { userId, isActive: true },
|
where: { userId, isActive: true },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export class ProjectsService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
const projects = await this.tenant.query(
|
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);
|
return this.computeFunding(projects);
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ export class ProjectsService {
|
|||||||
|
|
||||||
async findForPlanning() {
|
async findForPlanning() {
|
||||||
const projects = await this.tenant.query(
|
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);
|
return this.computeFunding(projects);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-sankey')
|
@Get('cash-flow-sankey')
|
||||||
getCashFlowSankey(@Query('year') year?: string) {
|
getCashFlowSankey(
|
||||||
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
|
@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')
|
@Get('cash-flow')
|
||||||
@@ -66,4 +74,20 @@ export class ReportsController {
|
|||||||
const mo = Math.min(parseInt(months || '') || 24, 48);
|
const mo = Math.min(parseInt(months || '') || 24, 48);
|
||||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,33 +83,151 @@ export class ReportsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCashFlowSankey(year: number) {
|
async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
|
||||||
// Get income accounts with amounts
|
let income: any[];
|
||||||
const income = await this.tenant.query(`
|
let expenses: any[];
|
||||||
|
|
||||||
|
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
|
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
WHERE a.account_type = 'income' AND a.is_active = true
|
WHERE a.account_type = 'income' AND a.is_active = true${fundCondition}
|
||||||
GROUP BY a.id, a.name
|
GROUP BY a.id, a.name
|
||||||
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
||||||
ORDER BY amount DESC
|
ORDER BY amount DESC
|
||||||
`, [year]);
|
`, fundParams);
|
||||||
|
|
||||||
const expenses = await this.tenant.query(`
|
expenses = await this.tenant.query(`
|
||||||
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
WHERE a.account_type = 'expense' AND a.is_active = true
|
WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition}
|
||||||
GROUP BY a.id, a.name, a.fund_type
|
GROUP BY a.id, a.name, a.fund_type
|
||||||
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
|
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
|
||||||
ORDER BY amount DESC
|
ORDER BY amount DESC
|
||||||
`, [year]);
|
`, fundParams);
|
||||||
|
}
|
||||||
|
|
||||||
if (!income.length && !expenses.length) {
|
if (!income.length && !expenses.length) {
|
||||||
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
|
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 totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||||
const totalReserve = reserveItems.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 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 {
|
return {
|
||||||
from, to,
|
from, to,
|
||||||
@@ -444,24 +563,43 @@ export class ReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDashboardKPIs() {
|
async getDashboardKPIs() {
|
||||||
// Total cash: ALL asset accounts (not just those named "Cash")
|
// Operating cash (asset accounts, fund_type=operating)
|
||||||
// Uses proper double-entry balance: debit - credit for assets
|
const opCash = await this.tenant.query(`
|
||||||
const cash = await this.tenant.query(`
|
|
||||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
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
|
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
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
`);
|
`);
|
||||||
// Also include investment account current_value in total cash
|
// Reserve cash (asset accounts, fund_type=reserve)
|
||||||
const investmentCash = await this.tenant.query(`
|
const resCash = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(current_value), 0) as total
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
FROM investment_accounts WHERE is_active = true
|
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
|
// Receivables: sum of unpaid invoices
|
||||||
const ar = await this.tenant.query(`
|
const ar = await this.tenant.query(`
|
||||||
@@ -469,9 +607,7 @@ export class ReportsService {
|
|||||||
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100)
|
// Reserve fund balance via equity accounts + reserve investments
|
||||||
// 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
|
|
||||||
const reserves = await this.tenant.query(`
|
const reserves = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
||||||
@@ -482,17 +618,43 @@ export class ReportsService {
|
|||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) 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)
|
// Delinquent count (overdue invoices)
|
||||||
const delinquent = await this.tenant.query(`
|
const delinquent = await this.tenant.query(`
|
||||||
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
|
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
|
// Recent transactions
|
||||||
const recentTx = await this.tenant.query(`
|
const recentTx = await this.tenant.query(`
|
||||||
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
||||||
@@ -504,9 +666,17 @@ export class ReportsService {
|
|||||||
return {
|
return {
|
||||||
total_cash: totalCash.toFixed(2),
|
total_cash: totalCash.toFixed(2),
|
||||||
total_receivables: ar[0]?.total || '0.00',
|
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'),
|
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
||||||
recent_transactions: recentTx,
|
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,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export class User {
|
|||||||
@Column({ name: 'is_platform_owner', default: false })
|
@Column({ name: 'is_platform_owner', default: false })
|
||||||
isPlatformOwner: boolean;
|
isPlatformOwner: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'has_seen_intro', default: false })
|
||||||
|
hasSeenIntro: boolean;
|
||||||
|
|
||||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||||
lastLoginAt: Date;
|
lastLoginAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
||||||
// Protect platform owner from having superadmin removed
|
// Protect platform owner from having superadmin removed
|
||||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||||
|
|||||||
27
backend/src/modules/vendors/vendors.service.ts
vendored
27
backend/src/modules/vendors/vendors.service.ts
vendored
@@ -17,10 +17,10 @@ export class VendorsService {
|
|||||||
|
|
||||||
async create(dto: any) {
|
async create(dto: any) {
|
||||||
const rows = await this.tenant.query(
|
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)
|
`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) RETURNING *`,
|
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.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];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -32,24 +32,25 @@ export class VendorsService {
|
|||||||
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
|
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),
|
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),
|
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 *`,
|
WHERE id = $1 RETURNING *`,
|
||||||
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
|
[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];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportCSV(): Promise<string> {
|
async exportCSV(): Promise<string> {
|
||||||
const rows = await this.tenant.query(
|
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`,
|
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(',')];
|
const lines = [headers.join(',')];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
lines.push(headers.map((h) => {
|
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);
|
const s = String(v);
|
||||||
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
}).join(','));
|
}).join(','));
|
||||||
@@ -80,20 +81,22 @@ export class VendorsService {
|
|||||||
zip_code = COALESCE(NULLIF($8, ''), zip_code),
|
zip_code = COALESCE(NULLIF($8, ''), zip_code),
|
||||||
tax_id = COALESCE(NULLIF($9, ''), tax_id),
|
tax_id = COALESCE(NULLIF($9, ''), tax_id),
|
||||||
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
|
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
|
||||||
|
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
|
[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++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
|
`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)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
[name, row.contact_name || null, row.email || null, row.phone || null,
|
[name, row.contact_name || null, row.email || null, row.phone || null,
|
||||||
row.address_line1 || null, row.city || null, row.state || null,
|
row.address_line1 || null, row.city || null, row.state || null,
|
||||||
row.zip_code || null, row.tax_id || 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++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|||||||
16
db/migrations/008-vendor-last-negotiated.sql
Normal file
16
db/migrations/008-vendor-last-negotiated.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: Add last_negotiated date to vendors table
|
||||||
|
-- Bug & Tweak Sprint
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tenant_schema TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR tenant_schema IN
|
||||||
|
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER TABLE %I.vendors ADD COLUMN IF NOT EXISTS last_negotiated DATE',
|
||||||
|
tenant_schema
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
9
db/migrations/009-onboarding-flags.sql
Normal file
9
db/migrations/009-onboarding-flags.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: Add onboarding tracking flag to users table
|
||||||
|
-- Phase 7: Onboarding Features
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE shared.users
|
||||||
|
ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
34
db/migrations/010-health-scores.sql
Normal file
34
db/migrations/010-health-scores.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- Migration: Add health_scores table to all tenant schemas
|
||||||
|
-- This table stores AI-derived operating and reserve fund health scores
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tenant RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR tenant IN
|
||||||
|
SELECT schema_name FROM shared.organizations WHERE status = 'active'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE TABLE IF NOT EXISTS %I.health_scores (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
score_type VARCHAR(20) NOT NULL CHECK (score_type IN (''operating'', ''reserve'')),
|
||||||
|
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||||
|
previous_score INTEGER,
|
||||||
|
trajectory VARCHAR(20) CHECK (trajectory IN (''improving'', ''stable'', ''declining'')),
|
||||||
|
label VARCHAR(30),
|
||||||
|
summary TEXT,
|
||||||
|
factors JSONB,
|
||||||
|
recommendations JSONB,
|
||||||
|
missing_data JSONB,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT ''complete'' CHECK (status IN (''complete'', ''pending'', ''error'')),
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)', tenant.schema_name
|
||||||
|
);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_%s_hs_type_calc ON %I.health_scores(score_type, calculated_at DESC)',
|
||||||
|
replace(tenant.schema_name, '.', '_'), tenant.schema_name
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
375
docs/DEPLOYMENT.md
Normal file
375
docs/DEPLOYMENT.md
Normal 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 **1–2 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 50–80% 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
3192
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "0.2.0",
|
"version": "2026.3.2-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { SankeyPage } from './pages/reports/SankeyPage';
|
|||||||
import { CashFlowPage } from './pages/reports/CashFlowPage';
|
import { CashFlowPage } from './pages/reports/CashFlowPage';
|
||||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||||
|
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
||||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||||
@@ -135,6 +136,7 @@ export function App() {
|
|||||||
<Route path="reports/aging" element={<AgingReportPage />} />
|
<Route path="reports/aging" element={<AgingReportPage />} />
|
||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
|
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
@@ -9,17 +10,53 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
} from '@tabler/icons-react';
|
} 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 { useAuthStore } from '../../stores/authStore';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { AppTour } from '../onboarding/AppTour';
|
||||||
|
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
import logoSrc from '../../assets/logo.svg';
|
import logoSrc from '../../assets/logo.svg';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [opened, { toggle, close }] = useDisclosure();
|
const [opened, { toggle, close }] = useDisclosure();
|
||||||
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -145,6 +182,10 @@ export function AppLayout() {
|
|||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
|
{/* ── Onboarding Components ── */}
|
||||||
|
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||||
|
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,23 +30,23 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: 'Financials',
|
label: 'Financials',
|
||||||
items: [
|
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: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
|
||||||
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
|
{ 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',
|
label: 'Assessments',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
{ 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',
|
label: 'Transactions',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
|
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
],
|
],
|
||||||
@@ -56,7 +56,7 @@ const navSections = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
||||||
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-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' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -66,6 +66,7 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
icon: IconChartSankey,
|
icon: IconChartSankey,
|
||||||
|
tourId: 'nav-reports',
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
||||||
{ label: 'Income Statement', path: '/reports/income-statement' },
|
{ label: 'Income Statement', path: '/reports/income-statement' },
|
||||||
@@ -74,6 +75,7 @@ const navSections = [
|
|||||||
{ label: 'Aging Report', path: '/reports/aging' },
|
{ label: 'Aging Report', path: '/reports/aging' },
|
||||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||||
{ label: 'Year-End', path: '/reports/year-end' },
|
{ label: 'Year-End', path: '/reports/year-end' },
|
||||||
|
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -127,7 +129,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea p="sm">
|
<ScrollArea p="sm" data-tour="sidebar-nav">
|
||||||
{navSections.map((section, sIdx) => (
|
{navSections.map((section, sIdx) => (
|
||||||
<div key={sIdx}>
|
<div key={sIdx}>
|
||||||
{section.label && (
|
{section.label && (
|
||||||
@@ -147,6 +149,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
defaultOpened={item.children.some((c: any) =>
|
defaultOpened={item.children.some((c: any) =>
|
||||||
location.pathname.startsWith(c.path),
|
location.pathname.startsWith(c.path),
|
||||||
)}
|
)}
|
||||||
|
data-tour={item.tourId || undefined}
|
||||||
>
|
>
|
||||||
{item.children.map((child: any) => (
|
{item.children.map((child: any) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -164,6 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
leftSection={<item.icon size={18} />}
|
leftSection={<item.icon size={18} />}
|
||||||
active={location.pathname === item.path}
|
active={location.pathname === item.path}
|
||||||
onClick={() => go(item.path!)}
|
onClick={() => go(item.path!)}
|
||||||
|
data-tour={item.tourId || undefined}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
93
frontend/src/components/onboarding/AppTour.tsx
Normal file
93
frontend/src/components/onboarding/AppTour.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import Joyride, { type CallBackProps, STATUS, ACTIONS, EVENTS } from 'react-joyride';
|
||||||
|
import { TOUR_STEPS } from '../../config/tourSteps';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface AppTourProps {
|
||||||
|
run: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppTour({ run, onComplete }: AppTourProps) {
|
||||||
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
|
const setUserIntroSeen = useAuthStore((s) => s.setUserIntroSeen);
|
||||||
|
|
||||||
|
const handleCallback = useCallback(
|
||||||
|
async (data: CallBackProps) => {
|
||||||
|
const { status, action, type } = data;
|
||||||
|
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
|
||||||
|
|
||||||
|
if (finishedStatuses.includes(status)) {
|
||||||
|
// Mark intro as seen on backend (fire-and-forget)
|
||||||
|
api.patch('/auth/intro-seen').catch(() => {});
|
||||||
|
setUserIntroSeen();
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle step navigation
|
||||||
|
if (type === EVENTS.STEP_AFTER) {
|
||||||
|
setStepIndex((prev) =>
|
||||||
|
action === ACTIONS.PREV ? prev - 1 : prev + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onComplete, setUserIntroSeen],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!run) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Joyride
|
||||||
|
steps={TOUR_STEPS}
|
||||||
|
run={run}
|
||||||
|
stepIndex={stepIndex}
|
||||||
|
continuous
|
||||||
|
showProgress
|
||||||
|
showSkipButton
|
||||||
|
scrollToFirstStep
|
||||||
|
disableOverlayClose
|
||||||
|
callback={handleCallback}
|
||||||
|
styles={{
|
||||||
|
options: {
|
||||||
|
primaryColor: '#228be6',
|
||||||
|
zIndex: 10000,
|
||||||
|
arrowColor: '#fff',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
textColor: '#333',
|
||||||
|
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
tooltipTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
buttonNext: {
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
padding: '8px 16px',
|
||||||
|
},
|
||||||
|
buttonBack: {
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
buttonSkip: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
locale={{
|
||||||
|
back: 'Previous',
|
||||||
|
close: 'Close',
|
||||||
|
last: 'Finish Tour',
|
||||||
|
next: 'Next',
|
||||||
|
skip: 'Skip Tour',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
|
||||||
|
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
|
||||||
|
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconBuildingBank, IconUsers, IconFileSpreadsheet,
|
||||||
|
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
|
||||||
|
IconAlertCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
|
interface OnboardingWizardProps {
|
||||||
|
opened: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnitRow {
|
||||||
|
unitNumber: string;
|
||||||
|
ownerName: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV Parsing (reused from BudgetsPage pattern) ──
|
||||||
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.split('\n').filter((l) => l.trim());
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
||||||
|
return lines.slice(1).map((line) => {
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (const char of line) {
|
||||||
|
if (char === '"') { inQuotes = !inQuotes; }
|
||||||
|
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
|
||||||
|
else { current += char; }
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((h, i) => { row[h] = values[i] || ''; });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
|
||||||
|
|
||||||
|
// ── Step 1: Account State ──
|
||||||
|
const [accountCreated, setAccountCreated] = useState(false);
|
||||||
|
const [accountName, setAccountName] = useState('Operating Checking');
|
||||||
|
const [accountNumber, setAccountNumber] = useState('1000');
|
||||||
|
const [accountDescription, setAccountDescription] = useState('');
|
||||||
|
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
||||||
|
|
||||||
|
// ── Step 2: Assessment Group State ──
|
||||||
|
const [groupCreated, setGroupCreated] = useState(false);
|
||||||
|
const [groupName, setGroupName] = useState('Standard Assessment');
|
||||||
|
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
||||||
|
const [frequency, setFrequency] = useState('monthly');
|
||||||
|
const [units, setUnits] = useState<UnitRow[]>([]);
|
||||||
|
const [unitsCreated, setUnitsCreated] = useState(false);
|
||||||
|
|
||||||
|
// ── Step 3: Budget State ──
|
||||||
|
const [budgetFile, setBudgetFile] = useState<File | null>(null);
|
||||||
|
const [budgetUploaded, setBudgetUploaded] = useState(false);
|
||||||
|
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
// ── Step 1: Create Account ──
|
||||||
|
const handleCreateAccount = async () => {
|
||||||
|
if (!accountName.trim()) {
|
||||||
|
setError('Account name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!accountNumber.trim()) {
|
||||||
|
setError('Account number is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const balance = typeof initialBalance === 'string' ? parseFloat(initialBalance) : initialBalance;
|
||||||
|
if (isNaN(balance)) {
|
||||||
|
setError('Initial balance must be a valid number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.post('/accounts', {
|
||||||
|
accountNumber: accountNumber.trim(),
|
||||||
|
name: accountName.trim(),
|
||||||
|
description: accountDescription.trim(),
|
||||||
|
accountType: 'asset',
|
||||||
|
fundType: 'operating',
|
||||||
|
initialBalance: balance,
|
||||||
|
});
|
||||||
|
setAccountCreated(true);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Account Created',
|
||||||
|
message: `${accountName} has been created with an initial balance of $${balance.toLocaleString()}`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Failed to create account';
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step 2: Create Assessment Group ──
|
||||||
|
const handleCreateGroup = async () => {
|
||||||
|
if (!groupName.trim()) {
|
||||||
|
setError('Group name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assessment = typeof regularAssessment === 'string' ? parseFloat(regularAssessment) : regularAssessment;
|
||||||
|
if (isNaN(assessment) || assessment <= 0) {
|
||||||
|
setError('Assessment amount must be greater than zero');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { data: group } = await api.post('/assessment-groups', {
|
||||||
|
name: groupName.trim(),
|
||||||
|
regularAssessment: assessment,
|
||||||
|
frequency,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
setGroupCreated(true);
|
||||||
|
|
||||||
|
// Create units if any were added
|
||||||
|
if (units.length > 0) {
|
||||||
|
let created = 0;
|
||||||
|
for (const unit of units) {
|
||||||
|
if (!unit.unitNumber.trim()) continue;
|
||||||
|
try {
|
||||||
|
await api.post('/units', {
|
||||||
|
unitNumber: unit.unitNumber.trim(),
|
||||||
|
ownerName: unit.ownerName.trim() || null,
|
||||||
|
ownerEmail: unit.ownerEmail.trim() || null,
|
||||||
|
assessmentGroupId: group.id,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
} catch {
|
||||||
|
// Continue even if a unit fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUnitsCreated(true);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Assessment Group Created',
|
||||||
|
message: `${groupName} created with ${created} unit(s)`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Assessment Group Created',
|
||||||
|
message: `${groupName} created successfully`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Failed to create assessment group';
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step 3: Budget Import ──
|
||||||
|
const handleDownloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/budgets/${currentYear}/template`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `budget_template_${currentYear}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to download template',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadBudget = async () => {
|
||||||
|
if (!budgetFile) {
|
||||||
|
setError('Please select a CSV file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const text = await budgetFile.text();
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
setError('CSV file appears to be empty or invalid');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
|
||||||
|
setBudgetUploaded(true);
|
||||||
|
setBudgetImportResult(data);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Budget Imported',
|
||||||
|
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Failed to import budget';
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Finish Wizard ──
|
||||||
|
const handleFinish = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.patch('/organizations/settings', { onboardingComplete: true });
|
||||||
|
setOrgSettings({ onboardingComplete: true });
|
||||||
|
onComplete();
|
||||||
|
} catch {
|
||||||
|
// Even if API fails, close the wizard — onboarding data is already created
|
||||||
|
onComplete();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Unit Rows ──
|
||||||
|
const addUnit = () => {
|
||||||
|
setUnits([...units, { unitNumber: '', ownerName: '', ownerEmail: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUnit = (index: number, field: keyof UnitRow, value: string) => {
|
||||||
|
const updated = [...units];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setUnits(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUnit = (index: number) => {
|
||||||
|
setUnits(units.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Navigation ──
|
||||||
|
const canGoNext = () => {
|
||||||
|
if (active === 0) return accountCreated;
|
||||||
|
if (active === 1) return groupCreated;
|
||||||
|
if (active === 2) return true; // Budget is optional
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
setError(null);
|
||||||
|
if (active < 3) setActive(active + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => {}} // Prevent closing without completing
|
||||||
|
withCloseButton={false}
|
||||||
|
size="xl"
|
||||||
|
centered
|
||||||
|
overlayProps={{ opacity: 0.6, blur: 3 }}
|
||||||
|
styles={{
|
||||||
|
body: { padding: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box px="xl" pt="xl" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
|
||||||
|
<IconRocket size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Set Up Your Organization</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Let's get the essentials configured so you can start managing your HOA finances.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box px="xl" py="lg">
|
||||||
|
<Stepper active={active} size="sm" mb="xl">
|
||||||
|
<Stepper.Step
|
||||||
|
label="Operating Account"
|
||||||
|
description="Set up your primary bank account"
|
||||||
|
icon={<IconBuildingBank size={18} />}
|
||||||
|
completedIcon={<IconCheck size={18} />}
|
||||||
|
/>
|
||||||
|
<Stepper.Step
|
||||||
|
label="Assessment Group"
|
||||||
|
description="Define homeowner assessments"
|
||||||
|
icon={<IconUsers size={18} />}
|
||||||
|
completedIcon={<IconCheck size={18} />}
|
||||||
|
/>
|
||||||
|
<Stepper.Step
|
||||||
|
label="Budget"
|
||||||
|
description="Import your annual budget"
|
||||||
|
icon={<IconFileSpreadsheet size={18} />}
|
||||||
|
completedIcon={<IconCheck size={18} />}
|
||||||
|
/>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md" withCloseButton onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 1: Create Operating Account ── */}
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Text fw={600} mb="xs">Create Your Primary Operating Account</Text>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
This is your HOA's main bank account for day-to-day operations. You can add more accounts later.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{accountCreated ? (
|
||||||
|
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||||
|
<Text fw={500}>{accountName} created successfully!</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={2} mb="md">
|
||||||
|
<TextInput
|
||||||
|
label="Account Name"
|
||||||
|
placeholder="e.g. Operating Checking"
|
||||||
|
value={accountName}
|
||||||
|
onChange={(e) => setAccountName(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Account Number"
|
||||||
|
placeholder="e.g. 1000"
|
||||||
|
value={accountNumber}
|
||||||
|
onChange={(e) => setAccountNumber(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Optional description"
|
||||||
|
value={accountDescription}
|
||||||
|
onChange={(e) => setAccountDescription(e.currentTarget.value)}
|
||||||
|
mb="md"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Current Balance"
|
||||||
|
description="Enter the current balance of this bank account"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={initialBalance}
|
||||||
|
onChange={setInitialBalance}
|
||||||
|
thousandSeparator=","
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
mb="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateAccount}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconBuildingBank size={16} />}
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Assessment Group + Units ── */}
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Text fw={600} mb="xs">Create an Assessment Group</Text>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Assessment groups define how much each homeowner pays and how often. You can create additional groups later for different unit types.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{groupCreated ? (
|
||||||
|
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||||
|
<Text fw={500}>{groupName} created successfully!</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
${(typeof regularAssessment === 'number' ? regularAssessment : parseFloat(regularAssessment as string) || 0).toLocaleString()} {frequency}
|
||||||
|
{unitsCreated && ` with ${units.length} unit(s)`}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={3} mb="md">
|
||||||
|
<TextInput
|
||||||
|
label="Group Name"
|
||||||
|
placeholder="e.g. Standard Assessment"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Assessment Amount"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={regularAssessment}
|
||||||
|
onChange={setRegularAssessment}
|
||||||
|
thousandSeparator=","
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Frequency"
|
||||||
|
value={frequency}
|
||||||
|
onChange={(v) => setFrequency(v || 'monthly')}
|
||||||
|
data={[
|
||||||
|
{ value: 'monthly', label: 'Monthly' },
|
||||||
|
{ value: 'quarterly', label: 'Quarterly' },
|
||||||
|
{ value: 'annual', label: 'Annual' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider my="md" label="Add Homeowner Units (Optional)" labelPosition="center" />
|
||||||
|
|
||||||
|
{units.length > 0 && (
|
||||||
|
<Table mb="md" striped withTableBorder>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Unit Number</Table.Th>
|
||||||
|
<Table.Th>Owner Name</Table.Th>
|
||||||
|
<Table.Th>Owner Email</Table.Th>
|
||||||
|
<Table.Th w={40}></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{units.map((unit, idx) => (
|
||||||
|
<Table.Tr key={idx}>
|
||||||
|
<Table.Td>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
placeholder="e.g. 101"
|
||||||
|
value={unit.unitNumber}
|
||||||
|
onChange={(e) => updateUnit(idx, 'unitNumber', e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
placeholder="John Smith"
|
||||||
|
value={unit.ownerName}
|
||||||
|
onChange={(e) => updateUnit(idx, 'ownerName', e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
value={unit.ownerEmail}
|
||||||
|
onChange={(e) => updateUnit(idx, 'ownerEmail', e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<ActionIcon color="red" variant="subtle" size="sm" onClick={() => removeUnit(idx)}>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
onClick={addUnit}
|
||||||
|
>
|
||||||
|
Add Unit
|
||||||
|
</Button>
|
||||||
|
<Text size="xs" c="dimmed">You can also import units in bulk later from the Units page.</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateGroup}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconUsers size={16} />}
|
||||||
|
>
|
||||||
|
Create Assessment Group
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: Budget Upload ── */}
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Upload a CSV file with your annual budget. If you don't have one ready, you can download a template
|
||||||
|
or skip this step and set it up later from the Budgets page.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{budgetUploaded ? (
|
||||||
|
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||||
|
<Text fw={500}>Budget imported successfully!</Text>
|
||||||
|
{budgetImportResult && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
>
|
||||||
|
Download CSV Template
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
label="Upload Budget CSV"
|
||||||
|
placeholder="Click to select a .csv file"
|
||||||
|
accept=".csv"
|
||||||
|
value={budgetFile}
|
||||||
|
onChange={setBudgetFile}
|
||||||
|
mb="md"
|
||||||
|
leftSection={<IconFileSpreadsheet size={16} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleUploadBudget}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconFileSpreadsheet size={16} />}
|
||||||
|
disabled={!budgetFile}
|
||||||
|
>
|
||||||
|
Import Budget
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Completion Screen ── */}
|
||||||
|
{active === 3 && (
|
||||||
|
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
||||||
|
<IconCheck size={32} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={3} mb="xs">You're All Set!</Title>
|
||||||
|
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
||||||
|
Your organization is configured and ready to go. You can always update your accounts,
|
||||||
|
assessment groups, and budgets from the sidebar navigation.
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
||||||
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
|
<IconBuildingBank size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Badge color="green" size="sm">Done</Badge>
|
||||||
|
<Text size="xs" mt={4}>Account</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
|
<IconUsers size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Badge color="green" size="sm">Done</Badge>
|
||||||
|
<Text size="xs" mt={4}>Assessments</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
|
<IconFileSpreadsheet size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
|
||||||
|
{budgetUploaded ? 'Done' : 'Skipped'}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" mt={4}>Budget</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={handleFinish}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconRocket size={18} />}
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: 'blue', to: 'cyan' }}
|
||||||
|
>
|
||||||
|
Start Using LedgerIQ
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Navigation Buttons ── */}
|
||||||
|
{active < 3 && (
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
{active === 2 && !budgetUploaded && (
|
||||||
|
<Button variant="subtle" onClick={nextStep}>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={nextStep}
|
||||||
|
disabled={!canGoNext()}
|
||||||
|
>
|
||||||
|
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/config/tourSteps.ts
Normal file
68
frontend/src/config/tourSteps.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* How-To Intro Tour Steps
|
||||||
|
*
|
||||||
|
* Centralized configuration for the react-joyride walkthrough.
|
||||||
|
* Edit the title and content fields below to change tour text.
|
||||||
|
* Steps are ordered to mirror the natural workflow of the platform.
|
||||||
|
*/
|
||||||
|
import type { Step } from 'react-joyride';
|
||||||
|
|
||||||
|
export const TOUR_STEPS: Step[] = [
|
||||||
|
{
|
||||||
|
target: '[data-tour="dashboard-content"]',
|
||||||
|
title: 'Your Financial Dashboard',
|
||||||
|
content:
|
||||||
|
'Welcome to LedgerIQ! This dashboard gives you an at-a-glance view of your HOA\'s financial health — operating funds, reserve funds, receivables, delinquencies, and recent transactions. It updates automatically as you record activity.',
|
||||||
|
placement: 'center',
|
||||||
|
disableBeacon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="sidebar-nav"]',
|
||||||
|
title: 'Navigation',
|
||||||
|
content:
|
||||||
|
'The sidebar organizes all your tools into five sections: Financials, Assessments, Transactions, Planning, and Reports. Click any item to navigate directly to that module.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-accounts"]',
|
||||||
|
title: 'Chart of Accounts',
|
||||||
|
content:
|
||||||
|
'Manage your Chart of Accounts here. Set up operating and reserve fund bank accounts, track balances, record opening balances, and manage your investment accounts — all separated by fund type.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-assessment-groups"]',
|
||||||
|
title: 'Assessments & Homeowners',
|
||||||
|
content:
|
||||||
|
'Create assessment groups to define your monthly, quarterly, or annual HOA dues. Add homeowner units, assign them to groups, and generate invoices automatically based on your assessment schedule.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-transactions"]',
|
||||||
|
title: 'Transactions & Journal Entries',
|
||||||
|
content:
|
||||||
|
'Record all financial activity here through double-entry journal entries. The system also automatically creates entries when you record payments, generate invoices, or set opening balances.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-budgets"]',
|
||||||
|
title: 'Budget Management',
|
||||||
|
content:
|
||||||
|
'Create and manage annual budgets for every income and expense account. You can enter amounts manually by month or import your budget from a CSV file for quick setup.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-reports"]',
|
||||||
|
title: 'Financial Reports',
|
||||||
|
content:
|
||||||
|
'Generate comprehensive reports including Balance Sheet, Income Statement, Cash Flow Statement, Budget vs Actual, Aging Report, and more. All reports are generated in real-time from your journal data.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '[data-tour="nav-investment-planning"]',
|
||||||
|
title: 'AI Investment Planning',
|
||||||
|
content:
|
||||||
|
'Use AI-powered recommendations to optimize your reserve fund investments. The system analyzes current market rates for CDs, money market accounts, and high-yield savings to suggest the best allocation strategy.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
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 [filterType, setFilterType] = useState<string | null>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ── Accounts query ──
|
// ── Accounts query ──
|
||||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||||
@@ -434,14 +436,44 @@ export function AccountsPage() {
|
|||||||
// Net position = assets + investments - liabilities
|
// Net position = assets + investments - liabilities
|
||||||
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
||||||
|
|
||||||
// ── Estimated monthly interest across all accounts with rates ──
|
// ── Estimated monthly interest across all accounts + investments with rates ──
|
||||||
const estMonthlyInterest = accounts
|
const acctMonthlyInterest = accounts
|
||||||
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
||||||
.reduce((sum, a) => {
|
.reduce((sum, a) => {
|
||||||
const bal = parseFloat(a.balance || '0');
|
const bal = parseFloat(a.balance || '0');
|
||||||
const rate = parseFloat(a.interest_rate || '0');
|
const rate = parseFloat(a.interest_rate || '0');
|
||||||
return sum + (bal * (rate / 100) / 12);
|
return sum + (bal * (rate / 100) / 12);
|
||||||
}, 0);
|
}, 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 ──
|
// ── Adjust modal: current balance from trial balance ──
|
||||||
const adjustCurrentBalance = adjustingAccount
|
const adjustCurrentBalance = adjustingAccount
|
||||||
@@ -472,37 +504,35 @@ export function AccountsPage() {
|
|||||||
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
{!isReadOnly && (
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
Add Account
|
Add Account
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Cash on Hand</Text>
|
<Text size="xs" c="dimmed">Operating Fund</Text>
|
||||||
<Text fw={700} size="sm" c="green">{fmt(totalsByType['asset'] || 0)}</Text>
|
<Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
|
||||||
|
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
|
||||||
</Card>
|
</Card>
|
||||||
{investmentTotal > 0 && (
|
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Investments</Text>
|
<Text size="xs" c="dimmed">Reserve Fund</Text>
|
||||||
<Text fw={700} size="sm" c="teal">{fmt(investmentTotal)}</Text>
|
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
|
||||||
|
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
{(totalsByType['liability'] || 0) > 0 && (
|
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Liabilities</Text>
|
<Text size="xs" c="dimmed">Total All Funds</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 fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</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>
|
</Card>
|
||||||
{estMonthlyInterest > 0 && (
|
{estMonthlyInterest > 0 && (
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
||||||
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -552,7 +582,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{investments.filter(i => i.is_active).length > 0 && (
|
{investments.filter(i => i.is_active).length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -570,7 +600,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{operatingInvestments.length > 0 && (
|
{operatingInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -588,7 +618,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{reserveInvestments.length > 0 && (
|
{reserveInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -606,7 +636,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
isArchivedView
|
isArchivedView
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -908,6 +938,7 @@ function AccountTable({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onSetPrimary,
|
onSetPrimary,
|
||||||
onAdjustBalance,
|
onAdjustBalance,
|
||||||
|
isReadOnly = false,
|
||||||
isArchivedView = false,
|
isArchivedView = false,
|
||||||
}: {
|
}: {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
@@ -915,6 +946,7 @@ function AccountTable({
|
|||||||
onArchive: (a: Account) => void;
|
onArchive: (a: Account) => void;
|
||||||
onSetPrimary: (id: string) => void;
|
onSetPrimary: (id: string) => void;
|
||||||
onAdjustBalance: (a: Account) => void;
|
onAdjustBalance: (a: Account) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
isArchivedView?: boolean;
|
isArchivedView?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
||||||
@@ -1003,6 +1035,7 @@ function AccountTable({
|
|||||||
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
{!a.is_system && (
|
{!a.is_system && (
|
||||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||||
@@ -1039,6 +1072,7 @@ function AccountTable({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
);
|
);
|
||||||
@@ -1090,6 +1124,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Th>Name</Table.Th>
|
<Table.Th>Name</Table.Th>
|
||||||
<Table.Th>Institution</Table.Th>
|
<Table.Th>Institution</Table.Th>
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
<Table.Th ta="right">Principal</Table.Th>
|
<Table.Th ta="right">Principal</Table.Th>
|
||||||
<Table.Th ta="right">Current Value</Table.Th>
|
<Table.Th ta="right">Current Value</Table.Th>
|
||||||
<Table.Th ta="right">Rate</Table.Th>
|
<Table.Th ta="right">Rate</Table.Th>
|
||||||
@@ -1103,7 +1138,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{investments.length === 0 && (
|
{investments.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={11}>
|
<Table.Td colSpan={12}>
|
||||||
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
|
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -1117,6 +1152,11 @@ function InvestmentMiniTable({
|
|||||||
{inv.investment_type}
|
{inv.investment_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</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.principal)}</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || 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>
|
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface AssessmentGroup {
|
interface AssessmentGroup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,6 +53,7 @@ export function AssessmentGroupsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
||||||
queryKey: ['assessment-groups'],
|
queryKey: ['assessment-groups'],
|
||||||
@@ -156,9 +158,11 @@ export function AssessmentGroupsPage() {
|
|||||||
<Title order={2}>Assessment Groups</Title>
|
<Title order={2}>Assessment Groups</Title>
|
||||||
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
|
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
|
||||||
</div>
|
</div>
|
||||||
|
{!isReadOnly && (
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
Add Group
|
Add Group
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||||
@@ -274,6 +278,7 @@ export function AssessmentGroupsPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
|
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -296,6 +301,7 @@ export function AssessmentGroupsPage() {
|
|||||||
<IconArchive size={16} />
|
<IconArchive size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -96,6 +97,7 @@ export function BudgetsPage() {
|
|||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { isLoading } = useQuery<BudgetLine[]>({
|
const { isLoading } = useQuery<BudgetLine[]>({
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
@@ -236,8 +238,12 @@ export function BudgetsPage() {
|
|||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
||||||
|
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 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);
|
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -253,6 +259,7 @@ export function BudgetsPage() {
|
|||||||
>
|
>
|
||||||
Download Template
|
Download Template
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isReadOnly && (<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
leftSection={<IconUpload size={16} />}
|
leftSection={<IconUpload size={16} />}
|
||||||
@@ -271,6 +278,7 @@ export function BudgetsPage() {
|
|||||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||||
Save Budget
|
Save Budget
|
||||||
</Button>
|
</Button>
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -284,17 +292,23 @@ export function BudgetsPage() {
|
|||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Card withBorder p="sm">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Total Income</Text>
|
<Text size="xs" c="dimmed">Operating Income</Text>
|
||||||
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
|
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
|
||||||
</Card>
|
</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">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Total Expenses</Text>
|
<Text size="xs" c="dimmed">Total Expenses</Text>
|
||||||
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="sm">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Net</Text>
|
<Text size="xs" c="dimmed">Net (Operating)</Text>
|
||||||
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
||||||
{fmt(totalIncome - totalExpense)}
|
{fmt(totalOperatingIncome - totalExpense)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -384,6 +398,7 @@ export function BudgetsPage() {
|
|||||||
hideControls
|
hideControls
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
min={0}
|
min={0}
|
||||||
|
disabled={isReadOnly}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -29,7 +30,7 @@ interface Project {
|
|||||||
fund_source: string;
|
fund_source: string;
|
||||||
funded_percentage: string;
|
funded_percentage: string;
|
||||||
planned_date: string;
|
planned_date: string;
|
||||||
target_year: number;
|
target_year: number | null;
|
||||||
target_month: number;
|
target_month: number;
|
||||||
status: string;
|
status: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -37,6 +38,7 @@ interface Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FUTURE_YEAR = 9999;
|
const FUTURE_YEAR = 9999;
|
||||||
|
const UNSCHEDULED = -1; // sentinel for projects with no target_year
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
planned: 'blue', approved: 'green', in_progress: 'yellow',
|
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) =>
|
const fmt = (v: string | number) =>
|
||||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
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) => {
|
const formatPlannedDate = (d: string | null | undefined) => {
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
@@ -73,6 +76,9 @@ interface KanbanCardProps {
|
|||||||
|
|
||||||
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
||||||
const plannedLabel = formatPlannedDate(project.planned_date);
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -104,6 +110,11 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
|||||||
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
|
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
|
||||||
P{project.priority}
|
P{project.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{isBeyondWindow && (
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{project.target_year}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text size="xs" ff="monospace" fw={500} mb={4}>
|
<Text size="xs" ff="monospace" fw={500} mb={4}>
|
||||||
@@ -144,19 +155,26 @@ function KanbanColumn({
|
|||||||
isDragOver, onDragOverHandler, onDragLeave,
|
isDragOver, onDragOverHandler, onDragLeave,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
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 (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
radius="md"
|
radius="md"
|
||||||
p="sm"
|
p="sm"
|
||||||
miw={280}
|
miw={useWideLayout ? 580 : 280}
|
||||||
maw={320}
|
maw={useWideLayout ? 640 : 320}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
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,
|
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
||||||
transition: 'background-color 150ms ease, border 150ms ease',
|
transition: 'background-color 150ms ease, border 150ms ease',
|
||||||
}}
|
}}
|
||||||
@@ -166,8 +184,13 @@ function KanbanColumn({
|
|||||||
>
|
>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Title order={5}>{yearLabel(year)}</Title>
|
<Title order={5}>{yearLabel(year)}</Title>
|
||||||
|
<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>
|
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Text size="xs" c="dimmed" mb="xs">
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
{projects.length} project{projects.length !== 1 ? 's' : ''}
|
{projects.length} project{projects.length !== 1 ? 's' : ''}
|
||||||
@@ -178,6 +201,16 @@ function KanbanColumn({
|
|||||||
<Text size="xs" c="dimmed" ta="center" py="lg">
|
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||||
Drop projects here
|
Drop projects here
|
||||||
</Text>
|
</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) => (
|
projects.map((p) => (
|
||||||
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
<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 [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
||||||
const printModeRef = useRef(false);
|
const printModeRef = useRef(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
@@ -287,10 +321,10 @@ export function CapitalProjectsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const moveProjectMutation = useMutation({
|
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 };
|
const payload: Record<string, unknown> = { target_year };
|
||||||
// Derive planned_date based on the new 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;
|
payload.planned_date = null;
|
||||||
} else {
|
} else {
|
||||||
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
|
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
|
||||||
@@ -329,7 +363,7 @@ export function CapitalProjectsPage() {
|
|||||||
form.setValues({
|
form.setValues({
|
||||||
status: p.status || 'planned',
|
status: p.status || 'planned',
|
||||||
priority: p.priority || 3,
|
priority: p.priority || 3,
|
||||||
target_year: p.target_year,
|
target_year: p.target_year ?? currentYear,
|
||||||
target_month: p.target_month || 6,
|
target_month: p.target_month || 6,
|
||||||
planned_date: p.planned_date || '',
|
planned_date: p.planned_date || '',
|
||||||
notes: p.notes || '',
|
notes: p.notes || '',
|
||||||
@@ -352,7 +386,7 @@ export function CapitalProjectsPage() {
|
|||||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
source_year: project.target_year,
|
source_year: project.target_year ?? UNSCHEDULED,
|
||||||
target_month: project.target_month,
|
target_month: project.target_month,
|
||||||
}));
|
}));
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -376,7 +410,7 @@ export function CapitalProjectsPage() {
|
|||||||
if (payload.source_year !== targetYear) {
|
if (payload.source_year !== targetYear) {
|
||||||
moveProjectMutation.mutate({
|
moveProjectMutation.mutate({
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
target_year: targetYear,
|
target_year: targetYear === UNSCHEDULED ? null : targetYear,
|
||||||
target_month: payload.target_month || 6,
|
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
|
// 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 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 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)
|
// 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 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
|
// Kanban columns: Unscheduled + current..current+4 + Future
|
||||||
const kanbanYears = [...baseYears, FUTURE_YEAR];
|
const kanbanYears = [UNSCHEDULED, ...baseYears, FUTURE_YEAR];
|
||||||
|
|
||||||
// ---- Loading state ----
|
// ---- Loading state ----
|
||||||
|
|
||||||
@@ -417,12 +456,11 @@ export function CapitalProjectsPage() {
|
|||||||
<Stack align="center" gap="md" maw={420}>
|
<Stack align="center" gap="md" maw={420}>
|
||||||
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
|
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
|
||||||
<Title order={3} c="dimmed" ta="center">
|
<Title order={3} c="dimmed" ta="center">
|
||||||
No projects in the capital plan
|
No projects yet
|
||||||
</Title>
|
</Title>
|
||||||
<Text c="dimmed" ta="center" size="sm">
|
<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
|
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>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -448,7 +486,9 @@ export function CapitalProjectsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
years.map((year) => {
|
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;
|
if (yearProjects.length === 0) return null;
|
||||||
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||||
return (
|
return (
|
||||||
@@ -479,7 +519,9 @@ export function CapitalProjectsPage() {
|
|||||||
<Table.Td fw={500}>{p.name}</Table.Td>
|
<Table.Td fw={500}>{p.name}</Table.Td>
|
||||||
<Table.Td>{p.category || '-'}</Table.Td>
|
<Table.Td>{p.category || '-'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{p.target_year === FUTURE_YEAR
|
{p.target_year === null
|
||||||
|
? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
|
||||||
|
: p.target_year === FUTURE_YEAR
|
||||||
? 'Future'
|
? 'Future'
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
@@ -511,9 +553,9 @@ export function CapitalProjectsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
|
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
@@ -528,11 +570,20 @@ export function CapitalProjectsPage() {
|
|||||||
|
|
||||||
// ---- Render: Kanban view ----
|
// ---- Render: Kanban view ----
|
||||||
|
|
||||||
|
const maxPlannedYear = currentYear + 4; // last year in the 5-year window
|
||||||
|
|
||||||
const renderKanbanView = () => (
|
const renderKanbanView = () => (
|
||||||
<ScrollArea type="auto" offsetScrollbars>
|
<ScrollArea type="auto" offsetScrollbars>
|
||||||
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
|
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
|
||||||
{kanbanYears.map((year) => {
|
{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 (
|
return (
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
key={year}
|
key={year}
|
||||||
|
|||||||
@@ -1,17 +1,228 @@
|
|||||||
import {
|
import {
|
||||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||||
Badge, Loader, Center,
|
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
|
||||||
|
Popover, List,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCash,
|
IconCash,
|
||||||
IconFileInvoice,
|
IconFileInvoice,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
|
IconBuildingBank,
|
||||||
|
IconTrendingUp,
|
||||||
|
IconTrendingDown,
|
||||||
|
IconMinus,
|
||||||
|
IconHeartbeat,
|
||||||
|
IconRefresh,
|
||||||
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface HealthScore {
|
||||||
|
id: string;
|
||||||
|
score_type: string;
|
||||||
|
score: number;
|
||||||
|
previous_score: number | null;
|
||||||
|
trajectory: string | null;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>;
|
||||||
|
recommendations: Array<{ priority: string; text: string }>;
|
||||||
|
missing_data: string[] | null;
|
||||||
|
status: string;
|
||||||
|
response_time_ms: number | null;
|
||||||
|
calculated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthScoresData {
|
||||||
|
operating: HealthScore | null;
|
||||||
|
reserve: HealthScore | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreColor(score: number): string {
|
||||||
|
if (score >= 75) return 'green';
|
||||||
|
if (score >= 60) return 'yellow';
|
||||||
|
if (score >= 40) return 'orange';
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
|
||||||
|
if (trajectory === 'improving') return <IconTrendingUp size={16} color="var(--mantine-color-green-6)" />;
|
||||||
|
if (trajectory === 'declining') return <IconTrendingDown size={16} color="var(--mantine-color-red-6)" />;
|
||||||
|
if (trajectory === 'stable') return <IconMinus size={16} color="var(--mantine-color-gray-6)" />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; title: string; icon: React.ReactNode }) {
|
||||||
|
if (!score) {
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
<Center h={100}>
|
||||||
|
<Text c="dimmed" size="sm">No health score yet</Text>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score.status === 'pending') {
|
||||||
|
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
|
||||||
|
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
<Center>
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
||||||
|
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
|
||||||
|
{missingItems.map((item: string, i: number) => (
|
||||||
|
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score.status === 'error') {
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
<Center h={100}>
|
||||||
|
<Badge color="red" variant="light">Error calculating score</Badge>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = getScoreColor(score.score);
|
||||||
|
const factors = Array.isArray(score.factors) ? score.factors :
|
||||||
|
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
|
||||||
|
const recommendations = Array.isArray(score.recommendations) ? score.recommendations :
|
||||||
|
(typeof score.recommendations === 'string' ? JSON.parse(score.recommendations) : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
|
<Group align="flex-start" gap="lg">
|
||||||
|
<RingProgress
|
||||||
|
size={120}
|
||||||
|
thickness={12}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: score.score, color }]}
|
||||||
|
label={
|
||||||
|
<Stack align="center" gap={0}>
|
||||||
|
<Text fw={700} size="xl" ta="center" lh={1}>{score.score}</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center">/100</Text>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Badge color={color} variant="light" size="sm">{score.label}</Badge>
|
||||||
|
{score.trajectory && (
|
||||||
|
<Tooltip label={`Trend: ${score.trajectory}`}>
|
||||||
|
<Group gap={2}>
|
||||||
|
<TrajectoryIcon trajectory={score.trajectory} />
|
||||||
|
<Text size="xs" c="dimmed">{score.trajectory}</Text>
|
||||||
|
</Group>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{score.previous_score !== null && (
|
||||||
|
<Text size="xs" c="dimmed">(prev: {score.previous_score})</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" lineClamp={2}>{score.summary}</Text>
|
||||||
|
<Group gap={4} mt={2}>
|
||||||
|
{factors.slice(0, 3).map((f: any, i: number) => (
|
||||||
|
<Tooltip key={i} label={f.detail} multiline w={280}>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="dot"
|
||||||
|
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
{(factors.length > 3 || recommendations.length > 0) && (
|
||||||
|
<Popover width={350} position="bottom" shadow="md">
|
||||||
|
<Popover.Target>
|
||||||
|
<Badge size="xs" variant="light" color="blue" style={{ cursor: 'pointer' }}>
|
||||||
|
<IconInfoCircle size={10} /> Details
|
||||||
|
</Badge>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{factors.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text fw={600} size="xs">Factors</Text>
|
||||||
|
{factors.map((f: any, i: number) => (
|
||||||
|
<Group key={i} gap={6} wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="dot"
|
||||||
|
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">{f.detail}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{recommendations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider my={4} />
|
||||||
|
<Text fw={600} size="xs">Recommendations</Text>
|
||||||
|
<List size="xs" spacing={4}>
|
||||||
|
{recommendations.map((r: any, i: number) => (
|
||||||
|
<List.Item key={i}>
|
||||||
|
<Badge size="xs" color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'} variant="light" mr={4}>
|
||||||
|
{r.priority}
|
||||||
|
</Badge>
|
||||||
|
{r.text}
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{score.calculated_at && (
|
||||||
|
<Text size="xs" c="dimmed" ta="right" mt={4}>
|
||||||
|
Updated: {new Date(score.calculated_at).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
{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 {
|
interface DashboardData {
|
||||||
total_cash: string;
|
total_cash: string;
|
||||||
total_receivables: string;
|
total_receivables: string;
|
||||||
@@ -20,10 +231,19 @@ interface DashboardData {
|
|||||||
recent_transactions: {
|
recent_transactions: {
|
||||||
id: string; entry_date: string; description: string; entry_type: string; amount: string;
|
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() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<DashboardData>({
|
const { data, isLoading } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
@@ -31,15 +251,24 @@ export function DashboardPage() {
|
|||||||
enabled: !!currentOrg,
|
enabled: !!currentOrg,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: healthScores } = useQuery<HealthScoresData>({
|
||||||
|
queryKey: ['health-scores'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||||
|
enabled: !!currentOrg,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recalcMutation = useMutation({
|
||||||
|
mutationFn: () => api.post('/health-scores/calculate'),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const fmt = (v: string | number) =>
|
const fmt = (v: string | number) =>
|
||||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
const stats = [
|
const opInv = parseFloat(data?.operating_investments || '0');
|
||||||
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' },
|
const resInv = parseFloat(data?.reserve_investments || '0');
|
||||||
{ 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 entryTypeColors: Record<string, string> = {
|
const entryTypeColors: Record<string, string> = {
|
||||||
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
||||||
@@ -47,13 +276,8 @@ export function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack data-tour="dashboard-content">
|
||||||
<div>
|
|
||||||
<Title order={2}>Dashboard</Title>
|
<Title order={2}>Dashboard</Title>
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!currentOrg ? (
|
{!currentOrg ? (
|
||||||
<Card withBorder p="xl" ta="center">
|
<Card withBorder p="xl" ta="center">
|
||||||
@@ -66,24 +290,88 @@ export function DashboardPage() {
|
|||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
||||||
|
<Tooltip label="Recalculate health scores now">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-xs"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={recalcMutation.isPending}
|
||||||
|
onClick={() => recalcMutation.mutate()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
|
<HealthScoreCard
|
||||||
|
score={healthScores?.operating || null}
|
||||||
|
title="Operating Fund"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon color="green" variant="light" size={36} radius="md">
|
||||||
|
<IconHeartbeat size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<HealthScoreCard
|
||||||
|
score={healthScores?.reserve || null}
|
||||||
|
title="Reserve Fund"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon color="violet" variant="light" size={36} radius="md">
|
||||||
|
<IconHeartbeat size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
{stats.map((stat) => (
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Card key={stat.title} withBorder padding="lg" radius="md">
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
|
||||||
{stat.title}
|
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
|
||||||
</Text>
|
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
|
||||||
<Text fw={700} size="xl">
|
|
||||||
{stat.value}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
|
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||||
<stat.icon size={28} />
|
<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>
|
</ThemeIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
@@ -120,17 +408,31 @@ export function DashboardPage() {
|
|||||||
<Title order={4}>Quick Stats</Title>
|
<Title order={4}>Quick Stats</Title>
|
||||||
<Stack mt="sm" gap="xs">
|
<Stack mt="sm" gap="xs">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Cash Position</Text>
|
<Text size="sm" c="dimmed">Operating Cash</Text>
|
||||||
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
|
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
|
||||||
</Group>
|
</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">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
||||||
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
||||||
</Group>
|
</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">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
||||||
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Investment {
|
interface Investment {
|
||||||
id: string; name: string; institution: string; account_number_last4: string;
|
id: string; name: string; institution: string; account_number_last4: string;
|
||||||
@@ -25,6 +26,7 @@ export function InvestmentsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<Investment | null>(null);
|
const [editing, setEditing] = useState<Investment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
||||||
queryKey: ['investments'],
|
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 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 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 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) => {
|
const daysRemainingColor = (days: number | null) => {
|
||||||
if (days === null) return 'gray';
|
if (days === null) return 'gray';
|
||||||
@@ -90,12 +97,13 @@ export function InvestmentsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Investment Accounts</Title>
|
<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>
|
</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 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">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">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>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
@@ -133,7 +141,7 @@ export function InvestmentsPage() {
|
|||||||
) : '-'}
|
) : '-'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</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>
|
</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>}
|
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
interface ActualLine {
|
interface ActualLine {
|
||||||
@@ -64,6 +65,7 @@ export function MonthlyActualsPage() {
|
|||||||
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
||||||
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -204,6 +206,7 @@ export function MonthlyActualsPage() {
|
|||||||
hideControls
|
hideControls
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
allowNegative
|
allowNegative
|
||||||
|
disabled={isReadOnly}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -229,6 +232,7 @@ export function MonthlyActualsPage() {
|
|||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||||
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
||||||
|
{!isReadOnly && (
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
@@ -237,6 +241,7 @@ export function MonthlyActualsPage() {
|
|||||||
>
|
>
|
||||||
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface OrgMember {
|
interface OrgMember {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,6 +52,7 @@ export function OrgMembersPage() {
|
|||||||
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
||||||
queryKey: ['org-members'],
|
queryKey: ['org-members'],
|
||||||
@@ -162,9 +163,11 @@ export function OrgMembersPage() {
|
|||||||
<Title order={2}>Organization Members</Title>
|
<Title order={2}>Organization Members</Title>
|
||||||
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
{!isReadOnly && (
|
||||||
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
||||||
Add Member
|
Add Member
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
@@ -259,6 +262,7 @@ export function OrgMembersPage() {
|
|||||||
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
|
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Tooltip label="Change role">
|
<Tooltip label="Change role">
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
|
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
|
||||||
@@ -273,6 +277,7 @@ export function OrgMembersPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus } from '@tabler/icons-react';
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Payment {
|
interface Payment {
|
||||||
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
||||||
@@ -20,6 +21,7 @@ interface Payment {
|
|||||||
export function PaymentsPage() {
|
export function PaymentsPage() {
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
||||||
queryKey: ['payments'],
|
queryKey: ['payments'],
|
||||||
@@ -74,7 +76,7 @@ export function PaymentsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Payments</Title>
|
<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>
|
</Group>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen }
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -78,6 +79,7 @@ export function ProjectsPage() {
|
|||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
@@ -331,6 +333,7 @@ export function ProjectsPage() {
|
|||||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isReadOnly && (<>
|
||||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
loading={importMutation.isPending}>
|
loading={importMutation.isPending}>
|
||||||
Import CSV
|
Import CSV
|
||||||
@@ -339,6 +342,7 @@ export function ProjectsPage() {
|
|||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
+ Add Project
|
+ Add Project
|
||||||
</Button>
|
</Button>
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -451,9 +455,11 @@ export function ProjectsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
|
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
IconCash, IconArrowUpRight, IconArrowDownRight,
|
IconCash, IconArrowUpRight, IconArrowDownRight,
|
||||||
IconWallet, IconReportMoney, IconSearch,
|
IconWallet, IconReportMoney, IconSearch, IconHeartRateMonitor,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import api from '../../services/api';
|
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 = () => {
|
const handleApply = () => {
|
||||||
setQueryFrom(fromDate);
|
setQueryFrom(fromDate);
|
||||||
setQueryTo(toDate);
|
setQueryTo(toDate);
|
||||||
@@ -68,6 +78,10 @@ export function CashFlowPage() {
|
|||||||
|
|
||||||
const totalOperating = parseFloat(data?.total_operating || '0');
|
const totalOperating = parseFloat(data?.total_operating || '0');
|
||||||
const totalReserve = parseFloat(data?.total_reserve || '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 beginningCash = parseFloat(data?.beginning_cash || '0');
|
||||||
const endingCash = parseFloat(data?.ending_cash || '0');
|
const endingCash = parseFloat(data?.ending_cash || '0');
|
||||||
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
||||||
@@ -132,10 +146,14 @@ export function CashFlowPage() {
|
|||||||
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
|
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
|
||||||
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
|
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
|
||||||
</ThemeIcon>
|
</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>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
|
<Group justify="space-between" mb={4}>
|
||||||
{fmt(totalOperating)}
|
<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>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
@@ -143,20 +161,31 @@ export function CashFlowPage() {
|
|||||||
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
|
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
|
||||||
<IconReportMoney size={14} />
|
<IconReportMoney size={14} />
|
||||||
</ThemeIcon>
|
</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>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
|
<Group justify="space-between" mb={4}>
|
||||||
{fmt(totalReserve)}
|
<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>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
<Group gap="xs" mb={4}>
|
<Group gap="xs" mb={4}>
|
||||||
<ThemeIcon variant="light" color="teal" size="sm">
|
<ThemeIcon variant="light" color={aiRec?.overall_assessment ? 'teal' : 'gray'} size="sm">
|
||||||
<IconCash size={14} />
|
<IconHeartRateMonitor size={14} />
|
||||||
</ThemeIcon>
|
</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>
|
</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>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
|||||||
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal 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} · {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid,
|
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid, SegmentedControl,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +52,8 @@ export function SankeyPage() {
|
|||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
|
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
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 yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -59,9 +61,12 @@ export function SankeyPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<CashFlowData>({
|
const { data, isLoading, isError } = useQuery<CashFlowData>({
|
||||||
queryKey: ['sankey', year],
|
queryKey: ['sankey', year, source, fundFilter],
|
||||||
queryFn: async () => {
|
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;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -191,6 +196,31 @@ export function SankeyPage() {
|
|||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||||
</Group>
|
</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 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface ReserveComponent {
|
interface ReserveComponent {
|
||||||
id: string; name: string; category: string; description: string;
|
id: string; name: string; category: string; description: string;
|
||||||
@@ -26,6 +27,7 @@ export function ReservesPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
||||||
queryKey: ['reserve-components'],
|
queryKey: ['reserve-components'],
|
||||||
@@ -89,7 +91,7 @@ export function ReservesPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Reserve Components</Title>
|
<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>
|
</Group>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
@@ -139,7 +141,7 @@ export function ReservesPage() {
|
|||||||
{c.condition_rating}/10
|
{c.condition_rating}/10
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</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>
|
</Table.Tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function SettingsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Version</Text>
|
<Text size="sm" c="dimmed">Version</Text>
|
||||||
<Badge variant="light">0.2.0 MVP_P2</Badge>
|
<Badge variant="light">2026.3.2 (beta)</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">API</Text>
|
<Text size="sm" c="dimmed">API</Text>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
|
|||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface JournalEntryLine {
|
interface JournalEntryLine {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -48,6 +49,7 @@ export function TransactionsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [viewId, setViewId] = useState<string | null>(null);
|
const [viewId, setViewId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
||||||
queryKey: ['journal-entries'],
|
queryKey: ['journal-entries'],
|
||||||
@@ -164,9 +166,11 @@ export function TransactionsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Journal Entries</Title>
|
<Title order={2}>Journal Entries</Title>
|
||||||
|
{!isReadOnly && (
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={open}>
|
<Button leftSection={<IconPlus size={16} />} onClick={open}>
|
||||||
New Entry
|
New Entry
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
@@ -216,14 +220,14 @@ export function TransactionsPage() {
|
|||||||
<IconEye size={16} />
|
<IconEye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!e.is_posted && !e.is_void && (
|
{!isReadOnly && !e.is_posted && !e.is_void && (
|
||||||
<Tooltip label="Post">
|
<Tooltip label="Post">
|
||||||
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
|
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
|
||||||
<IconCheck size={16} />
|
<IconCheck size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{e.is_posted && !e.is_void && (
|
{!isReadOnly && e.is_posted && !e.is_void && (
|
||||||
<Tooltip label="Void">
|
<Tooltip label="Void">
|
||||||
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
|
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
|
||||||
<IconX size={16} />
|
<IconX size={16} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Unit {
|
interface Unit {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,6 +43,7 @@ export function UnitsPage() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||||
queryKey: ['units'],
|
queryKey: ['units'],
|
||||||
@@ -163,6 +165,7 @@ export function UnitsPage() {
|
|||||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isReadOnly && (<>
|
||||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
loading={importMutation.isPending}>
|
loading={importMutation.isPending}>
|
||||||
Import CSV
|
Import CSV
|
||||||
@@ -175,6 +178,7 @@ export function UnitsPage() {
|
|||||||
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -224,6 +228,7 @@ export function UnitsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
|
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
|
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
@@ -234,6 +239,7 @@ export function UnitsPage() {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
23
frontend/src/pages/vendors/VendorsPage.tsx
vendored
23
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -3,18 +3,21 @@ import {
|
|||||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||||
Switch, Badge, ActionIcon, Text, Loader, Center,
|
Switch, Badge, ActionIcon, Text, Loader, Center,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
id: string; name: string; contact_name: string; email: string; phone: string;
|
id: string; name: string; contact_name: string; email: string; phone: string;
|
||||||
address_line1: string; city: string; state: string; zip_code: string;
|
address_line1: string; city: string; state: string; zip_code: string;
|
||||||
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
|
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
|
||||||
|
last_negotiated: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorsPage() {
|
export function VendorsPage() {
|
||||||
@@ -23,6 +26,7 @@ export function VendorsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||||
queryKey: ['vendors'],
|
queryKey: ['vendors'],
|
||||||
@@ -34,12 +38,19 @@ export function VendorsPage() {
|
|||||||
name: '', contact_name: '', email: '', phone: '',
|
name: '', contact_name: '', email: '', phone: '',
|
||||||
address_line1: '', city: '', state: '', zip_code: '',
|
address_line1: '', city: '', state: '', zip_code: '',
|
||||||
tax_id: '', is_1099_eligible: false,
|
tax_id: '', is_1099_eligible: false,
|
||||||
|
last_negotiated: null as Date | null,
|
||||||
},
|
},
|
||||||
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
|
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
||||||
notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' });
|
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 || '',
|
phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '',
|
||||||
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
|
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
|
||||||
is_1099_eligible: v.is_1099_eligible,
|
is_1099_eligible: v.is_1099_eligible,
|
||||||
|
last_negotiated: v.last_negotiated ? new Date(v.last_negotiated) : null,
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
@@ -107,12 +119,14 @@ export function VendorsPage() {
|
|||||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isReadOnly && (<>
|
||||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
loading={importMutation.isPending}>
|
loading={importMutation.isPending}>
|
||||||
Import CSV
|
Import CSV
|
||||||
</Button>
|
</Button>
|
||||||
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
<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>
|
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
|
||||||
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
|
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
|
||||||
@@ -122,6 +136,7 @@ export function VendorsPage() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
|
<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>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.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@@ -133,11 +148,12 @@ export function VendorsPage() {
|
|||||||
<Table.Td>{v.email}</Table.Td>
|
<Table.Td>{v.email}</Table.Td>
|
||||||
<Table.Td>{v.phone}</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.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 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>
|
</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.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
||||||
@@ -157,6 +173,7 @@ export function VendorsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
|
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
|
||||||
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
|
<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>
|
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Organization {
|
|||||||
role: string;
|
role: string;
|
||||||
schemaName?: string;
|
schemaName?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
settings?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -16,6 +17,7 @@ interface User {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
isSuperadmin?: boolean;
|
isSuperadmin?: boolean;
|
||||||
isPlatformOwner?: boolean;
|
isPlatformOwner?: boolean;
|
||||||
|
hasSeenIntro?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImpersonationOriginal {
|
interface ImpersonationOriginal {
|
||||||
@@ -33,11 +35,16 @@ interface AuthState {
|
|||||||
impersonationOriginal: ImpersonationOriginal | null;
|
impersonationOriginal: ImpersonationOriginal | null;
|
||||||
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
setCurrentOrg: (org: Organization, token?: string) => void;
|
setCurrentOrg: (org: Organization, token?: string) => void;
|
||||||
|
setUserIntroSeen: () => void;
|
||||||
|
setOrgSettings: (settings: Record<string, any>) => void;
|
||||||
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
stopImpersonation: () => void;
|
stopImpersonation: () => void;
|
||||||
logout: () => 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>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -59,6 +66,16 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
currentOrg: org,
|
currentOrg: org,
|
||||||
token: token || state.token,
|
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) => {
|
startImpersonation: (token, user, organizations) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
set({
|
set({
|
||||||
@@ -97,7 +114,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-auth',
|
name: 'ledgeriq-auth',
|
||||||
version: 4,
|
version: 5,
|
||||||
migrate: () => ({
|
migrate: () => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user