9 Commits

Author SHA1 Message Date
61a4f27af4 security: address assessment findings and bump to v2026.3.11
- C1: Disable Swagger UI in production (env gate)
- M1+M2: Add Helmet.js for security headers (CSP, X-Frame-Options,
  X-Content-Type-Options, Referrer-Policy) and remove X-Powered-By
- H2: Add @nestjs/throttler rate limiting (5 req/min on login/register)
- M4: Remove orgSchema from JWT payload and client-side storage;
  tenant middleware now resolves schema from orgId via cached DB lookup
- L1: Fix Chatwoot user identification (read from auth store on ready)
- Remove schemaName from frontend Organization type and UI displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:22:58 -04:00
508a86d16c fix: resolve Vite parse5 HTML error in index.html
Fix malformed Chatwoot chat widget script that caused Vite's parse5
HTML parser to throw "eof-in-element-that-can-contain-only-text".
Also fix broken URL (https// -> https://) for the chat widget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:32:35 -04:00
16e1ada261 fix: budget save error and add read-only view mode (v2026.03.10)
Fix budget save 500 error caused by three data mismatches between
frontend and backend: wrapped payload ({lines:[...]}) vs expected
raw array, snake_case vs camelCase field names (account_id vs
accountId), and dec_amt vs dec for December values.

Add read-only budget view as default for existing budgets with an
"Edit Budget" button to enter edit mode, and Cancel to discard
changes - reducing accidental edits.

Bump version to 2026.03.10 across all packages and settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:28:09 -04:00
6bd080f8c4 Merge branch 'claude/practical-rhodes' 2026-03-10 14:22:14 -04:00
be3a5191c5 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:22:08 -04:00
b0282b7f8b fix: show P&L debit/credit totals on journal entries list
The previous aggregation used simple SUM(debit)/SUM(credit) which
always produced equal values for balanced entries. This was misleading
for entries with income/expense lines (e.g., monthly actuals).

Now, when an entry has income/expense lines, the totals reflect only
P&L account activity (expenses as debits, income as credits), excluding
the cash offset. For balance-sheet-only entries (opening balances,
adjustments), the full entry totals are shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:41:26 -04:00
ac72905ecb fix: add total_debit/total_credit aggregations to journal entries list
The findAll query was missing SUM aggregations, so the frontend received
no total_debit/total_credit fields and fell back to displaying $0.00.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:17:08 -04:00
7d4df25d16 Update frontend/index.html 2026-03-09 14:17:04 -04:00
3bf6b8c6c9 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:49:23 -04:00
18 changed files with 206 additions and 84 deletions

View File

@@ -1,12 +1,12 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.7-beta", "version": "2026.03.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.7-beta", "version": "2026.03.10",
"dependencies": { "dependencies": {
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
@@ -16,10 +16,12 @@
"@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",
@@ -1791,6 +1793,17 @@
} }
} }
}, },
"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",
@@ -5277,6 +5290,15 @@
"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.7-beta", "version": "2026.3.11",
"description": "HOA LedgerIQ - Backend API", "description": "HOA LedgerIQ - Backend API",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -25,11 +25,14 @@
"@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",
@@ -37,7 +40,6 @@
"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,6 +2,7 @@ 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';
@@ -52,6 +53,10 @@ 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 status to avoid DB hit per request // In-memory cache for org info to avoid DB hit per request
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>(); private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
private static readonly CACHE_TTL = 60_000; // 60 seconds private static readonly CACHE_TTL = 60_000; // 60 seconds
constructor( constructor(
@@ -30,23 +30,25 @@ 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?.orgSchema) { if (decoded?.orgId) {
// Check if the org is still active (catches post-JWT suspension) // Look up org info (status + schema) from orgId with caching
if (decoded.orgId) { const orgInfo = await this.getOrgInfo(decoded.orgId);
const status = await this.getOrgStatus(decoded.orgId); if (orgInfo) {
if (status && ['suspended', 'archived'].includes(status)) { if (['suspended', 'archived'].includes(orgInfo.status)) {
res.status(403).json({ res.status(403).json({
statusCode: 403, statusCode: 403,
message: `This organization has been ${status}. Please contact your administrator.`, message: `This organization has been ${orgInfo.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
@@ -55,19 +57,23 @@ export class TenantMiddleware implements NestMiddleware {
next(); next();
} }
private async getOrgStatus(orgId: string): Promise<string | null> { private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
const cached = this.orgStatusCache.get(orgId); const cached = this.orgCache.get(orgId);
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) { if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
return cached.status; return { status: cached.status, schemaName: cached.schemaName };
} }
try { try {
const result = await this.dataSource.query( const result = await this.dataSource.query(
`SELECT status FROM shared.organizations WHERE id = $1`, `SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
[orgId], [orgId],
); );
if (result.length > 0) { if (result.length > 0) {
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() }); this.orgCache.set(orgId, {
return result[0].status; status: 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,6 +3,7 @@ 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
@@ -41,6 +42,24 @@ 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) => {
@@ -63,15 +82,17 @@ async function bootstrap() {
credentials: true, credentials: true,
}); });
// Swagger docs — available in all environments // Swagger docs — disabled in production to avoid exposing API surface
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.7') .setVersion('2026.3.11')
.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,6 +9,7 @@ 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';
@@ -23,12 +24,14 @@ 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,7 +118,6 @@ 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,
}; };
@@ -177,7 +176,6 @@ 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;
} }
@@ -195,7 +193,6 @@ 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,7 +18,6 @@ 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.user?.orgSchema; const schema = req.tenantSchema;
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.user?.orgSchema; const schema = req.tenantSchema;
// 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.user?.orgSchema; const schema = req.tenantSchema;
// 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.user?.orgSchema; const schema = req.tenantSchema;
// Fire-and-forget // Fire-and-forget
this.service.calculateScore(schema, 'reserve').catch((err) => { this.service.calculateScore(schema, 'reserve').catch((err) => {

View File

@@ -13,6 +13,16 @@ 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

@@ -153,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role; existing.role = data.role;
return this.userOrgRepository.save(existing); return this.userOrgRepository.save(existing);
} }
// Update password for existing user being added to a new org
if (data.password) {
const passwordHash = await bcrypt.hash(data.password, 12);
await dataSource.query(
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
[passwordHash, userId],
);
}
} else { } else {
// Create new user // Create new user
const passwordHash = await bcrypt.hash(data.password, 12); const passwordHash = await bcrypt.hash(data.password, 12);

View File

@@ -11,18 +11,32 @@
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script> <script>
(function(d,t) { (function(d,t) {
var BASE_URL="https//chat.hoaledger.com"; var BASE_URL="https://chat.hoaledgeriq.com";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0]; var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js"; g.src=BASE_URL+"/packs/js/sdk.js";
g.async = true; g.async=true;
s.parentNode.insertBefore(g,s); s.parentNode.insertBefore(g,s);
g.onload=function(){ g.onload=function(){
window.chatwootSDK.run({ window.chatwootSDK.run({
websiteToken: 'K6VXvTtKXvaCMvre4yK85SPb', websiteToken:'K6VXvTtKXvaCMvre4yK85SPb',
baseUrl: BASE_URL baseUrl:BASE_URL
}) })
} }
})(document,"script"); })(document,"script");
</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.3.7-beta", "version": "2026.03.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.7-beta", "version": "2026.03.10",
"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.7-beta", "version": "2026.3.11",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -120,11 +120,6 @@ 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,7 +4,7 @@ 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 } from '@tabler/icons-react'; import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, 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'; import { useIsReadOnly } from '../../stores/authStore';
@@ -96,6 +96,7 @@ 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();
@@ -105,6 +106,11 @@ export function BudgetsPage() {
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6'; const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8'; 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],
queryFn: async () => { queryFn: async () => {
@@ -112,25 +118,27 @@ 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 lines = budgetData const payload = 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) => ({
account_id: b.account_id, accountId: b.account_id,
fund_type: b.fund_type, fundType: 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_amt: b.dec_amt, sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
})); }));
return api.put(`/budgets/${year}`, { lines }); return api.put(`/budgets/${year}`, payload);
}, },
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) => {
@@ -227,6 +235,12 @@ 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;
@@ -281,9 +295,35 @@ export function BudgetsPage() {
accept=".csv,.txt" accept=".csv,.txt"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}> {hasBudget && !isEditing ? (
<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>
@@ -397,6 +437,7 @@ 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)}
@@ -404,9 +445,13 @@ 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

@@ -38,10 +38,6 @@ 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>
@@ -117,7 +113,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.3.7 (Beta)</Badge> <Badge variant="light">2026.03.10</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,7 +5,6 @@ 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>;
} }