1 Commits

Author SHA1 Message Date
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:59:20 -04:00
27 changed files with 172 additions and 330 deletions

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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

View File

@@ -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`);

View File

@@ -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;

View File

@@ -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,
})), })),

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -220,14 +220,6 @@ 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 components (warn but don't block)
const components = await qr.query(
`SELECT COUNT(*) as cnt FROM reserve_components`,
);
if (parseInt(components[0].cnt) === 0) {
missing.push('No reserve components found. Add reserve components (roof, parking, pool, etc.) with replacement costs for an accurate funded-ratio calculation.');
}
// Should have capital projects (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`, `SELECT COUNT(*) as cnt FROM projects WHERE is_active = true`,
@@ -566,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
@@ -606,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;
@@ -633,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 ──
@@ -771,7 +749,6 @@ export class HealthScoresService {
accounts, accounts,
investments, investments,
reserveComponents, reserveComponents,
reserveProjects,
projects, projects,
budgets, budgets,
assessments, assessments,
@@ -982,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
@@ -1006,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`;
@@ -1022,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)}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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}
/> />
); );
})} })}

View File

@@ -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>

View File

@@ -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 {
@@ -311,6 +311,7 @@ interface DashboardData {
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
@@ -424,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
@@ -436,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>

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -10,7 +10,6 @@ import {
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 {
@@ -67,11 +66,6 @@ export function MonthlyActualsPage() {
const [savedJEId, setSavedJEId] = useState<string | null>(null); const [savedJEId, setSavedJEId] = useState<string | null>(null);
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;
@@ -184,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">
@@ -298,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>
@@ -310,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>

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>;
} }