15 Commits

Author SHA1 Message Date
f47fbfcf93 Merge branch 'claude/ecstatic-elgamal' 2026-03-11 15:42:24 -04:00
04771f370c fix: clarify reserve health score when no components are entered
- Add missing-data warning when reserve_components table is empty so
  users see "No reserve components found" on the dashboard
- Change AI prompt to show "N/A" instead of "0.0%" for funded ratio
  when no components exist, preventing misleading "0% funded" reports
- Instruct AI not to report 0% funded when data is simply missing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:42:15 -04:00
208c1dd7bc 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:32:51 -04:00
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
a047144922 Added userID and URL to Chatwoot Script 2026-03-10 14:49:50 -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
538828b91a Merge pull request 'fix: dark mode styling across 5 pages' (#4) from fix/dark-mode-styling into main 2026-03-09 14:04:50 -04:00
14160854b9 fix: resolve hardcoded light backgrounds breaking dark mode across 5 pages
Replace hardcoded light colors (#e6f9e6, #fde8e8, white, #e9ecef) with
theme-aware alternatives using usePreferencesStore. Affected pages:
- CashFlowForecastPage: forecast row and striped row backgrounds
- MonthlyActualsPage: sticky column backgrounds, borders, section headers
- BudgetsPage: sticky column backgrounds, borders, section headers
- BudgetVsActualPage: income/expense section header backgrounds
- QuarterlyReportPage: income/expense and total row backgrounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:02:46 -04:00
36d486d78c Add Chat Widget for support
added support chat widget to index.html
2026-03-09 13:31:17 -04:00
22 changed files with 254 additions and 95 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
const config = new DocumentBuilder() if (!isProduction) {
.setTitle('HOA LedgerIQ API') const config = new DocumentBuilder()
.setDescription('API for the HOA LedgerIQ') .setTitle('HOA LedgerIQ API')
.setVersion('2026.3.7') .setDescription('API for the HOA LedgerIQ')
.addBearerAuth() .setVersion('2026.3.11')
.build(); .addBearerAuth()
const document = SwaggerModule.createDocument(app, config); .build();
SwaggerModule.setup('api/docs', app, document); const document = SwaggerModule.createDocument(app, config);
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

@@ -220,6 +220,14 @@ 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`,
@@ -997,8 +1005,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.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)'}
Percent Funded: ${data.percentFunded.toFixed(1)}% 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.'}
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,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

@@ -9,5 +9,34 @@
<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.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,10 +4,11 @@ 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';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetLine { interface BudgetLine {
account_id: string; account_id: string;
@@ -95,9 +96,20 @@ 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],
@@ -106,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) => {
@@ -221,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;
@@ -275,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 ? (
Save Budget <Button
</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
</Button>
</>
)}
</>)} </>)}
</Group> </Group>
</Group> </Group>
@@ -317,8 +363,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: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th> <Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th> <Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, 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>
))} ))}
@@ -337,7 +383,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' ? '#e6f9e6' : '#fde8e8'; const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
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 [
@@ -368,9 +414,9 @@ export function BudgetsPage() {
style={{ style={{
position: 'sticky', position: 'sticky',
left: 0, left: 0,
background: 'white', background: stickyBg,
zIndex: 1, zIndex: 1,
borderRight: '1px solid #e9ecef', borderRight: `1px solid ${stickyBorder}`,
}} }}
> >
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text> <Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
@@ -379,9 +425,9 @@ export function BudgetsPage() {
style={{ style={{
position: 'sticky', position: 'sticky',
left: 120, left: 120,
background: 'white', background: stickyBg,
zIndex: 1, zIndex: 1,
borderRight: '1px solid #e9ecef', borderRight: `1px solid ${stickyBorder}`,
}} }}
> >
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
@@ -391,16 +437,21 @@ 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}>
<NumberInput {cellsEditable ? (
value={(line as any)[m] || 0} <NumberInput
onChange={(v) => updateCell(idx, m, Number(v) || 0)} value={(line as any)[m] || 0}
size="xs" onChange={(v) => updateCell(idx, m, Number(v) || 0)}
hideControls size="xs"
decimalScale={2} hideControls
min={0} decimalScale={2}
disabled={isReadOnly} min={0}
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

@@ -8,6 +8,7 @@ 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,
@@ -79,6 +80,7 @@ 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');
@@ -418,10 +420,10 @@ export function CashFlowForecastPage() {
<tr <tr
key={d.month} key={d.month}
style={{ style={{
borderBottom: '1px solid var(--mantine-color-gray-2)', borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`,
backgroundColor: d.is_forecast backgroundColor: d.is_forecast
? 'var(--mantine-color-orange-0)' ? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)')
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)', : i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : '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

@@ -10,6 +10,7 @@ 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 {
@@ -66,6 +67,11 @@ 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;
@@ -178,16 +184,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: 'white', zIndex: 1, position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
borderRight: '1px solid #e9ecef', borderRight: `1px solid ${stickyBorder}`,
}} }}
> >
<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: 'white', zIndex: 1, position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
borderRight: '1px solid #e9ecef', borderRight: `1px solid ${stickyBorder}`,
}} }}
> >
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
@@ -292,10 +298,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: 'white', zIndex: 2, minWidth: 120 }}> <Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
Acct # Acct #
</Table.Th> </Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}> <Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, 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>
@@ -304,8 +310,8 @@ export function MonthlyActualsPage() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)} {renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)} {renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</div> </div>

View File

@@ -5,6 +5,7 @@ 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;
@@ -46,6 +47,9 @@ 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;
@@ -92,7 +96,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 ? '#fde8e8' : '#e6f9e6' }}> <Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
<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,6 +8,7 @@ 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;
@@ -48,6 +49,9 @@ 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));
@@ -207,7 +211,7 @@ export function QuarterlyReportPage() {
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{incomeItems.length > 0 && ( {incomeItems.length > 0 && (
<Table.Tr style={{ background: '#e6f9e6' }}> <Table.Tr style={{ background: incomeBg }}>
<Table.Td colSpan={8} fw={700}>Income</Table.Td> <Table.Td colSpan={8} fw={700}>Income</Table.Td>
</Table.Tr> </Table.Tr>
)} )}
@@ -215,7 +219,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: '#e6f9e6' }}> <Table.Tr style={{ background: incomeBg }}>
<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>
@@ -226,7 +230,7 @@ export function QuarterlyReportPage() {
</Table.Tr> </Table.Tr>
)} )}
{expenseItems.length > 0 && ( {expenseItems.length > 0 && (
<Table.Tr style={{ background: '#fde8e8' }}> <Table.Tr style={{ background: expenseBg }}>
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td> <Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
</Table.Tr> </Table.Tr>
)} )}
@@ -234,7 +238,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: '#fde8e8' }}> <Table.Tr style={{ background: expenseBg }}>
<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,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>;
} }