Compare commits
1 Commits
9a082d2950
...
fix/viewer
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d137a40d3 |
26
backend/package-lock.json
generated
26
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.03.10",
|
"version": "2026.3.7-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.03.10",
|
"version": "2026.3.7-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
@@ -16,12 +16,10 @@
|
|||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"newrelic": "latest",
|
"newrelic": "latest",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -1793,17 +1791,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/throttler": {
|
|
||||||
"version": "6.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
|
||||||
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
|
||||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
|
||||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/typeorm": {
|
"node_modules/@nestjs/typeorm": {
|
||||||
"version": "10.0.2",
|
"version": "10.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
||||||
@@ -5290,15 +5277,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/helmet": {
|
|
||||||
"version": "8.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
|
||||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/html-entities": {
|
"node_modules/html-entities": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.11",
|
"version": "2026.3.7-beta",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,14 +25,11 @@
|
|||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"newrelic": "latest",
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
@@ -40,6 +37,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
|
"newrelic": "latest",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
@@ -53,10 +52,6 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
ThrottlerModule.forRoot([{
|
|
||||||
ttl: 60000, // 1-minute window
|
|
||||||
limit: 100, // 100 requests per minute (global default)
|
|
||||||
}]),
|
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
// In-memory cache for org info to avoid DB hit per request
|
// In-memory cache for org status to avoid DB hit per request
|
||||||
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
|
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
||||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -30,25 +30,23 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
const secret = this.configService.get<string>('JWT_SECRET');
|
const secret = this.configService.get<string>('JWT_SECRET');
|
||||||
const decoded = jwt.verify(token, secret!) as any;
|
const decoded = jwt.verify(token, secret!) as any;
|
||||||
if (decoded?.orgId) {
|
if (decoded?.orgSchema) {
|
||||||
// Look up org info (status + schema) from orgId with caching
|
// Check if the org is still active (catches post-JWT suspension)
|
||||||
const orgInfo = await this.getOrgInfo(decoded.orgId);
|
if (decoded.orgId) {
|
||||||
if (orgInfo) {
|
const status = await this.getOrgStatus(decoded.orgId);
|
||||||
if (['suspended', 'archived'].includes(orgInfo.status)) {
|
if (status && ['suspended', 'archived'].includes(status)) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
|
message: `This organization has been ${status}. Please contact your administrator.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
req.tenantSchema = orgInfo.schemaName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.tenantSchema = decoded.orgSchema;
|
||||||
req.orgId = decoded.orgId;
|
req.orgId = decoded.orgId;
|
||||||
req.userId = decoded.sub;
|
req.userId = decoded.sub;
|
||||||
req.userRole = decoded.role;
|
req.userRole = decoded.role;
|
||||||
} else if (decoded?.sub) {
|
|
||||||
// Superadmin or user without org — still set userId
|
|
||||||
req.userId = decoded.sub;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Token invalid or expired - let Passport handle the auth error
|
// Token invalid or expired - let Passport handle the auth error
|
||||||
@@ -57,23 +55,19 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
|
private async getOrgStatus(orgId: string): Promise<string | null> {
|
||||||
const cached = this.orgCache.get(orgId);
|
const cached = this.orgStatusCache.get(orgId);
|
||||||
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||||
return { status: cached.status, schemaName: cached.schemaName };
|
return cached.status;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.dataSource.query(
|
const result = await this.dataSource.query(
|
||||||
`SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
|
`SELECT status FROM shared.organizations WHERE id = $1`,
|
||||||
[orgId],
|
[orgId],
|
||||||
);
|
);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
this.orgCache.set(orgId, {
|
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
||||||
status: result[0].status,
|
return result[0].status;
|
||||||
schemaName: result[0].schemaName,
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
});
|
|
||||||
return { status: result[0].status, schemaName: result[0].schemaName };
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — don't block requests on cache miss errors
|
// Non-critical — don't block requests on cache miss errors
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as os from 'node:os';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import helmet from 'helmet';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||||
@@ -42,24 +41,6 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
|
|
||||||
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
|
|
||||||
app.use(
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://chat.hoaledgeriq.com'],
|
|
||||||
connectSrc: ["'self'", 'https://chat.hoaledgeriq.com', 'wss://chat.hoaledgeriq.com'],
|
|
||||||
imgSrc: ["'self'", 'data:', 'https://chat.hoaledgeriq.com'],
|
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
||||||
frameSrc: ["'self'", 'https://chat.hoaledgeriq.com'],
|
|
||||||
fontSrc: ["'self'", 'data:'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Request logging — only in development (too noisy / slow for prod)
|
// Request logging — only in development (too noisy / slow for prod)
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
app.use((req: any, _res: any, next: any) => {
|
app.use((req: any, _res: any, next: any) => {
|
||||||
@@ -82,17 +63,15 @@ async function bootstrap() {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Swagger docs — disabled in production to avoid exposing API surface
|
// Swagger docs — available in all environments
|
||||||
if (!isProduction) {
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('HOA LedgerIQ API')
|
.setTitle('HOA LedgerIQ API')
|
||||||
.setDescription('API for the HOA LedgerIQ')
|
.setDescription('API for the HOA LedgerIQ')
|
||||||
.setVersion('2026.3.11')
|
.setVersion('2026.3.7')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
}
|
|
||||||
|
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -24,14 +23,12 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
||||||
async register(@Body() dto: RegisterDto) {
|
async register(@Body() dto: RegisterDto) {
|
||||||
return this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
||||||
@UseGuards(AuthGuard('local'))
|
@UseGuards(AuthGuard('local'))
|
||||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export class AuthService {
|
|||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
orgId: membership.organizationId,
|
orgId: membership.organizationId,
|
||||||
|
orgSchema: membership.organization.schemaName,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ export class AuthService {
|
|||||||
|
|
||||||
if (defaultOrg) {
|
if (defaultOrg) {
|
||||||
payload.orgId = defaultOrg.organizationId;
|
payload.orgId = defaultOrg.organizationId;
|
||||||
|
payload.orgSchema = defaultOrg.organization?.schemaName;
|
||||||
payload.role = defaultOrg.role;
|
payload.role = defaultOrg.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +195,7 @@ export class AuthService {
|
|||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
name: uo.organization?.name,
|
name: uo.organization?.name,
|
||||||
|
schemaName: uo.organization?.schemaName,
|
||||||
status: uo.organization?.status,
|
status: uo.organization?.status,
|
||||||
role: uo.role,
|
role: uo.role,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
orgId: payload.orgId,
|
orgId: payload.orgId,
|
||||||
|
orgSchema: payload.orgSchema,
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
isSuperadmin: payload.isSuperadmin || false,
|
isSuperadmin: payload.isSuperadmin || false,
|
||||||
impersonatedBy: payload.impersonatedBy || null,
|
impersonatedBy: payload.impersonatedBy || null,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class HealthScoresController {
|
|||||||
@Get('latest')
|
@Get('latest')
|
||||||
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||||
getLatest(@Req() req: any) {
|
getLatest(@Req() req: any) {
|
||||||
const schema = req.tenantSchema;
|
const schema = req.user?.orgSchema;
|
||||||
return this.service.getLatestScores(schema);
|
return this.service.getLatestScores(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export class HealthScoresController {
|
|||||||
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
|
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculate(@Req() req: any) {
|
async calculate(@Req() req: any) {
|
||||||
const schema = req.tenantSchema;
|
const schema = req.user?.orgSchema;
|
||||||
|
|
||||||
// Fire-and-forget — background processing saves results to DB
|
// Fire-and-forget — background processing saves results to DB
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -44,7 +44,7 @@ export class HealthScoresController {
|
|||||||
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
|
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculateOperating(@Req() req: any) {
|
async calculateOperating(@Req() req: any) {
|
||||||
const schema = req.tenantSchema;
|
const schema = req.user?.orgSchema;
|
||||||
|
|
||||||
// Fire-and-forget
|
// Fire-and-forget
|
||||||
this.service.calculateScore(schema, 'operating').catch((err) => {
|
this.service.calculateScore(schema, 'operating').catch((err) => {
|
||||||
@@ -61,7 +61,7 @@ export class HealthScoresController {
|
|||||||
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
|
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculateReserve(@Req() req: any) {
|
async calculateReserve(@Req() req: any) {
|
||||||
const schema = req.tenantSchema;
|
const schema = req.user?.orgSchema;
|
||||||
|
|
||||||
// Fire-and-forget
|
// Fire-and-forget
|
||||||
this.service.calculateScore(schema, 'reserve').catch((err) => {
|
this.service.calculateScore(schema, 'reserve').catch((err) => {
|
||||||
|
|||||||
@@ -220,12 +220,12 @@ export class HealthScoresService {
|
|||||||
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
|
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should have reserve-funded projects with estimated costs (warn but don't block)
|
// Should have capital projects (warn but don't block)
|
||||||
const projects = await qr.query(
|
const projects = await qr.query(
|
||||||
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true AND fund_source = 'reserve'`,
|
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true`,
|
||||||
);
|
);
|
||||||
if (parseInt(projects[0].cnt) === 0) {
|
if (parseInt(projects[0].cnt) === 0) {
|
||||||
missing.push('No reserve-funded projects found. Add projects with estimated costs for an accurate funded-ratio calculation.');
|
missing.push('No capital projects found. Add planned capital projects for a more accurate reserve health assessment.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,12 +558,10 @@ export class HealthScoresService {
|
|||||||
FROM reserve_components
|
FROM reserve_components
|
||||||
ORDER BY remaining_life_years ASC NULLS LAST
|
ORDER BY remaining_life_years ASC NULLS LAST
|
||||||
`),
|
`),
|
||||||
// Capital projects (include component-level fields for funded ratio when reserve_components is empty)
|
// Capital projects
|
||||||
qr.query(`
|
qr.query(`
|
||||||
SELECT name, estimated_cost, actual_cost, target_year, target_month, fund_source,
|
SELECT name, estimated_cost, target_year, target_month, fund_source,
|
||||||
status, priority, current_fund_balance, funded_percentage,
|
status, priority, current_fund_balance, funded_percentage
|
||||||
category, useful_life_years, remaining_life_years, condition_rating,
|
|
||||||
annual_contribution
|
|
||||||
FROM projects
|
FROM projects
|
||||||
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
||||||
ORDER BY target_year, target_month NULLS LAST
|
ORDER BY target_year, target_month NULLS LAST
|
||||||
@@ -598,19 +596,11 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
const totalReserveFund = reserveCash + totalInvestments;
|
const totalReserveFund = reserveCash + totalInvestments;
|
||||||
|
|
||||||
// Use reserve_components for funded ratio when available; fall back to
|
const totalReplacementCost = reserveComponents
|
||||||
// reserve-funded projects (which carry the same estimated_cost / lifecycle
|
.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0);
|
||||||
// fields that users actually populate on the Projects page).
|
|
||||||
const reserveProjects = projects.filter((p: any) => p.fund_source === 'reserve');
|
|
||||||
const useComponentsTable = reserveComponents.length > 0;
|
|
||||||
|
|
||||||
const totalReplacementCost = useComponentsTable
|
const totalComponentFunded = reserveComponents
|
||||||
? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0)
|
.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0);
|
||||||
: reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.estimated_cost || '0'), 0);
|
|
||||||
|
|
||||||
const totalComponentFunded = useComponentsTable
|
|
||||||
? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0)
|
|
||||||
: reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.current_fund_balance || '0'), 0);
|
|
||||||
|
|
||||||
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
|
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
|
||||||
|
|
||||||
@@ -625,13 +615,9 @@ export class HealthScoresService {
|
|||||||
.filter((b: any) => b.account_type === 'expense')
|
.filter((b: any) => b.account_type === 'expense')
|
||||||
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
||||||
|
|
||||||
// Components needing replacement within 5 years — use whichever source has data
|
// Components needing replacement within 5 years
|
||||||
const urgentComponents = useComponentsTable
|
const urgentComponents = reserveComponents.filter(
|
||||||
? reserveComponents.filter(
|
|
||||||
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
||||||
)
|
|
||||||
: reserveProjects.filter(
|
|
||||||
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Build 12-month forward reserve cash flow projection ──
|
// ── Build 12-month forward reserve cash flow projection ──
|
||||||
@@ -763,7 +749,6 @@ export class HealthScoresService {
|
|||||||
accounts,
|
accounts,
|
||||||
investments,
|
investments,
|
||||||
reserveComponents,
|
reserveComponents,
|
||||||
reserveProjects,
|
|
||||||
projects,
|
projects,
|
||||||
budgets,
|
budgets,
|
||||||
assessments,
|
assessments,
|
||||||
@@ -974,15 +959,13 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
// Build component lines from reserve_components if available, otherwise from reserve-funded projects
|
const componentLines = data.reserveComponents.length === 0
|
||||||
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
|
? 'No reserve components tracked.'
|
||||||
const componentLines = componentSource.length === 0
|
: data.reserveComponents.map((c: any) => {
|
||||||
? 'No reserve components or reserve projects tracked.'
|
const cost = parseFloat(c.replacement_cost || '0');
|
||||||
: componentSource.map((c: any) => {
|
|
||||||
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(c.current_fund_balance || '0');
|
||||||
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
||||||
return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
|
return `- ${c.name} [${c.category}] | Life: ${c.useful_life_years}yr, Remaining: ${c.remaining_life_years}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const projectLines = data.projects.length === 0
|
const projectLines = data.projects.length === 0
|
||||||
@@ -998,7 +981,7 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
const urgentLines = data.urgentComponents.length === 0
|
const urgentLines = data.urgentComponents.length === 0
|
||||||
? 'None — no components due within 5 years.'
|
? 'None — no components due within 5 years.'
|
||||||
: data.urgentComponents.map((c: any) => {
|
: data.urgentComponents.map((c: any) => {
|
||||||
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
const cost = parseFloat(c.replacement_cost || '0');
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(c.current_fund_balance || '0');
|
||||||
const gap = cost - funded;
|
const gap = cost - funded;
|
||||||
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
|
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
|
||||||
@@ -1014,8 +997,8 @@ Reserve Cash (bank accounts): $${data.reserveCash.toFixed(2)}
|
|||||||
Reserve Investments: $${data.totalInvestments.toFixed(2)}
|
Reserve Investments: $${data.totalInvestments.toFixed(2)}
|
||||||
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
|
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
|
||||||
|
|
||||||
Total Replacement Cost (all components): ${data.totalReplacementCost > 0 ? '$' + data.totalReplacementCost.toFixed(2) : '$0.00 (no reserve components entered — funded ratio cannot be calculated)'}
|
Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)}
|
||||||
Percent Funded: ${data.totalReplacementCost > 0 ? data.percentFunded.toFixed(1) + '%' : 'N/A — no reserve components with replacement costs have been entered. Do NOT report a 0% funded ratio; instead note that funded ratio is unavailable due to missing component data.'}
|
Percent Funded: ${data.percentFunded.toFixed(1)}%
|
||||||
|
|
||||||
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
|
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
|
||||||
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
|
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ export class JournalEntriesService {
|
|||||||
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT je.*,
|
SELECT je.*,
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
|
||||||
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
|
|
||||||
ELSE COALESCE(SUM(jel.debit), 0)
|
|
||||||
END as total_debit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
|
||||||
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
|
|
||||||
ELSE COALESCE(SUM(jel.credit), 0)
|
|
||||||
END as total_credit,
|
|
||||||
json_agg(json_build_object(
|
json_agg(json_build_object(
|
||||||
'id', jel.id, 'account_id', jel.account_id,
|
'id', jel.id, 'account_id', jel.account_id,
|
||||||
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
||||||
|
|||||||
@@ -716,38 +716,14 @@ export class ReportsService {
|
|||||||
`);
|
`);
|
||||||
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
|
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
|
||||||
|
|
||||||
// Interest earned YTD: actual interest income from journal entries for current year
|
// Interest earned YTD: approximate from current_value - principal (unrealized gains)
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const interestEarned = await this.tenant.query(`
|
const interestEarned = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
|
SELECT COALESCE(SUM(current_value - principal), 0) as total
|
||||||
FROM accounts a
|
FROM investment_accounts WHERE is_active = true AND current_value > principal
|
||||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
`);
|
||||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
|
||||||
AND je.is_posted = true AND je.is_void = false
|
|
||||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
|
||||||
WHERE a.account_type = 'income' AND a.is_active = true
|
|
||||||
AND LOWER(a.name) LIKE '%interest%'
|
|
||||||
`, [currentYear]);
|
|
||||||
|
|
||||||
// Interest earned last year (for YoY comparison)
|
|
||||||
const interestLastYear = await this.tenant.query(`
|
|
||||||
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
|
|
||||||
FROM accounts a
|
|
||||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
|
||||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
|
||||||
AND je.is_posted = true AND je.is_void = false
|
|
||||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
|
||||||
WHERE a.account_type = 'income' AND a.is_active = true
|
|
||||||
AND LOWER(a.name) LIKE '%interest%'
|
|
||||||
`, [currentYear - 1]);
|
|
||||||
|
|
||||||
// Projected interest for current year (YTD actual + remaining months estimated)
|
|
||||||
const currentMonth = new Date().getMonth() + 1;
|
|
||||||
const ytdInterest = parseFloat(interestEarned[0]?.total || '0');
|
|
||||||
const monthlyAvg = currentMonth > 0 ? ytdInterest / currentMonth : 0;
|
|
||||||
const projectedInterest = ytdInterest + (monthlyAvg * (12 - currentMonth));
|
|
||||||
|
|
||||||
// Planned capital spend for current year
|
// Planned capital spend for current year
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
const capitalSpend = await this.tenant.query(`
|
const capitalSpend = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||||
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
||||||
@@ -773,9 +749,7 @@ export class ReportsService {
|
|||||||
operating_investments: operatingInvestments.toFixed(2),
|
operating_investments: operatingInvestments.toFixed(2),
|
||||||
reserve_investments: reserveInvestments.toFixed(2),
|
reserve_investments: reserveInvestments.toFixed(2),
|
||||||
est_monthly_interest: estMonthlyInterest.toFixed(2),
|
est_monthly_interest: estMonthlyInterest.toFixed(2),
|
||||||
interest_earned_ytd: ytdInterest.toFixed(2),
|
interest_earned_ytd: interestEarned[0]?.total || '0.00',
|
||||||
interest_last_year: parseFloat(interestLastYear[0]?.total || '0').toFixed(2),
|
|
||||||
interest_projected: projectedInterest.toFixed(2),
|
|
||||||
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,34 +9,5 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<script>
|
|
||||||
(function(d,t) {
|
|
||||||
var BASE_URL="https://chat.hoaledgeriq.com";
|
|
||||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
|
||||||
g.src=BASE_URL+"/packs/js/sdk.js";
|
|
||||||
g.async=true;
|
|
||||||
s.parentNode.insertBefore(g,s);
|
|
||||||
g.onload=function(){
|
|
||||||
window.chatwootSDK.run({
|
|
||||||
websiteToken:'K6VXvTtKXvaCMvre4yK85SPb',
|
|
||||||
baseUrl:BASE_URL
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})(document,"script");
|
|
||||||
window.addEventListener('chatwoot:ready', function() {
|
|
||||||
try {
|
|
||||||
var raw = localStorage.getItem('ledgeriq-auth');
|
|
||||||
if (!raw) return;
|
|
||||||
var auth = JSON.parse(raw);
|
|
||||||
var user = auth && auth.state && auth.state.user;
|
|
||||||
if (user && window.$chatwoot) {
|
|
||||||
window.$chatwoot.setUser(user.id, {
|
|
||||||
name: (user.firstName || '') + ' ' + (user.lastName || ''),
|
|
||||||
email: user.email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.03.10",
|
"version": "2026.3.7-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.03.10",
|
"version": "2026.3.7-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.11",
|
"version": "2026.3.7-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ export function AccountsPage() {
|
|||||||
{investments.filter(i => i.is_active).length > 0 && (
|
{investments.filter(i => i.is_active).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -605,7 +605,7 @@ export function AccountsPage() {
|
|||||||
{operatingInvestments.length > 0 && (
|
{operatingInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -623,7 +623,7 @@ export function AccountsPage() {
|
|||||||
{reserveInvestments.length > 0 && (
|
{reserveInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1087,9 +1087,11 @@ function AccountTable({
|
|||||||
function InvestmentMiniTable({
|
function InvestmentMiniTable({
|
||||||
investments,
|
investments,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
isReadOnly = false,
|
||||||
}: {
|
}: {
|
||||||
investments: Investment[];
|
investments: Investment[];
|
||||||
onEdit: (inv: Investment) => void;
|
onEdit: (inv: Investment) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
||||||
const totalValue = investments.reduce(
|
const totalValue = investments.reduce(
|
||||||
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Th ta="right">Maturity Value</Table.Th>
|
<Table.Th ta="right">Maturity Value</Table.Th>
|
||||||
<Table.Th>Maturity Date</Table.Th>
|
<Table.Th>Maturity Date</Table.Th>
|
||||||
<Table.Th ta="right">Days Remaining</Table.Th>
|
<Table.Th ta="right">Days Remaining</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
{!isReadOnly && <Table.Th></Table.Th>}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -1182,6 +1184,7 @@ function InvestmentMiniTable({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Tooltip label="Edit investment">
|
<Tooltip label="Edit investment">
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
||||||
@@ -1189,6 +1192,7 @@ function InvestmentMiniTable({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
)}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export function SelectOrgPage() {
|
|||||||
<Text fw={500}>{org.name}</Text>
|
<Text fw={500}>{org.name}</Text>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Badge size="sm" variant="light">{org.role}</Badge>
|
<Badge size="sm" variant="light">{org.role}</Badge>
|
||||||
|
{org.schemaName && (
|
||||||
|
<Badge size="xs" variant="dot" color="gray">
|
||||||
|
{org.schemaName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import {
|
|||||||
Select, Loader, Center, Badge, Card, Alert,
|
Select, Loader, Center, Badge, Card, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, IconX } 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';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -96,20 +95,9 @@ function parseCSV(text: string): Record<string, string>[] {
|
|||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
|
||||||
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
|
||||||
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
|
||||||
|
|
||||||
// Budget exists when there is data loaded for the selected year
|
|
||||||
const hasBudget = budgetData.length > 0;
|
|
||||||
// Cells are editable only when editing an existing budget or creating a new one (no data yet)
|
|
||||||
const cellsEditable = !isReadOnly && (isEditing || !hasBudget);
|
|
||||||
|
|
||||||
const { isLoading } = useQuery<BudgetLine[]>({
|
const { isLoading } = useQuery<BudgetLine[]>({
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
@@ -118,27 +106,25 @@ export function BudgetsPage() {
|
|||||||
// Hydrate each line: ensure numbers and compute annual_total
|
// Hydrate each line: ensure numbers and compute annual_total
|
||||||
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
||||||
setBudgetData(hydrated);
|
setBudgetData(hydrated);
|
||||||
setIsEditing(false); // Reset to view mode when year changes or data reloads
|
|
||||||
return hydrated;
|
return hydrated;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const payload = budgetData
|
const lines = budgetData
|
||||||
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
||||||
.map((b) => ({
|
.map((b) => ({
|
||||||
accountId: b.account_id,
|
account_id: b.account_id,
|
||||||
fundType: b.fund_type,
|
fund_type: b.fund_type,
|
||||||
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
||||||
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
||||||
sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
|
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
|
||||||
}));
|
}));
|
||||||
return api.put(`/budgets/${year}`, payload);
|
return api.put(`/budgets/${year}`, { lines });
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
setIsEditing(false);
|
|
||||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -235,12 +221,6 @@ export function BudgetsPage() {
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
// Re-fetch to discard unsaved changes
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCell = (idx: number, month: string, value: number) => {
|
const updateCell = (idx: number, month: string, value: number) => {
|
||||||
const updated = [...budgetData];
|
const updated = [...budgetData];
|
||||||
(updated[idx] as any)[month] = value || 0;
|
(updated[idx] as any)[month] = value || 0;
|
||||||
@@ -295,35 +275,9 @@ export function BudgetsPage() {
|
|||||||
accept=".csv,.txt"
|
accept=".csv,.txt"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
{hasBudget && !isEditing ? (
|
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
leftSection={<IconPencil size={16} />}
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
>
|
|
||||||
Edit Budget
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isEditing && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
color="gray"
|
|
||||||
leftSection={<IconX size={16} />}
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
|
||||||
onClick={() => saveMutation.mutate()}
|
|
||||||
loading={saveMutation.isPending}
|
|
||||||
>
|
|
||||||
Save Budget
|
Save Budget
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>)}
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -363,8 +317,8 @@ export function BudgetsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
||||||
{monthLabels.map((m) => (
|
{monthLabels.map((m) => (
|
||||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||||
))}
|
))}
|
||||||
@@ -383,7 +337,7 @@ export function BudgetsPage() {
|
|||||||
const lines = budgetData.filter((b) => b.account_type === type);
|
const lines = budgetData.filter((b) => b.account_type === type);
|
||||||
if (lines.length === 0) return null;
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
|
const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8';
|
||||||
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -414,9 +368,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 0,
|
left: 0,
|
||||||
background: stickyBg,
|
background: 'white',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: `1px solid ${stickyBorder}`,
|
borderRight: '1px solid #e9ecef',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
@@ -425,9 +379,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 120,
|
left: 120,
|
||||||
background: stickyBg,
|
background: 'white',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: `1px solid ${stickyBorder}`,
|
borderRight: '1px solid #e9ecef',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -437,7 +391,6 @@ export function BudgetsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
{months.map((m) => (
|
{months.map((m) => (
|
||||||
<Table.Td key={m} p={2}>
|
<Table.Td key={m} p={2}>
|
||||||
{cellsEditable ? (
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={(line as any)[m] || 0}
|
value={(line as any)[m] || 0}
|
||||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||||
@@ -445,13 +398,9 @@ 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' } }}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<Text size="sm" ta="right" ff="monospace">
|
|
||||||
{fmt((line as any)[m] || 0)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
))}
|
))}
|
||||||
<Table.Td ta="right" fw={500} ff="monospace">
|
<Table.Td ta="right" fw={500} ff="monospace">
|
||||||
|
|||||||
@@ -72,9 +72,10 @@ interface KanbanCardProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
onEdit: (p: Project) => void;
|
onEdit: (p: Project) => void;
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
|
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: 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
|
// For projects in the Future bucket with a specific year, show the year
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
|||||||
padding="sm"
|
padding="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
draggable
|
draggable={!isReadOnly}
|
||||||
onDragStart={(e) => onDragStart(e, project)}
|
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
|
||||||
style={{ cursor: 'grab', userSelect: 'none' }}
|
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
|
||||||
mb="xs"
|
mb="xs"
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||||
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
|
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
|
{!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
|
||||||
<Text fw={600} size="sm" truncate>
|
<Text fw={600} size="sm" truncate>
|
||||||
{project.name}
|
{project.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{!isReadOnly && (
|
||||||
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
||||||
<IconEdit size={14} />
|
<IconEdit size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} mb={6}>
|
<Group gap={6} mb={6}>
|
||||||
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
|
|||||||
isDragOver: boolean;
|
isDragOver: boolean;
|
||||||
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
||||||
onDragLeave: () => void;
|
onDragLeave: () => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({
|
function KanbanColumn({
|
||||||
year, projects, onEdit, onDragStart, onDrop,
|
year, projects, onEdit, onDragStart, onDrop,
|
||||||
isDragOver, onDragOverHandler, onDragLeave,
|
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
|
||||||
}: 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 isFuture = year === FUTURE_YEAR;
|
||||||
@@ -178,9 +182,9 @@ function KanbanColumn({
|
|||||||
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',
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => onDragOverHandler(e, year)}
|
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={!isReadOnly ? onDragLeave : undefined}
|
||||||
onDrop={(e) => onDrop(e, year)}
|
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Title order={5}>{yearLabel(year)}</Title>
|
<Title order={5}>{yearLabel(year)}</Title>
|
||||||
@@ -199,7 +203,7 @@ function KanbanColumn({
|
|||||||
<Box style={{ flex: 1, minHeight: 60 }}>
|
<Box style={{ flex: 1, minHeight: 60 }}>
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<Text size="xs" c="dimmed" ta="center" py="lg">
|
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||||
Drop projects here
|
{isReadOnly ? 'No projects' : 'Drop projects here'}
|
||||||
</Text>
|
</Text>
|
||||||
) : useWideLayout ? (
|
) : useWideLayout ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -208,12 +212,12 @@ function KanbanColumn({
|
|||||||
gap: 'var(--mantine-spacing-xs)',
|
gap: 'var(--mantine-spacing-xs)',
|
||||||
}}>
|
}}>
|
||||||
{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} isReadOnly={isReadOnly} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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} isReadOnly={isReadOnly} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
|
|||||||
isDragOver={dragOverYear === year}
|
isDragOver={dragOverYear === year}
|
||||||
onDragOverHandler={handleDragOver}
|
onDragOverHandler={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
||||||
@@ -80,7 +79,6 @@ export function CashFlowForecastPage() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentYear = now.getFullYear();
|
const currentYear = now.getFullYear();
|
||||||
const currentMonth = now.getMonth() + 1;
|
const currentMonth = now.getMonth() + 1;
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
|
|
||||||
// Filter: All, Operating, Reserve
|
// Filter: All, Operating, Reserve
|
||||||
const [fundFilter, setFundFilter] = useState<string>('all');
|
const [fundFilter, setFundFilter] = useState<string>('all');
|
||||||
@@ -420,10 +418,10 @@ export function CashFlowForecastPage() {
|
|||||||
<tr
|
<tr
|
||||||
key={d.month}
|
key={d.month}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`,
|
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||||
backgroundColor: d.is_forecast
|
backgroundColor: d.is_forecast
|
||||||
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)')
|
? 'var(--mantine-color-orange-0)'
|
||||||
: i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : 'var(--mantine-color-gray-0)'),
|
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface HealthScore {
|
interface HealthScore {
|
||||||
@@ -306,13 +306,12 @@ interface DashboardData {
|
|||||||
reserve_investments: string;
|
reserve_investments: string;
|
||||||
est_monthly_interest: string;
|
est_monthly_interest: string;
|
||||||
interest_earned_ytd: string;
|
interest_earned_ytd: string;
|
||||||
interest_last_year: string;
|
|
||||||
interest_projected: string;
|
|
||||||
planned_capital_spend: string;
|
planned_capital_spend: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Track whether a refresh is in progress (per score type) for async polling
|
// Track whether a refresh is in progress (per score type) for async polling
|
||||||
@@ -426,7 +425,7 @@ export function DashboardPage() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={operatingRefreshing}
|
isRefreshing={operatingRefreshing}
|
||||||
onRefresh={handleRefreshOperating}
|
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
|
||||||
lastFailed={!!healthScores?.operating_last_failed}
|
lastFailed={!!healthScores?.operating_last_failed}
|
||||||
/>
|
/>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
@@ -438,7 +437,7 @@ export function DashboardPage() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={reserveRefreshing}
|
isRefreshing={reserveRefreshing}
|
||||||
onRefresh={handleRefreshReserve}
|
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
|
||||||
lastFailed={!!healthScores?.reserve_last_failed}
|
lastFailed={!!healthScores?.reserve_last_failed}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -543,30 +542,7 @@ export function DashboardPage() {
|
|||||||
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Interest Earned YoY</Text>
|
<Text size="sm" c="dimmed">Planned Capital Spend</Text>
|
||||||
<Group gap={6}>
|
|
||||||
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_projected || '0')}</Text>
|
|
||||||
<Text size="xs" c="dimmed">proj</Text>
|
|
||||||
<Text size="xs" c="dimmed">vs</Text>
|
|
||||||
<Text size="sm" fw={500} c="gray">{fmt(data?.interest_last_year || '0')}</Text>
|
|
||||||
<Text size="xs" c="dimmed">prev</Text>
|
|
||||||
{(() => {
|
|
||||||
const proj = parseFloat(data?.interest_projected || '0');
|
|
||||||
const prev = parseFloat(data?.interest_last_year || '0');
|
|
||||||
const diff = proj - prev;
|
|
||||||
if (prev === 0 && proj === 0) return null;
|
|
||||||
return (
|
|
||||||
<Badge size="xs" color={diff >= 0 ? 'green' : 'red'} variant="light">
|
|
||||||
{diff >= 0 ? '+' : ''}{prev > 0 ? ((diff / prev) * 100).toFixed(0) : '—'}%
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Divider my={4} />
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Capital Projects</Text>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Planned Capital Spend {new Date().getFullYear()}</Text>
|
|
||||||
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider my={4} />
|
<Divider my={4} />
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -347,6 +348,7 @@ function RecommendationsDisplay({
|
|||||||
export function InvestmentPlanningPage() {
|
export function InvestmentPlanningPage() {
|
||||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||||
const [isTriggering, setIsTriggering] = useState(false);
|
const [isTriggering, setIsTriggering] = useState(false);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// Load financial snapshot on mount
|
// Load financial snapshot on mount
|
||||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||||
@@ -696,6 +698,7 @@ export function InvestmentPlanningPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
{!isReadOnly && (
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconSparkles size={16} />}
|
leftSection={<IconSparkles size={16} />}
|
||||||
onClick={handleTriggerAI}
|
onClick={handleTriggerAI}
|
||||||
@@ -705,6 +708,7 @@ export function InvestmentPlanningPage() {
|
|||||||
>
|
>
|
||||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Processing State */}
|
{/* Processing State */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconSend, IconInfoCircle, IconCheck, IconX } 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 Invoice {
|
interface Invoice {
|
||||||
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
||||||
@@ -64,6 +65,7 @@ export function InvoicesPage() {
|
|||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||||
queryKey: ['invoices'],
|
queryKey: ['invoices'],
|
||||||
@@ -124,10 +126,12 @@ export function InvoicesPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Invoices</Title>
|
<Title order={2}>Invoices</Title>
|
||||||
|
{!isReadOnly && (
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Card, SimpleGrid, Badge, Alert, Modal,
|
Select, Loader, Center, Card, SimpleGrid, Badge, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconEdit,
|
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
interface ActualLine {
|
interface ActualLine {
|
||||||
@@ -66,15 +64,8 @@ export function MonthlyActualsPage() {
|
|||||||
const [month, setMonth] = useState(defaults.month);
|
const [month, setMonth] = useState(defaults.month);
|
||||||
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
||||||
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
|
||||||
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
|
||||||
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -87,15 +78,10 @@ export function MonthlyActualsPage() {
|
|||||||
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
|
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
|
||||||
setEditedAmounts({});
|
setEditedAmounts({});
|
||||||
setSavedJEId(data.existing_journal_entry_id || null);
|
setSavedJEId(data.existing_journal_entry_id || null);
|
||||||
// Default to read mode if actuals already exist, edit mode if new
|
|
||||||
setIsEditing(!data.existing_journal_entry_id);
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Whether actuals have been previously saved (reconciled)
|
|
||||||
const hasExistingActuals = !!savedJEId;
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const lines = (grid?.lines || [])
|
const lines = (grid?.lines || [])
|
||||||
@@ -115,8 +101,6 @@ export function MonthlyActualsPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
|
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
|
||||||
setSavedJEId(data.journal_entry_id);
|
setSavedJEId(data.journal_entry_id);
|
||||||
setIsEditing(false);
|
|
||||||
setEditedAmounts({});
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: data.message || 'Actuals saved and reconciled',
|
message: data.message || 'Actuals saved and reconciled',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
@@ -141,19 +125,6 @@ export function MonthlyActualsPage() {
|
|||||||
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
|
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditClick = () => {
|
|
||||||
if (hasExistingActuals) {
|
|
||||||
openConfirm();
|
|
||||||
} else {
|
|
||||||
setIsEditing(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmEdit = () => {
|
|
||||||
closeConfirm();
|
|
||||||
setIsEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines = grid?.lines || [];
|
const lines = grid?.lines || [];
|
||||||
const incomeLines = lines.filter((l) => l.account_type === 'income');
|
const incomeLines = lines.filter((l) => l.account_type === 'income');
|
||||||
const expenseLines = lines.filter((l) => l.account_type === 'expense');
|
const expenseLines = lines.filter((l) => l.account_type === 'expense');
|
||||||
@@ -166,6 +137,7 @@ export function MonthlyActualsPage() {
|
|||||||
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
|
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
|
||||||
}, [lines, editedAmounts]);
|
}, [lines, editedAmounts]);
|
||||||
|
|
||||||
|
const hasChanges = Object.keys(editedAmounts).length > 0;
|
||||||
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
|
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
@@ -191,7 +163,7 @@ export function MonthlyActualsPage() {
|
|||||||
{title}
|
{title}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(actualTotal)}</Table.Td>
|
<Table.Td />
|
||||||
<Table.Td ta="right" fw={700} ff="monospace"
|
<Table.Td ta="right" fw={700} ff="monospace"
|
||||||
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
|
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
|
||||||
>
|
>
|
||||||
@@ -206,16 +178,16 @@ export function MonthlyActualsPage() {
|
|||||||
<Table.Tr key={line.account_id}>
|
<Table.Tr key={line.account_id}>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
|
position: 'sticky', left: 0, background: 'white', zIndex: 1,
|
||||||
borderRight: `1px solid ${stickyBorder}`,
|
borderRight: '1px solid #e9ecef',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
|
position: 'sticky', left: 120, background: 'white', zIndex: 1,
|
||||||
borderRight: `1px solid ${stickyBorder}`,
|
borderRight: '1px solid #e9ecef',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -226,8 +198,7 @@ export function MonthlyActualsPage() {
|
|||||||
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
|
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
|
||||||
{fmt(line.budget_amount)}
|
{fmt(line.budget_amount)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td p={isEditing ? 2 : undefined} style={{ minWidth: 130 }}>
|
<Table.Td p={2} style={{ minWidth: 130 }}>
|
||||||
{isEditing ? (
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
||||||
@@ -238,9 +209,6 @@ export function MonthlyActualsPage() {
|
|||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<Text size="sm" ff="monospace" ta="right">{fmt(amount)}</Text>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
ta="right" ff="monospace" style={{ minWidth: 110 }}
|
ta="right" ff="monospace" style={{ minWidth: 110 }}
|
||||||
@@ -264,24 +232,14 @@ export function MonthlyActualsPage() {
|
|||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||||
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
||||||
{!isReadOnly && !isEditing && (
|
{!isReadOnly && (
|
||||||
<Button
|
|
||||||
leftSection={<IconEdit size={16} />}
|
|
||||||
variant="light"
|
|
||||||
onClick={handleEditClick}
|
|
||||||
disabled={lines.length === 0}
|
|
||||||
>
|
|
||||||
Edit Actuals
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isReadOnly && isEditing && (
|
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
loading={saveMutation.isPending}
|
loading={saveMutation.isPending}
|
||||||
disabled={lines.length === 0}
|
disabled={lines.length === 0}
|
||||||
>
|
>
|
||||||
Save Actuals
|
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -318,7 +276,7 @@ export function MonthlyActualsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasExistingActuals && !isEditing && (
|
{savedJEId && (
|
||||||
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
|
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
@@ -334,10 +292,10 @@ export function MonthlyActualsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
|
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>
|
||||||
Acct #
|
Acct #
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>
|
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>
|
||||||
Account Name
|
Account Name
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
||||||
@@ -346,8 +304,8 @@ export function MonthlyActualsPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
|
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
|
||||||
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
|
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,26 +317,6 @@ export function MonthlyActualsPage() {
|
|||||||
<AttachmentPanel journalEntryId={savedJEId} />
|
<AttachmentPanel journalEntryId={savedJEId} />
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirmation modal for editing reconciled actuals */}
|
|
||||||
<Modal opened={confirmOpened} onClose={closeConfirm} title="Edit Reconciled Actuals" centered>
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm">
|
|
||||||
Actuals for <Text span fw={700}>{monthLabel} {year}</Text> have already been
|
|
||||||
reconciled. Editing will void the existing journal entry and create a new one
|
|
||||||
when you save.
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Press Edit to proceed, or Cancel to keep the current values.
|
|
||||||
</Text>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={closeConfirm}>Cancel</Button>
|
|
||||||
<Button color="orange" leftSection={<IconEdit size={16} />} onClick={handleConfirmEdit}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
|
|
||||||
interface BudgetVsActualLine {
|
interface BudgetVsActualLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -47,9 +46,6 @@ const monthFilterOptions = [
|
|||||||
export function BudgetVsActualPage() {
|
export function BudgetVsActualPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [month, setMonth] = useState('');
|
const [month, setMonth] = useState('');
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
|
||||||
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -96,7 +92,7 @@ export function BudgetVsActualPage() {
|
|||||||
|
|
||||||
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
||||||
<>
|
<>
|
||||||
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
|
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
|
||||||
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
{sectionLines.map((line) => {
|
{sectionLines.map((line) => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
|
|
||||||
interface BudgetVsActualItem {
|
interface BudgetVsActualItem {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -49,9 +48,6 @@ export function QuarterlyReportPage() {
|
|||||||
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
||||||
const defaultQuarter = currentQuarter;
|
const defaultQuarter = currentQuarter;
|
||||||
const defaultYear = now.getFullYear();
|
const defaultYear = now.getFullYear();
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
|
||||||
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
|
||||||
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
|
||||||
|
|
||||||
const [year, setYear] = useState(String(defaultYear));
|
const [year, setYear] = useState(String(defaultYear));
|
||||||
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
||||||
@@ -211,7 +207,7 @@ export function QuarterlyReportPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{incomeItems.length > 0 && (
|
{incomeItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: incomeBg }}>
|
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||||
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -219,7 +215,7 @@ export function QuarterlyReportPage() {
|
|||||||
<BVARow key={item.account_id} item={item} isExpense={false} />
|
<BVARow key={item.account_id} item={item} isExpense={false} />
|
||||||
))}
|
))}
|
||||||
{incomeItems.length > 0 && (
|
{incomeItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: incomeBg }}>
|
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||||
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||||
@@ -230,7 +226,7 @@ export function QuarterlyReportPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
{expenseItems.length > 0 && (
|
{expenseItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: expenseBg }}>
|
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||||
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -238,7 +234,7 @@ export function QuarterlyReportPage() {
|
|||||||
<BVARow key={item.account_id} item={item} isExpense={true} />
|
<BVARow key={item.account_id} item={item} isExpense={true} />
|
||||||
))}
|
))}
|
||||||
{expenseItems.length > 0 && (
|
{expenseItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: expenseBg }}>
|
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||||
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function SettingsPage() {
|
|||||||
<Text size="sm" c="dimmed">Your Role</Text>
|
<Text size="sm" c="dimmed">Your Role</Text>
|
||||||
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Schema</Text>
|
||||||
|
<Text size="sm" ff="monospace" c="dimmed">{currentOrg?.schemaName || 'N/A'}</Text>
|
||||||
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -113,7 +117,7 @@ export function SettingsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Version</Text>
|
<Text size="sm" c="dimmed">Version</Text>
|
||||||
<Badge variant="light">2026.03.10</Badge>
|
<Badge variant="light">2026.3.7 (Beta)</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">API</Text>
|
<Text size="sm" c="dimmed">API</Text>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface Organization {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
schemaName?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user