22 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
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
4759374883 feat: add dark mode with persistent user preference
Add dark mode support using Mantine's built-in color scheme system,
persisted via a new Zustand preferences store. Includes a quick toggle
in the app header and an enabled switch in User Preferences. Also
removes the "AI Health Scores" title from the dashboard to reclaim
vertical space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:36:11 -04:00
cb6e34d5ce feat: add password reset utility script
Usage: ./scripts/reset-password.sh <email> <new-password>
Generates bcrypt hash via bcryptjs in the backend container,
updates the database, and verifies the hash matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:19:22 -05:00
2b72951e66 chore: bump version to 2026.3.7 (Beta)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
69dad7cc74 fix: resolve 5 invoice/payment issues from user feedback
- Replace misleading 'sent' status with 'pending' (no email capability)
- Show assessment group name instead of raw 'regular_assessment' type
- Add owner last name to invoice table
- Fix payment creation Internal Server Error (PostgreSQL $2 type cast)
- Add edit/delete capability for payment records with invoice recalc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
efa5aca35f feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly,
annual) with configurable due months and due day. Invoice generation
respects each group's schedule - only generating invoices when the
selected month is a billing month for that group. Adds a generation
preview showing which groups will be billed, period tracking on
invoices, and billing period context in the payments UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
c429dcc033 Merge pull request 'fix: improve AI health score accuracy and consistency' (#1) from ai-improvements into main
Reviewed-on: #1
2026-03-06 14:44:39 -05:00
41 changed files with 1325 additions and 203 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta",
"version": "2026.03.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta",
"version": "2026.03.10",
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
@@ -16,10 +16,12 @@
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.1.0",
"ioredis": "^5.4.2",
"newrelic": "latest",
"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": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
@@ -5277,6 +5290,15 @@
"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": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta",
"version": "2026.3.11",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {
@@ -25,11 +25,14 @@
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.1.0",
"ioredis": "^5.4.2",
"newrelic": "latest",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
@@ -37,7 +40,6 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"newrelic": "latest",
"uuid": "^9.0.1"
},
"devDependencies": {

View File

@@ -2,6 +2,7 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
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,
AuthModule,
OrganizationsModule,

View File

@@ -112,6 +112,8 @@ export class TenantSchemaService {
special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}',
due_day INTEGER DEFAULT 1,
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -155,8 +157,11 @@ export class TenantSchemaService {
amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
'draft', 'pending', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
)),
period_start DATE,
period_end DATE,
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,

View File

@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
@Injectable()
export class TenantMiddleware implements NestMiddleware {
// In-memory cache for org status to avoid DB hit per request
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
// In-memory cache for org info to avoid DB hit per request
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
private static readonly CACHE_TTL = 60_000; // 60 seconds
constructor(
@@ -30,23 +30,25 @@ export class TenantMiddleware implements NestMiddleware {
const token = authHeader.substring(7);
const secret = this.configService.get<string>('JWT_SECRET');
const decoded = jwt.verify(token, secret!) as any;
if (decoded?.orgSchema) {
// Check if the org is still active (catches post-JWT suspension)
if (decoded.orgId) {
const status = await this.getOrgStatus(decoded.orgId);
if (status && ['suspended', 'archived'].includes(status)) {
if (decoded?.orgId) {
// Look up org info (status + schema) from orgId with caching
const orgInfo = await this.getOrgInfo(decoded.orgId);
if (orgInfo) {
if (['suspended', 'archived'].includes(orgInfo.status)) {
res.status(403).json({
statusCode: 403,
message: `This organization has been ${status}. Please contact your administrator.`,
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
});
return;
}
req.tenantSchema = orgInfo.schemaName;
}
req.tenantSchema = decoded.orgSchema;
req.orgId = decoded.orgId;
req.userId = decoded.sub;
req.userRole = decoded.role;
} else if (decoded?.sub) {
// Superadmin or user without org — still set userId
req.userId = decoded.sub;
}
} catch {
// Token invalid or expired - let Passport handle the auth error
@@ -55,19 +57,23 @@ export class TenantMiddleware implements NestMiddleware {
next();
}
private async getOrgStatus(orgId: string): Promise<string | null> {
const cached = this.orgStatusCache.get(orgId);
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
const cached = this.orgCache.get(orgId);
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
return cached.status;
return { status: cached.status, schemaName: cached.schemaName };
}
try {
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],
);
if (result.length > 0) {
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
return result[0].status;
this.orgCache.set(orgId, {
status: result[0].status,
schemaName: result[0].schemaName,
cachedAt: Date.now(),
});
return { status: result[0].status, schemaName: result[0].schemaName };
}
} catch {
// 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 { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
@@ -41,6 +42,24 @@ async function bootstrap() {
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)
if (!isProduction) {
app.use((req: any, _res: any, next: any) => {
@@ -63,15 +82,17 @@ async function bootstrap() {
credentials: true,
});
// Swagger docs — available in all environments
const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.2')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
// Swagger docs — disabled in production to avoid exposing API surface
if (!isProduction) {
const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.11')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
await app.listen(3000);
console.log(`Backend worker ${process.pid} listening on port 3000`);

View File

@@ -1,6 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
const DEFAULT_DUE_MONTHS: Record<string, number[]> = {
monthly: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
quarterly: [1, 4, 7, 10],
annual: [1],
};
@Injectable()
export class AssessmentGroupsService {
constructor(private tenant: TenantService) {}
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
return rows.length ? rows[0] : null;
}
private validateDueMonths(frequency: string, dueMonths: number[]) {
if (!dueMonths || !dueMonths.length) {
throw new BadRequestException('Due months are required');
}
// Validate all values are 1-12
if (dueMonths.some((m) => m < 1 || m > 12 || !Number.isInteger(m))) {
throw new BadRequestException('Due months must be integers between 1 and 12');
}
switch (frequency) {
case 'monthly':
if (dueMonths.length !== 12) {
throw new BadRequestException('Monthly frequency must include all 12 months');
}
break;
case 'quarterly':
if (dueMonths.length !== 4) {
throw new BadRequestException('Quarterly frequency must have exactly 4 due months');
}
break;
case 'annual':
if (dueMonths.length !== 1) {
throw new BadRequestException('Annual frequency must have exactly 1 due month');
}
break;
}
}
async create(dto: any) {
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
@@ -51,17 +84,23 @@ export class AssessmentGroupsService {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
}
const frequency = dto.frequency || 'monthly';
const dueMonths = dto.dueMonths || DEFAULT_DUE_MONTHS[frequency] || DEFAULT_DUE_MONTHS.monthly;
const dueDay = Math.min(Math.max(dto.dueDay || 1, 1), 28);
this.validateDueMonths(frequency, dueMonths);
const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault],
dto.unitCount || 0, frequency, dueMonths, dueDay, shouldBeDefault],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const existing = await this.findOne(id);
if (dto.isDefault === true) {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
@@ -80,6 +119,24 @@ export class AssessmentGroupsService {
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
// Handle due_months: if frequency changed and no explicit dueMonths, auto-populate defaults
const effectiveFrequency = dto.frequency || existing.frequency;
if (dto.dueMonths !== undefined) {
this.validateDueMonths(effectiveFrequency, dto.dueMonths);
sets.push(`due_months = $${idx++}`);
params.push(dto.dueMonths);
} else if (dto.frequency !== undefined && dto.frequency !== existing.frequency) {
// Frequency changed, auto-populate due_months
const newDueMonths = DEFAULT_DUE_MONTHS[dto.frequency] || DEFAULT_DUE_MONTHS.monthly;
sets.push(`due_months = $${idx++}`);
params.push(newDueMonths);
}
if (dto.dueDay !== undefined) {
sets.push(`due_day = $${idx++}`);
params.push(Math.min(Math.max(dto.dueDay, 1), 28));
}
if (!sets.length) return this.findOne(id);
sets.push('updated_at = NOW()');

View File

@@ -9,6 +9,7 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@@ -23,12 +24,14 @@ export class AuthController {
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
@UseGuards(AuthGuard('local'))
async login(@Request() req: any, @Body() _dto: LoginDto) {
const ip = req.headers['x-forwarded-for'] || req.ip;

View File

@@ -118,7 +118,6 @@ export class AuthService {
sub: user.id,
email: user.email,
orgId: membership.organizationId,
orgSchema: membership.organization.schemaName,
role: membership.role,
};
@@ -177,7 +176,6 @@ export class AuthService {
if (defaultOrg) {
payload.orgId = defaultOrg.organizationId;
payload.orgSchema = defaultOrg.organization?.schemaName;
payload.role = defaultOrg.role;
}
@@ -195,7 +193,6 @@ export class AuthService {
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
schemaName: uo.organization?.schemaName,
status: uo.organization?.status,
role: uo.role,
})),

View File

@@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
sub: payload.sub,
email: payload.email,
orgId: payload.orgId,
orgSchema: payload.orgSchema,
role: payload.role,
isSuperadmin: payload.isSuperadmin || false,
impersonatedBy: payload.impersonatedBy || null,

View File

@@ -16,7 +16,7 @@ export class HealthScoresController {
@Get('latest')
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
getLatest(@Req() req: any) {
const schema = req.user?.orgSchema;
const schema = req.tenantSchema;
return this.service.getLatestScores(schema);
}
@@ -24,7 +24,7 @@ export class HealthScoresController {
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.user?.orgSchema;
const schema = req.tenantSchema;
// Fire-and-forget — background processing saves results to DB
Promise.all([
@@ -44,7 +44,7 @@ export class HealthScoresController {
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
@AllowViewer()
async calculateOperating(@Req() req: any) {
const schema = req.user?.orgSchema;
const schema = req.tenantSchema;
// Fire-and-forget
this.service.calculateScore(schema, 'operating').catch((err) => {
@@ -61,7 +61,7 @@ export class HealthScoresController {
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
@AllowViewer()
async calculateReserve(@Req() req: any) {
const schema = req.user?.orgSchema;
const schema = req.tenantSchema;
// Fire-and-forget
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.`);
}
// 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)
const projects = await qr.query(
`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)}
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)}
Percent Funded: ${data.percentFunded.toFixed(1)}%
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.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 Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}

View File

@@ -16,6 +16,11 @@ export class InvoicesController {
@Get(':id')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-preview')
generatePreview(@Body() dto: { month: number; year: number }) {
return this.invoicesService.generatePreview(dto);
}
@Post('generate-bulk')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub);

View File

@@ -1,33 +1,135 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
const MONTH_NAMES = [
'', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTH_ABBREV = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
@Injectable()
export class InvoicesService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query(`
SELECT i.*, u.unit_number,
SELECT i.*, u.unit_number, u.owner_name, ag.name as assessment_group_name, ag.frequency,
(i.amount - i.amount_paid) as balance_due
FROM invoices i
JOIN units u ON u.id = i.unit_id
LEFT JOIN assessment_groups ag ON ag.id = i.assessment_group_id
ORDER BY i.invoice_date DESC, i.invoice_number DESC
`);
}
async findOne(id: string) {
const rows = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i
SELECT i.*, u.unit_number, u.owner_name FROM invoices i
JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]);
if (!rows.length) throw new NotFoundException('Invoice not found');
return rows[0];
}
async generateBulk(dto: { month: number; year: number }, userId: string) {
const units = await this.tenant.query(
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 0`,
/**
* Calculate billing period based on frequency and the billing month.
*/
private calculatePeriod(frequency: string, month: number, year: number): { start: string; end: string; description: string } {
switch (frequency) {
case 'quarterly': {
// Period covers 3 months starting from the billing month
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month + 2, 0); // last day of month+2
const endMonth = month + 2 > 12 ? month + 2 - 12 : month + 2;
const quarter = Math.ceil(month / 3);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Q${quarter} ${year} Assessment (${MONTH_ABBREV[month]}-${MONTH_ABBREV[endMonth]})`,
};
}
case 'annual': {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Annual Assessment ${year}`,
};
}
default: { // monthly
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0); // last day of month
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Monthly Assessment - ${MONTH_NAMES[month]} ${year}`,
};
}
}
}
/**
* Preview which groups/units will be billed for a given month/year.
*/
async generatePreview(dto: { month: number; year: number }) {
const allGroups = await this.tenant.query(
`SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as active_units
FROM assessment_groups ag WHERE ag.is_active = true ORDER BY ag.name`,
);
if (!units.length) throw new BadRequestException('No active units with assessments found');
const groups = allGroups.map((g: any) => {
const dueMonths: number[] = g.due_months || [1,2,3,4,5,6,7,8,9,10,11,12];
const isBillingMonth = dueMonths.includes(dto.month);
const activeUnits = parseInt(g.active_units || '0');
const totalAmount = isBillingMonth
? (parseFloat(g.regular_assessment) + parseFloat(g.special_assessment || '0')) * activeUnits
: 0;
const period = this.calculatePeriod(g.frequency || 'monthly', dto.month, dto.year);
return {
id: g.id,
name: g.name,
frequency: g.frequency || 'monthly',
due_months: dueMonths,
active_units: activeUnits,
regular_assessment: g.regular_assessment,
special_assessment: g.special_assessment,
is_billing_month: isBillingMonth,
total_amount: totalAmount,
period_description: period.description,
};
});
const billableGroups = groups.filter((g: any) => g.is_billing_month && g.active_units > 0);
const totalInvoices = billableGroups.reduce((sum: number, g: any) => sum + g.active_units, 0);
const totalAmount = billableGroups.reduce((sum: number, g: any) => sum + g.total_amount, 0);
return {
month: dto.month,
year: dto.year,
month_name: MONTH_NAMES[dto.month],
groups,
summary: { total_groups_billing: billableGroups.length, total_invoices: totalInvoices, total_amount: totalAmount },
};
}
/**
* Generate invoices for all assessment groups where the given month is a billing month.
*/
async generateBulk(dto: { month: number; year: number }, userId: string) {
// Get assessment groups where this month is a billing month
const groups = await this.tenant.query(
`SELECT * FROM assessment_groups WHERE is_active = true AND $1 = ANY(due_months)`,
[dto.month],
);
if (!groups.length) {
throw new BadRequestException(`No assessment groups have billing scheduled for ${MONTH_NAMES[dto.month]}`);
}
// Get or create fiscal period
let fp = await this.tenant.query(
@@ -41,50 +143,87 @@ export class InvoicesService {
}
const fiscalPeriodId = fp[0].id;
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
const dueDate = new Date(dto.year, dto.month - 1, 15);
// Look up GL accounts once
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
let created = 0;
const groupResults: any[] = [];
for (const unit of units) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
// Check if already generated
const existing = await this.tenant.query(
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
);
if (existing.length) continue;
// Create the invoice
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
unit.monthly_assessment],
for (const group of groups) {
// Get active units in this assessment group
const units = await this.tenant.query(
`SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`,
[group.id],
);
// Create journal entry: DR Accounts Receivable, CR Assessment Income
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
if (!units.length) continue;
if (arAccount.length && incomeAccount.length) {
const je = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
const frequency = group.frequency || 'monthly';
const period = this.calculatePeriod(frequency, dto.month, dto.year);
const dueDay = Math.min(group.due_day || 1, 28);
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
const dueDate = new Date(dto.year, dto.month - 1, dueDay);
// Use the group's assessment amount (full period amount, not monthly equivalent)
const assessmentAmount = parseFloat(group.regular_assessment) + parseFloat(group.special_assessment || '0');
let groupCreated = 0;
for (const unit of units) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
// Check if already generated
const existing = await this.tenant.query(
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
);
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unit.monthly_assessment, incomeAccount[0].id],
);
await this.tenant.query(
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
if (existing.length) continue;
// Use unit-level override if set, otherwise use group amount
const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0
? (frequency === 'monthly'
? parseFloat(unit.monthly_assessment)
: frequency === 'quarterly'
? parseFloat(unit.monthly_assessment) * 3
: parseFloat(unit.monthly_assessment) * 12)
: assessmentAmount;
// Create the invoice with status 'pending' (no email sending capability)
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status, period_start, period_end, assessment_group_id)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
period.description, unitAmount, period.start, period.end, group.id],
);
// Create journal entry: DR Accounts Receivable, CR Assessment Income
if (arAccount.length && incomeAccount.length) {
const je = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
);
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unitAmount, incomeAccount[0].id],
);
await this.tenant.query(
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
);
}
created++;
groupCreated++;
}
created++;
groupResults.push({
group_name: group.name,
frequency,
period: period.description,
invoices_created: groupCreated,
});
}
return { created, month: dto.month, year: dto.year };
return { created, month: dto.month, year: dto.year, groups: groupResults };
}
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
@@ -95,7 +234,7 @@ export class InvoicesService {
const overdue = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i
JOIN units u ON u.id = i.unit_id
WHERE i.status IN ('sent', 'partial') AND i.due_date < $1
WHERE i.status IN ('pending', 'partial') AND i.due_date < $1
AND NOT EXISTS (
SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id
AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%'
@@ -109,7 +248,7 @@ export class InvoicesService {
const lfNum = `LF-${inv.invoice_number}`;
await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'sent')`,
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'pending')`,
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
);
applied++;

View File

@@ -13,6 +13,16 @@ export class JournalEntriesService {
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
let sql = `
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(
'id', jel.id, 'account_id', jel.account_id,
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,

View File

@@ -153,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role;
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 {
// Create new user
const passwordHash = await bcrypt.hash(data.password, 12);

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PaymentsService } from './payments.service';
@@ -18,4 +18,12 @@ export class PaymentsController {
@Post()
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
return this.paymentsService.update(id, dto, req.user.sub);
}
@Delete(':id')
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
}

View File

@@ -74,17 +74,95 @@ export class PaymentsService {
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
}
// Update invoice if linked
// Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error
if (invoice) {
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
const invoiceAmt = parseFloat(invoice.amount);
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $3`,
[newPaid, newStatus, invoice.id],
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $4`,
[newPaid, newStatus, newStatus, invoice.id],
);
}
return payment[0];
}
async update(id: string, dto: any, userId: string) {
const existing = await this.findOne(id);
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.payment_date !== undefined) { sets.push(`payment_date = $${idx++}`); params.push(dto.payment_date); }
if (dto.amount !== undefined) { sets.push(`amount = $${idx++}`); params.push(dto.amount); }
if (dto.payment_method !== undefined) { sets.push(`payment_method = $${idx++}`); params.push(dto.payment_method); }
if (dto.reference_number !== undefined) { sets.push(`reference_number = $${idx++}`); params.push(dto.reference_number); }
if (dto.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(dto.notes); }
if (!sets.length) return this.findOne(id);
params.push(id);
await this.tenant.query(
`UPDATE payments SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
// If amount changed and payment is linked to an invoice, recalculate invoice totals
if (dto.amount !== undefined && existing.invoice_id) {
await this.recalculateInvoice(existing.invoice_id);
}
return this.findOne(id);
}
async delete(id: string) {
const payment = await this.findOne(id);
const invoiceId = payment.invoice_id;
// Delete associated journal entry lines and journal entry
if (payment.journal_entry_id) {
await this.tenant.query('DELETE FROM journal_entry_lines WHERE journal_entry_id = $1', [payment.journal_entry_id]);
await this.tenant.query('DELETE FROM journal_entries WHERE id = $1', [payment.journal_entry_id]);
}
// Delete the payment
await this.tenant.query('DELETE FROM payments WHERE id = $1', [id]);
// Recalculate invoice totals if payment was linked
if (invoiceId) {
await this.recalculateInvoice(invoiceId);
}
return { success: true };
}
private async recalculateInvoice(invoiceId: string) {
// Sum all remaining payments for this invoice
const result = await this.tenant.query(
'SELECT COALESCE(SUM(amount), 0) as total_paid FROM payments WHERE invoice_id = $1',
[invoiceId],
);
const totalPaid = parseFloat(result[0].total_paid);
// Get the invoice amount
const inv = await this.tenant.query('SELECT amount FROM invoices WHERE id = $1', [invoiceId]);
if (!inv.length) return;
const invoiceAmt = parseFloat(inv[0].amount);
let newStatus: string;
if (totalPaid >= invoiceAmt) {
newStatus = 'paid';
} else if (totalPaid > 0) {
newStatus = 'partial';
} else {
newStatus = 'pending';
}
await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = $4`,
[totalPaid, newStatus, newStatus, invoiceId],
);
}
}

View File

@@ -0,0 +1,57 @@
-- Migration 011: Add billing frequency support to invoices
-- Adds due_months and due_day to assessment_groups
-- Adds period_start, period_end, assessment_group_id to invoices
DO $$
DECLARE
v_schema TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Add due_months and due_day to assessment_groups
EXECUTE format('
ALTER TABLE %I.assessment_groups
ADD COLUMN IF NOT EXISTS due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
ADD COLUMN IF NOT EXISTS due_day INTEGER DEFAULT 1
', v_schema);
-- Add period tracking and assessment group link to invoices
EXECUTE format('
ALTER TABLE %I.invoices
ADD COLUMN IF NOT EXISTS period_start DATE,
ADD COLUMN IF NOT EXISTS period_end DATE,
ADD COLUMN IF NOT EXISTS assessment_group_id UUID
', v_schema);
-- Backfill due_months based on existing frequency values
EXECUTE format('
UPDATE %I.assessment_groups
SET due_months = CASE frequency
WHEN ''quarterly'' THEN ''{1,4,7,10}''::INTEGER[]
WHEN ''annual'' THEN ''{1}''::INTEGER[]
ELSE ''{1,2,3,4,5,6,7,8,9,10,11,12}''::INTEGER[]
END
WHERE due_months IS NULL OR due_months = ''{1,2,3,4,5,6,7,8,9,10,11,12}''
AND frequency != ''monthly''
', v_schema);
-- Backfill period_start/period_end for existing invoices (all monthly)
EXECUTE format('
UPDATE %I.invoices
SET period_start = invoice_date,
period_end = (invoice_date + INTERVAL ''1 month'' - INTERVAL ''1 day'')::DATE
WHERE period_start IS NULL AND invoice_type = ''regular_assessment''
', v_schema);
-- Backfill assessment_group_id on existing invoices from units
EXECUTE format('
UPDATE %I.invoices i
SET assessment_group_id = u.assessment_group_id
FROM %I.units u
WHERE i.unit_id = u.id AND i.assessment_group_id IS NULL
', v_schema, v_schema);
END LOOP;
END $$;

View File

@@ -0,0 +1,33 @@
-- Migration 012: Replace 'sent' status with 'pending' for invoices
-- 'sent' implied email delivery which doesn't exist; 'pending' is more accurate
DO $$
DECLARE
v_schema TEXT;
v_constraint TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Find and drop the existing status check constraint
SELECT constraint_name INTO v_constraint
FROM information_schema.table_constraints
WHERE table_schema = v_schema
AND table_name = 'invoices'
AND constraint_type = 'CHECK'
AND constraint_name LIKE '%status%';
IF v_constraint IS NOT NULL THEN
EXECUTE format('ALTER TABLE %I.invoices DROP CONSTRAINT %I', v_schema, v_constraint);
END IF;
-- Add new constraint that includes 'pending'
EXECUTE format('ALTER TABLE %I.invoices ADD CONSTRAINT invoices_status_check CHECK (status IN (
''draft'', ''pending'', ''sent'', ''paid'', ''partial'', ''overdue'', ''void'', ''written_off''
))', v_schema);
-- Convert existing 'sent' invoices to 'pending'
EXECUTE format('UPDATE %I.invoices SET status = ''pending'' WHERE status = ''sent''', v_schema);
END LOOP;
END $$;

View File

@@ -204,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT ''monthly'',
due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
due_day INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)', v_schema);
@@ -244,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices (
amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT ''draft'',
period_start DATE,
period_end DATE,
assessment_group_id UUID,
journal_entry_id UUID,
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
@@ -443,10 +449,10 @@ END LOOP;
-- ============================================================
-- 4b. Seed Assessment Groups
-- ============================================================
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20),
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15),
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15)
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day) VALUES
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 15),
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 1275.00, 0.00, 15, ''quarterly'', ''{1,4,7,10}'', 1),
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1)
', v_schema);
-- ============================================================

View File

@@ -9,5 +9,34 @@
<body>
<div id="root"></div>
<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>
</html>

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta",
"version": "2026.03.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta",
"version": "2026.03.10",
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta",
"version": "2026.3.11",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLogout,
@@ -9,9 +9,12 @@ import {
IconUserCog,
IconUsersGroup,
IconEyeOff,
IconSun,
IconMoon,
} from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
@@ -20,6 +23,7 @@ import logoSrc from '../../assets/logo.svg';
export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate();
const location = useLocation();
const isImpersonating = !!impersonationOriginal;
@@ -108,6 +112,16 @@ export function AppLayout() {
{currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
)}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
onClick={toggleColorScheme}
aria-label="Toggle color scheme"
>
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={220}>
<Menu.Target>
<UnstyledButton>

View File

@@ -10,6 +10,7 @@ import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import { App } from './App';
import { theme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({
defaultOptions: {
@@ -21,9 +22,11 @@ const queryClient = new QueryClient({
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MantineProvider theme={theme}>
function Root() {
const colorScheme = usePreferencesStore((s) => s.colorScheme);
return (
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
<Notifications position="top-right" />
<ModalsProvider>
<QueryClientProvider client={queryClient}>
@@ -33,5 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>,
);

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
MultiSelect,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
@@ -21,6 +22,8 @@ interface AssessmentGroup {
special_assessment: string;
unit_count: number;
frequency: string;
due_months: number[];
due_day: number;
actual_unit_count: string;
monthly_operating_income: string;
monthly_reserve_income: string;
@@ -49,6 +52,29 @@ const frequencyColors: Record<string, string> = {
annual: 'violet',
};
const MONTH_OPTIONS = [
{ value: '1', label: 'January' },
{ value: '2', label: 'February' },
{ value: '3', label: 'March' },
{ value: '4', label: 'April' },
{ value: '5', label: 'May' },
{ value: '6', label: 'June' },
{ value: '7', label: 'July' },
{ value: '8', label: 'August' },
{ value: '9', label: 'September' },
{ value: '10', label: 'October' },
{ value: '11', label: 'November' },
{ value: '12', label: 'December' },
];
const MONTH_ABBREV = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DEFAULT_DUE_MONTHS: Record<string, string[]> = {
monthly: ['1','2','3','4','5','6','7','8','9','10','11','12'],
quarterly: ['1','4','7','10'],
annual: ['1'],
};
export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
@@ -73,18 +99,31 @@ export function AssessmentGroupsPage() {
specialAssessment: 0,
unitCount: 0,
frequency: 'monthly',
dueMonths: DEFAULT_DUE_MONTHS.monthly,
dueDay: 1,
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
dueMonths: (v, values) => {
if (values.frequency === 'quarterly' && v.length !== 4) return 'Quarterly requires exactly 4 months';
if (values.frequency === 'annual' && v.length !== 1) return 'Annual requires exactly 1 month';
return null;
},
dueDay: (v) => (v >= 1 && v <= 28 ? null : 'Must be 1-28'),
},
});
const saveMutation = useMutation({
mutationFn: (values: any) =>
editing
? api.put(`/assessment-groups/${editing.id}`, values)
: api.post('/assessment-groups', values),
mutationFn: (values: any) => {
const payload = {
...values,
dueMonths: values.dueMonths.map(Number),
};
return editing
? api.put(`/assessment-groups/${editing.id}`, payload)
: api.post('/assessment-groups', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
@@ -121,6 +160,9 @@ export function AssessmentGroupsPage() {
const handleEdit = (group: AssessmentGroup) => {
setEditing(group);
const dueMonths = group.due_months
? group.due_months.map(String)
: DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly;
form.setValues({
name: group.name,
description: group.description || '',
@@ -128,6 +170,8 @@ export function AssessmentGroupsPage() {
specialAssessment: parseFloat(group.special_assessment || '0'),
unitCount: group.unit_count || 0,
frequency: group.frequency || 'monthly',
dueMonths,
dueDay: group.due_day || 1,
});
open();
};
@@ -138,6 +182,12 @@ export function AssessmentGroupsPage() {
open();
};
const handleFrequencyChange = (value: string | null) => {
if (!value) return;
form.setFieldValue('frequency', value);
form.setFieldValue('dueMonths', DEFAULT_DUE_MONTHS[value] || DEFAULT_DUE_MONTHS.monthly);
};
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@@ -149,6 +199,11 @@ export function AssessmentGroupsPage() {
}
};
const formatDueMonths = (months: number[], frequency: string) => {
if (!months || frequency === 'monthly') return 'Every month';
return months.map((m) => MONTH_ABBREV[m]).join(', ');
};
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
@@ -219,6 +274,7 @@ export function AssessmentGroupsPage() {
<Table.Th>Group Name</Table.Th>
<Table.Th ta="center">Units</Table.Th>
<Table.Th>Frequency</Table.Th>
<Table.Th>Due Months</Table.Th>
<Table.Th ta="right">Regular Assessment</Table.Th>
<Table.Th ta="right">Special Assessment</Table.Th>
<Table.Th ta="right">Monthly Equiv.</Table.Th>
@@ -229,7 +285,7 @@ export function AssessmentGroupsPage() {
<Table.Tbody>
{groups.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg">
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
</Text>
@@ -263,6 +319,9 @@ export function AssessmentGroupsPage() {
{frequencyLabels[g.frequency] || 'Monthly'}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDueMonths(g.due_months, g.frequency)}</Text>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
</Table.Td>
@@ -322,8 +381,22 @@ export function AssessmentGroupsPage() {
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' },
]}
{...form.getInputProps('frequency')}
value={form.values.frequency}
onChange={handleFrequencyChange}
/>
{form.values.frequency !== 'monthly' && (
<MultiSelect
label={form.values.frequency === 'quarterly' ? 'Billing Quarters (select 4 months)' : 'Due Month'}
description={form.values.frequency === 'quarterly'
? 'Select the first month of each quarter when assessments are due'
: 'Select the month when the annual assessment is due'}
data={MONTH_OPTIONS}
value={form.values.dueMonths}
onChange={(v) => form.setFieldValue('dueMonths', v)}
error={form.errors.dueMonths}
maxValues={form.values.frequency === 'annual' ? 1 : 4}
/>
)}
<Group grow>
<NumberInput
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
@@ -340,7 +413,16 @@ export function AssessmentGroupsPage() {
{...form.getInputProps('specialAssessment')}
/>
</Group>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
<Group grow>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
<NumberInput
label="Due Day of Month"
description="Day invoices are due (1-28)"
min={1}
max={28}
{...form.getInputProps('dueDay')}
/>
</Group>
<Button type="submit" loading={saveMutation.isPending}>
{editing ? 'Update' : 'Create'}
</Button>

View File

@@ -120,11 +120,6 @@ export function SelectOrgPage() {
<Text fw={500}>{org.name}</Text>
<Group gap={4}>
<Badge size="sm" variant="light">{org.role}</Badge>
{org.schemaName && (
<Badge size="xs" variant="dot" color="gray">
{org.schemaName}
</Badge>
)}
</Group>
</div>
</Group>

View File

@@ -4,10 +4,11 @@ import {
Select, Loader, Center, Badge, Card, Alert,
} from '@mantine/core';
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 api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetLine {
account_id: string;
@@ -95,9 +96,20 @@ function parseCSV(text: string): Record<string, string>[] {
export function BudgetsPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
const [isEditing, setIsEditing] = useState(false);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
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[]>({
queryKey: ['budgets', year],
@@ -106,25 +118,27 @@ export function BudgetsPage() {
// Hydrate each line: ensure numbers and compute annual_total
const hydrated = (data as any[]).map(hydrateBudgetLine);
setBudgetData(hydrated);
setIsEditing(false); // Reset to view mode when year changes or data reloads
return hydrated;
},
});
const saveMutation = useMutation({
mutationFn: async () => {
const lines = budgetData
const payload = budgetData
.filter((b) => months.some((m) => (b as any)[m] > 0))
.map((b) => ({
account_id: b.account_id,
fund_type: b.fund_type,
accountId: b.account_id,
fundType: b.fund_type,
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
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: () => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
setIsEditing(false);
notifications.show({ message: 'Budget saved', color: 'green' });
},
onError: (err: any) => {
@@ -221,6 +235,12 @@ export function BudgetsPage() {
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 updated = [...budgetData];
(updated[idx] as any)[month] = value || 0;
@@ -275,9 +295,35 @@ export function BudgetsPage() {
accept=".csv,.txt"
onChange={handleFileChange}
/>
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>
{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
</Button>
</>
)}
</>)}
</Group>
</Group>
@@ -317,8 +363,8 @@ export function BudgetsPage() {
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', 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: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
{monthLabels.map((m) => (
<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);
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);
return [
@@ -368,9 +414,9 @@ export function BudgetsPage() {
style={{
position: 'sticky',
left: 0,
background: 'white',
background: stickyBg,
zIndex: 1,
borderRight: '1px solid #e9ecef',
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
@@ -379,9 +425,9 @@ export function BudgetsPage() {
style={{
position: 'sticky',
left: 120,
background: 'white',
background: stickyBg,
zIndex: 1,
borderRight: '1px solid #e9ecef',
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
@@ -391,16 +437,21 @@ export function BudgetsPage() {
</Table.Td>
{months.map((m) => (
<Table.Td key={m} p={2}>
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
{cellsEditable ? (
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ta="right" ff="monospace">
{fmt((line as any)[m] || 0)}
</Text>
)}
</Table.Td>
))}
<Table.Td ta="right" fw={500} ff="monospace">

View File

@@ -8,6 +8,7 @@ import {
IconArrowLeft, IconArrowRight, IconCalendar,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { usePreferencesStore } from '../../stores/preferencesStore';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
@@ -79,6 +80,7 @@ export function CashFlowForecastPage() {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
// Filter: All, Operating, Reserve
const [fundFilter, setFundFilter] = useState<string>('all');
@@ -418,10 +420,10 @@ export function CashFlowForecastPage() {
<tr
key={d.month}
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
? 'var(--mantine-color-orange-0)'
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-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>

View File

@@ -414,7 +414,6 @@ export function DashboardPage() {
<Center h={200}><Loader /></Center>
) : (
<>
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<HealthScoreCard
score={healthScores?.operating || null}

View File

@@ -1,13 +1,12 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, Loader, Center, Card,
NumberInput, Select, Loader, Center, Card, Alert,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconFileInvoice, IconSend } from '@tabler/icons-react';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -15,15 +14,55 @@ interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
invoice_date: string; due_date: string; invoice_type: string;
description: string; amount: string; amount_paid: string; balance_due: string;
status: string;
status: string; period_start: string; period_end: string;
assessment_group_name: string; frequency: string; owner_name: string;
}
interface PreviewGroup {
id: string;
name: string;
frequency: string;
active_units: number;
regular_assessment: string;
special_assessment: string;
is_billing_month: boolean;
total_amount: number;
period_description: string;
}
interface Preview {
month: number;
year: number;
month_name: string;
groups: PreviewGroup[];
summary: {
total_groups_billing: number;
total_invoices: number;
total_amount: number;
};
}
const statusColors: Record<string, string> = {
draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
draft: 'gray', pending: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
};
const frequencyColors: Record<string, string> = {
monthly: 'blue', quarterly: 'teal', annual: 'violet',
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
/** Extract last name from "First Last" format */
const getLastName = (ownerName: string | null) => {
if (!ownerName) return '-';
const parts = ownerName.trim().split(/\s+/);
return parts.length > 1 ? parts[parts.length - 1] : ownerName;
};
export function InvoicesPage() {
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
@@ -35,13 +74,36 @@ export function InvoicesPage() {
initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() },
});
// Fetch preview when month/year changes
const fetchPreview = async (month: number, year: number) => {
setPreviewLoading(true);
try {
const { data } = await api.post('/invoices/generate-preview', { month, year });
setPreview(data);
} catch {
setPreview(null);
}
setPreviewLoading(false);
};
useEffect(() => {
if (bulkOpened) {
fetchPreview(bulkForm.values.month, bulkForm.values.year);
}
}, [bulkOpened, bulkForm.values.month, bulkForm.values.year]);
const bulkMutation = useMutation({
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
notifications.show({ message: `Generated ${res.data.created} invoices`, color: 'green' });
const groupInfo = res.data.groups?.map((g: any) => `${g.group_name}: ${g.invoices_created}`).join(', ') || '';
notifications.show({
message: `Generated ${res.data.created} invoices${groupInfo ? ` (${groupInfo})` : ''}`,
color: 'green',
});
closeBulk();
setPreview(null);
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
@@ -54,8 +116,6 @@ export function InvoicesPage() {
},
});
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (isLoading) return <Center h={300}><Loader /></Center>;
const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0);
@@ -66,18 +126,20 @@ export function InvoicesPage() {
<Title order={2}>Invoices</Title>
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Monthly Invoices</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</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">Outstanding</Text><Text fw={700} c="red">{fmt(String(totalOutstanding))}</Text></Card>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(totalOutstanding)}</Text></Card>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Date</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Type</Table.Th><Table.Th ta="right">Amount</Table.Th>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Owner</Table.Th>
<Table.Th>Group</Table.Th><Table.Th>Date</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Period</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
@@ -86,27 +148,104 @@ export function InvoicesPage() {
<Table.Tr key={i.id}>
<Table.Td fw={500}>{i.invoice_number}</Table.Td>
<Table.Td>{i.unit_number}</Table.Td>
<Table.Td>{getLastName(i.owner_name)}</Table.Td>
<Table.Td>
{i.assessment_group_name ? (
<Badge size="sm" variant="light" color={frequencyColors[i.frequency] || 'gray'}>
{i.assessment_group_name}
</Badge>
) : (
<Badge size="sm" variant="light">{i.invoice_type}</Badge>
)}
</Table.Td>
<Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td>
<Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{i.invoice_type}</Badge></Table.Td>
<Table.Td>
{i.period_start && i.period_end ? (
<Text size="xs" c="dimmed">
{new Date(i.period_start).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}
{i.period_start !== i.period_end && (
<> - {new Date(i.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}</>
)}
</Text>
) : (
<Text size="xs" c="dimmed">-</Text>
)}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td>
<Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td>
</Table.Tr>
))}
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={9}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={bulkOpened} onClose={closeBulk} title="Generate Monthly Assessments">
<Modal opened={bulkOpened} onClose={() => { closeBulk(); setPreview(null); }} title="Generate Assessments" size="lg">
<form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}>
<Stack>
<Group grow>
<Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} />
<NumberInput label="Year" {...bulkForm.getInputProps('year')} />
</Group>
<Text size="sm" c="dimmed">This will generate invoices for all active units based on their monthly assessment amount.</Text>
<Button type="submit" loading={bulkMutation.isPending}>Generate Invoices</Button>
{previewLoading && <Center py="md"><Loader size="sm" /></Center>}
{preview && !previewLoading && (
<Stack gap="xs">
<Text size="sm" fw={600}>Billing Preview for {preview.month_name} {preview.year}</Text>
{preview.groups.map((g) => (
<Card key={g.id} withBorder p="xs" style={{ opacity: g.is_billing_month ? 1 : 0.5 }}>
<Group justify="space-between">
<Group gap="xs">
{g.is_billing_month && g.active_units > 0
? <IconCheck size={16} color="green" />
: <IconX size={16} color="gray" />
}
<div>
<Group gap={6}>
<Text size="sm" fw={500}>{g.name}</Text>
<Badge size="xs" color={frequencyColors[g.frequency]} variant="light">
{g.frequency}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{g.is_billing_month
? `${g.active_units} units - ${g.period_description}`
: `Not a billing month for this group`
}
</Text>
</div>
</Group>
{g.is_billing_month && (
<Text size="sm" fw={500} ff="monospace">{fmt(g.total_amount)}</Text>
)}
</Group>
</Card>
))}
{preview.summary.total_invoices > 0 ? (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Will generate {preview.summary.total_invoices} invoices across{' '}
{preview.summary.total_groups_billing} group(s) totaling {fmt(preview.summary.total_amount)}
</Alert>
) : (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">
No assessment groups have billing scheduled for {preview.month_name}. No invoices will be generated.
</Alert>
)}
</Stack>
)}
<Button
type="submit"
loading={bulkMutation.isPending}
disabled={!preview || preview.summary.total_invoices === 0}
>
Generate {preview?.summary.total_invoices || 0} Invoices
</Button>
</Stack>
</form>
</Modal>

View File

@@ -10,6 +10,7 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine {
@@ -66,6 +67,11 @@ export function MonthlyActualsPage() {
const [savedJEId, setSavedJEId] = useState<string | null>(null);
const queryClient = useQueryClient();
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 y = new Date().getFullYear() - 2 + i;
@@ -178,16 +184,16 @@ export function MonthlyActualsPage() {
<Table.Tr key={line.account_id}>
<Table.Td
style={{
position: 'sticky', left: 0, background: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td>
<Table.Td
style={{
position: 'sticky', left: 120, background: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
@@ -292,10 +298,10 @@ export function MonthlyActualsPage() {
<Table striped highlightOnHover style={{ minWidth: 700 }}>
<Table.Thead>
<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 #
</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
</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
@@ -304,8 +310,8 @@ export function MonthlyActualsPage() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
</Table.Tbody>
</Table>
</div>

View File

@@ -1,13 +1,13 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, TextInput, Loader, Center,
NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
@@ -15,11 +15,13 @@ import { useIsReadOnly } from '../../stores/authStore';
interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: string;
invoice_number: string; payment_date: string; amount: string;
payment_method: string; reference_number: string; status: string;
payment_method: string; reference_number: string; status: string; notes: string;
}
export function PaymentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Payment | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
@@ -39,10 +41,18 @@ export function PaymentsPage() {
const form = useForm({
initialValues: {
invoice_id: '', amount: 0, payment_method: 'check',
reference_number: '', payment_date: new Date(),
reference_number: '', payment_date: new Date(), notes: '',
},
});
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
};
const createMutation = useMutation({
mutationFn: (values: any) => {
const inv = invoices.find((i: any) => i.id === values.invoice_id);
@@ -53,22 +63,88 @@ export function PaymentsPage() {
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
invalidateAll();
notifications.show({ message: 'Payment recorded', color: 'green' });
close(); form.reset();
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const updateMutation = useMutation({
mutationFn: (values: any) => {
return api.put(`/payments/${editing!.id}`, {
payment_date: values.payment_date.toISOString().split('T')[0],
amount: values.amount,
payment_method: values.payment_method,
reference_number: values.reference_number,
notes: values.notes,
});
},
onSuccess: () => {
invalidateAll();
notifications.show({ message: 'Payment updated', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/payments/${id}`),
onSuccess: () => {
invalidateAll();
notifications.show({ message: 'Payment deleted', color: 'orange' });
setDeleteConfirm(null);
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (payment: Payment) => {
setEditing(payment);
form.setValues({
invoice_id: payment.invoice_id || '',
amount: parseFloat(payment.amount || '0'),
payment_method: payment.payment_method || 'check',
reference_number: payment.reference_number || '',
payment_date: new Date(payment.payment_date),
notes: payment.notes || '',
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
const handleSubmit = (values: any) => {
if (editing) {
updateMutation.mutate(values);
} else {
createMutation.mutate(values);
}
};
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const invoiceOptions = invoices.map((i: any) => ({
value: i.id,
label: `${i.invoice_number} - ${i.unit_number || 'Unit'} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
}));
const formatPeriod = (inv: any) => {
if (inv.period_start && inv.period_end) {
const start = new Date(inv.period_start).toLocaleDateString(undefined, { month: 'short' });
const end = new Date(inv.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
return inv.period_start === inv.period_end ? start : `${start}-${end}`;
}
return '';
};
const invoiceOptions = invoices.map((i: any) => {
const period = formatPeriod(i);
const periodStr = period ? ` - ${period}` : '';
return {
value: i.id,
label: `${i.invoice_number} - ${i.unit_number || 'Unit'}${periodStr} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
};
});
if (isLoading) return <Center h={300}><Loader /></Center>;
@@ -76,7 +152,7 @@ export function PaymentsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Payments</Title>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>}
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Record Payment</Button>}
</Group>
<Table striped highlightOnHover>
<Table.Thead>
@@ -84,6 +160,7 @@ export function PaymentsPage() {
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -96,18 +173,34 @@ export function PaymentsPage() {
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
<Table.Td>{p.reference_number}</Table.Td>
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit payment">
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
{payments.length === 0 && (
<Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
<Table.Tr><Table.Td colSpan={isReadOnly ? 7 : 8}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
)}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title="Record Payment">
<form onSubmit={form.onSubmit((v) => createMutation.mutate(v))}>
{/* Create / Edit Payment Modal */}
<Modal opened={opened} onClose={() => { close(); setEditing(null); form.reset(); }} title={editing ? 'Edit Payment' : 'Record Payment'}>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Select label="Invoice" required data={invoiceOptions} searchable
{...form.getInputProps('invoice_id')} />
{!editing && (
<Select label="Invoice" required data={invoiceOptions} searchable
{...form.getInputProps('invoice_id')} />
)}
{editing && (
<TextInput label="Invoice" value={editing.invoice_number || 'N/A'} disabled />
)}
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
{...form.getInputProps('amount')} />
@@ -118,10 +211,60 @@ export function PaymentsPage() {
]} {...form.getInputProps('payment_method')} />
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
{...form.getInputProps('reference_number')} />
<Button type="submit" loading={createMutation.isPending}>Record Payment</Button>
<TextInput label="Notes" placeholder="Optional notes"
{...form.getInputProps('notes')} />
<Group justify="space-between">
{editing ? (
<>
<Button
variant="outline"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => setDeleteConfirm(editing)}
>
Delete Payment
</Button>
<Button type="submit" loading={updateMutation.isPending}>
Update Payment
</Button>
</>
) : (
<Button type="submit" fullWidth loading={createMutation.isPending}>Record Payment</Button>
)}
</Group>
</Stack>
</form>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Delete Payment"
size="sm"
>
<Stack>
<Text size="sm">
Are you sure you want to delete this payment of{' '}
<Text span fw={700}>{deleteConfirm ? fmt(deleteConfirm.amount) : ''}</Text>{' '}
for unit {deleteConfirm?.unit_number}?
</Text>
<Text size="xs" c="dimmed">
This will also remove the associated journal entry and recalculate the invoice balance.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
>
Delete Payment
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -6,9 +6,11 @@ import {
IconUser, IconPalette, IconClock, IconBell, IconEye,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
export function UserPreferencesPage() {
const { user, currentOrg } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
return (
<Stack>
@@ -66,7 +68,10 @@ export function UserPreferencesPage() {
<Text size="sm">Dark Mode</Text>
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
</div>
<Switch disabled />
<Switch
checked={colorScheme === 'dark'}
onChange={toggleColorScheme}
/>
</Group>
<Group justify="space-between">
<div>
@@ -76,7 +81,7 @@ export function UserPreferencesPage() {
<Switch disabled />
</Group>
<Divider />
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text>
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
</Stack>
</Card>

View File

@@ -5,6 +5,7 @@ import {
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualLine {
account_id: string;
@@ -46,6 +47,9 @@ const monthFilterOptions = [
export function BudgetVsActualPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
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 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) => (
<>
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
</Table.Tr>
{sectionLines.map((line) => {

View File

@@ -8,6 +8,7 @@ import {
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
} from '@tabler/icons-react';
import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualItem {
account_id: string;
@@ -48,6 +49,9 @@ export function QuarterlyReportPage() {
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter;
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 [quarter, setQuarter] = useState(String(defaultQuarter));
@@ -207,7 +211,7 @@ export function QuarterlyReportPage() {
</Table.Thead>
<Table.Tbody>
{incomeItems.length > 0 && (
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Tr style={{ background: incomeBg }}>
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
</Table.Tr>
)}
@@ -215,7 +219,7 @@ export function QuarterlyReportPage() {
<BVARow key={item.account_id} item={item} isExpense={false} />
))}
{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 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>
@@ -226,7 +230,7 @@ export function QuarterlyReportPage() {
</Table.Tr>
)}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Tr style={{ background: expenseBg }}>
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
</Table.Tr>
)}
@@ -234,7 +238,7 @@ export function QuarterlyReportPage() {
<BVARow key={item.account_id} item={item} isExpense={true} />
))}
{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 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>

View File

@@ -38,10 +38,6 @@ export function SettingsPage() {
<Text size="sm" c="dimmed">Your Role</Text>
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
</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>
</Card>
@@ -117,7 +113,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.3.2 (beta)</Badge>
<Badge variant="light">2026.03.10</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

@@ -5,7 +5,6 @@ interface Organization {
id: string;
name: string;
role: string;
schemaName?: string;
status?: string;
settings?: Record<string, any>;
}

View File

@@ -0,0 +1,26 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type ColorScheme = 'light' | 'dark';
interface PreferencesState {
colorScheme: ColorScheme;
toggleColorScheme: () => void;
setColorScheme: (scheme: ColorScheme) => void;
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
colorScheme: 'light',
toggleColorScheme: () =>
set((state) => ({
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
})),
setColorScheme: (scheme) => set({ colorScheme: scheme }),
}),
{
name: 'ledgeriq-preferences',
},
),
);

150
scripts/reset-password.sh Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# reset-password.sh — Reset a user's password in HOA LedgerIQ
#
# Usage:
# ./scripts/reset-password.sh <email> <new-password>
#
# Examples:
# ./scripts/reset-password.sh admin@hoaledgeriq.com MyNewPassword123
# ./scripts/reset-password.sh admin@sunrisevalley.org SecurePass!
# ---------------------------------------------------------------------------
set -euo pipefail
# ---- Defaults ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
COMPOSE_CMD="docker compose"
# If running with the SSL override, detect it
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
fi
# ---- Colors ----
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
# ---- Helpers ----
ensure_containers_running() {
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
fi
if ! $COMPOSE_CMD ps backend 2>/dev/null | grep -q "running\|Up"; then
die "Backend container is not running. Start it with: docker compose up -d backend"
fi
}
# ---- CLI ----
usage() {
cat <<EOF
HOA LedgerIQ Password Reset
Usage:
$(basename "$0") <email> <new-password>
Examples:
$(basename "$0") admin@hoaledgeriq.com MyNewPassword123
$(basename "$0") admin@sunrisevalley.org SecurePass!
This script:
1. Verifies the user exists in the database
2. Generates a bcrypt hash using bcryptjs (same library the app uses)
3. Updates the password in the database
4. Verifies the new hash works
EOF
exit 0
}
# Parse args
case "${1:-}" in
-h|--help|help|"") usage ;;
esac
[ $# -lt 2 ] && die "Usage: $(basename "$0") <email> <new-password>"
EMAIL="$1"
NEW_PASSWORD="$2"
# Load .env if present
if [ -f "$PROJECT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$PROJECT_DIR/.env"
set +a
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
fi
# Ensure containers are running
info "Checking containers ..."
ensure_containers_running
# Verify user exists
info "Looking up user: ${EMAIL} ..."
USER_RECORD=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
-t -A -c "SELECT id, email, first_name, last_name, is_superadmin FROM shared.users WHERE email = '${EMAIL}';" 2>/dev/null)
if [ -z "$USER_RECORD" ]; then
die "No user found with email: ${EMAIL}"
fi
# Parse user info for display
IFS='|' read -r USER_ID USER_EMAIL FIRST_NAME LAST_NAME IS_SUPER <<< "$USER_RECORD"
info "Found user: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
if [ "$IS_SUPER" = "t" ]; then
warn "This is a superadmin account"
fi
# Generate bcrypt hash using bcryptjs inside the backend container
info "Generating bcrypt hash ..."
HASH=$($COMPOSE_CMD exec -T backend node -e "
const bcrypt = require('bcryptjs');
bcrypt.hash(process.argv[1], 12).then(h => process.stdout.write(h));
" "$NEW_PASSWORD" 2>/dev/null)
if [ -z "$HASH" ] || [ ${#HASH} -lt 50 ]; then
die "Failed to generate bcrypt hash. Is the backend container running?"
fi
# Update the password using a heredoc to avoid shell escaping issues with $ in hashes
info "Updating password ..."
UPDATE_RESULT=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -t -A <<EOSQL
UPDATE shared.users SET password_hash = '${HASH}', updated_at = NOW() WHERE email = '${EMAIL}';
EOSQL
)
if [[ "$UPDATE_RESULT" != *"UPDATE 1"* ]]; then
die "Password update failed. Result: ${UPDATE_RESULT}"
fi
# Verify the new hash works
info "Verifying new password ..."
VERIFY=$($COMPOSE_CMD exec -T backend node -e "
const bcrypt = require('bcryptjs');
bcrypt.compare(process.argv[1], process.argv[2]).then(r => process.stdout.write(String(r)));
" "$NEW_PASSWORD" "$HASH" 2>/dev/null)
if [ "$VERIFY" != "true" ]; then
die "Verification failed — the hash does not match the password. Something went wrong."
fi
echo ""
ok "Password reset successful!"
echo ""
info " User: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
info " Login: ${EMAIL}"
echo ""