11 Commits

Author SHA1 Message Date
e62f3e7b07 Merge pull request 'claude/reverent-moore' (#6) from claude/reverent-moore into main
Reviewed-on: #6
2026-03-17 06:55:45 -04:00
e3022f20c5 Merge pull request 'claude/SSOMFASTRIPE' (#5) from claude/reverent-moore into main
Reviewed-on: #5
2026-03-16 21:22:34 -04:00
9cd20a1867 Merge branch 'ai-improvements' 2026-03-16 16:34:11 -04:00
420227d70c Merge branch 'feature/invoice-billing-frequency'
# Conflicts:
#	frontend/src/pages/invoices/InvoicesPage.tsx
2026-03-16 16:34:04 -04:00
e893319cfe Merge branch 'fix/viewer-readonly-audit'
# Conflicts:
#	frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx
2026-03-16 16:33:24 -04:00
93eeacfe8f Merge branch 'claude/reverent-moore' into feature/board-planning 2026-03-16 16:28:52 -04:00
267d92933e chore: reorganize sidebar navigation and bump version to 2026.03.16
Remove the Planning section. Move Projects and Capital Planning (as
sub-item) into Board Planning. Move Investment Planning with Investment
Scenarios as sub-item into Board Planning. Move Vendors into new Board
Reference section. Board Planning order: Budget Planning, Projects >
Capital Planning, Assessment Scenarios, Investment Planning > Investment
Scenarios, Compare Scenarios. Sidebar now supports parent items with
their own route plus nested children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:21:58 -04:00
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:59:20 -04:00
2b83defbc3 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 11:53:54 -05:00
a59dac7fe1 Merge remote-tracking branch 'origin/feature/invoice-billing-frequency' into ai-improvements 2026-03-06 19:18:11 -05:00
1e31595d7f 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-06 19:08:56 -05:00
11 changed files with 97 additions and 401 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.3.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.3.11",
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
@@ -36,7 +36,6 @@
"pg": "^8.13.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"resend": "^6.9.4",
"rxjs": "^7.8.1",
"stripe": "^20.4.1",
"typeorm": "^0.3.20",
@@ -2792,12 +2791,6 @@
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@tokenizer/inflate": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@@ -5364,12 +5357,6 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -8736,12 +8723,6 @@
"node": ">= 0.4"
}
},
"node_modules/postal-mime": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
"license": "MIT-0"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -9226,27 +9207,6 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resend": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz",
"integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.3",
"svix": "1.86.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -9819,16 +9779,6 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -10087,29 +10037,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svix": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
"integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0",
"uuid": "^10.0.0"
}
},
"node_modules/svix/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.17.14",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz",

View File

@@ -45,7 +45,6 @@
"pg": "^8.13.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"resend": "^6.9.4",
"rxjs": "^7.8.1",
"stripe": "^20.4.1",
"typeorm": "^0.3.20",

View File

@@ -1,159 +1,50 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { Resend } from 'resend';
/**
* Stubbed email service — logs to console and stores in shared.email_log.
* Replace internals with Resend/SendGrid when ready for production.
*/
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private resend: Resend | null = null;
private fromAddress: string;
private replyToAddress: string;
constructor(
private configService: ConfigService,
private dataSource: DataSource,
) {
const apiKey = this.configService.get<string>('RESEND_API_KEY');
if (apiKey && !apiKey.includes('placeholder')) {
this.resend = new Resend(apiKey);
this.logger.log('Resend email service initialized');
} else {
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
}
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
}
// ─── Public API ──────────────────────────────────────────────
constructor(private dataSource: DataSource) {}
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: 'Your HOA LedgerIQ account is ready to activate.',
heading: 'Welcome to HOA LedgerIQ!',
body: `
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
<p>Click the button below to set your password and activate your account:</p>
`,
ctaText: 'Activate My Account',
ctaUrl: activationUrl,
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
});
const body = [
`Welcome to HOA LedgerIQ!`,
``,
`Your organization "${businessName}" has been created.`,
`Please activate your account by clicking the link below:`,
``,
activationUrl,
``,
`This link expires in 72 hours.`,
].join('\n');
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
await this.log(email, subject, body, 'activation', { businessName, activationUrl });
}
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
const html = this.buildTemplate({
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
heading: `You're all set!`,
body: `
<p>Your account for <strong>${this.esc(businessName)}</strong> is now active.</p>
<p>Log in to start managing your HOA's finances, assessments, and investments — all in one place.</p>
`,
ctaText: 'Go to Dashboard',
ctaUrl: `${appUrl}/dashboard`,
footer: 'If you have any questions, just reply to this email and we\'ll help you get started.',
});
await this.send(email, subject, html, 'welcome', { businessName });
const body = `Your account is active. Log in at http://localhost to get started.`;
await this.log(email, subject, body, 'welcome', { businessName });
}
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
const subject = `Action required: Payment failed for ${businessName}`;
const html = this.buildTemplate({
preheader: 'We were unable to process your payment.',
heading: 'Payment Failed',
body: `
<p>We were unable to process the latest payment for <strong>${this.esc(businessName)}</strong>.</p>
<p>Please update your payment method to avoid any interruption to your service.</p>
`,
ctaText: 'Update Payment Method',
ctaUrl: `${this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`,
footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.',
});
await this.send(email, subject, html, 'payment_failed', { businessName });
const subject = `Payment failed for ${businessName} on HOA LedgerIQ`;
const body = `We were unable to process your payment. Please update your payment method.`;
await this.log(email, subject, body, 'payment_failed', { businessName });
}
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: `Join ${orgName} on HOA LedgerIQ.`,
heading: 'You\'re Invited!',
body: `
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
<p>Click below to accept the invitation and set up your account:</p>
`,
ctaText: 'Accept Invitation',
ctaUrl: inviteUrl,
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
});
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
const body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`;
await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl });
}
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password';
const html = this.buildTemplate({
preheader: 'Password reset requested for your HOA LedgerIQ account.',
heading: 'Password Reset',
body: `
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
`,
ctaText: 'Reset Password',
ctaUrl: resetUrl,
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
});
await this.send(email, subject, html, 'password_reset', { resetUrl });
}
// ─── Core send logic ────────────────────────────────────────
private async send(
toEmail: string,
subject: string,
html: string,
template: string,
metadata: Record<string, any>,
): Promise<void> {
// Always log to the database
await this.log(toEmail, subject, html, template, metadata);
if (!this.resend) {
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
return;
}
try {
const result = await this.resend.emails.send({
from: this.fromAddress,
to: [toEmail],
replyTo: this.replyToAddress || undefined,
subject,
html,
});
if (result.error) {
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
} else {
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
}
} catch (err: any) {
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
await this.updateLogStatus(toEmail, template, 'failed', err.message);
}
}
// ─── Database logging ───────────────────────────────────────
private async log(
toEmail: string,
subject: string,
@@ -161,6 +52,10 @@ export class EmailService {
template: string,
metadata: Record<string, any>,
): Promise<void> {
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
this.logger.log(` Body:\n${body}`);
try {
await this.dataSource.query(
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
@@ -171,119 +66,4 @@ export class EmailService {
this.logger.warn(`Failed to log email: ${err}`);
}
}
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
try {
await this.dataSource.query(
`UPDATE shared.email_log
SET metadata = metadata || $1::jsonb
WHERE to_email = $2 AND template = $3
AND created_at = (
SELECT MAX(created_at) FROM shared.email_log
WHERE to_email = $2 AND template = $3
)`,
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
);
} catch {
// Best effort — don't block the flow
}
}
// ─── HTML email template ────────────────────────────────────
private esc(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
private buildTemplate(opts: {
preheader: string;
heading: string;
body: string;
ctaText: string;
ctaUrl: string;
footer: string;
}): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this.esc(opts.heading)}</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
</head>
<body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<!-- Preheader (hidden preview text) -->
<div style="display:none;max-height:0;overflow:hidden;">${this.esc(opts.preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
<!-- Logo bar -->
<tr>
<td align="center" style="padding:24px 0 16px;">
<span style="font-size:22px;font-weight:700;color:#1a73e8;letter-spacing:-0.5px;">
HOA LedgerIQ
</span>
</td>
</tr>
<!-- Main card -->
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<tr>
<td style="padding:40px 32px;">
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#1a1a2e;">
${this.esc(opts.heading)}
</h1>
<div style="font-size:15px;line-height:1.6;color:#4a4a68;">
${opts.body}
</div>
<!-- CTA Button -->
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">
<tr>
<td align="center" style="background-color:#1a73e8;border-radius:6px;">
<a href="${opts.ctaUrl}"
target="_blank"
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
${this.esc(opts.ctaText)}
</a>
</td>
</tr>
</table>
<!-- Fallback URL -->
<p style="font-size:12px;color:#999;word-break:break-all;margin-top:16px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="${opts.ctaUrl}" style="color:#1a73e8;">${opts.ctaUrl}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 32px;text-align:center;">
<p style="font-size:12px;color:#999;line-height:1.5;margin:0;">
${this.esc(opts.footer)}
</p>
<p style="font-size:12px;color:#bbb;margin:12px 0 0;">
&copy; ${new Date().getFullYear()} HOA LedgerIQ &mdash; Smart Financial Management for HOAs
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
}

View File

@@ -40,25 +40,6 @@ services:
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/azure/callback}
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-app.hoaledgeriq.com}
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-https://app.hoaledgeriq.com}
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-}
- APP_URL=${APP_URL:-https://app.hoaledgeriq.com}
- RESEND_API_KEY=${RESEND_API_KEY:-}
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-sales@hoaledgeriq.com}
deploy:
resources:
limits:

View File

@@ -44,10 +44,6 @@ services:
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
- APP_URL=${APP_URL:-http://localhost}
- RESEND_API_KEY=${RESEND_API_KEY:-}
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-}
volumes:
- ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json

View File

@@ -587,7 +587,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && (
<>
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -605,7 +605,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && (
<>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -623,7 +623,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && (
<>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -1087,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({
investments,
onEdit,
isReadOnly = false,
}: {
investments: Investment[];
onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce(
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -1182,13 +1184,15 @@ function InvestmentMiniTable({
'-'
)}
</Table.Td>
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -72,9 +72,10 @@ interface KanbanCardProps {
project: Project;
onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
isReadOnly?: boolean;
}
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear();
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
padding="sm"
radius="md"
withBorder
draggable
onDragStart={(e) => onDragStart(e, project)}
style={{ cursor: 'grab', userSelect: 'none' }}
draggable={!isReadOnly}
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
mb="xs"
>
<Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
{!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
<Text fw={600} size="sm" truncate>
{project.name}
</Text>
</Group>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
{!isReadOnly && (
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
)}
</Group>
<Group gap={6} mb={6}>
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void;
isReadOnly?: boolean;
}
function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave,
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
}: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR;
@@ -178,9 +182,9 @@ function KanbanColumn({
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease',
}}
onDragOver={(e) => onDragOverHandler(e, year)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, year)}
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
onDragLeave={!isReadOnly ? onDragLeave : undefined}
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
>
<Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title>
@@ -199,7 +203,7 @@ function KanbanColumn({
<Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here
{isReadOnly ? 'No projects' : 'Drop projects here'}
</Text>
) : useWideLayout ? (
<div style={{
@@ -208,12 +212,12 @@ function KanbanColumn({
gap: 'var(--mantine-spacing-xs)',
}}>
{projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))}
</div>
) : (
projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))
)}
</Box>
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave}
isReadOnly={isReadOnly}
/>
);
})}

View File

@@ -18,7 +18,7 @@ import {
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api';
interface HealthScore {
@@ -313,6 +313,7 @@ interface DashboardData {
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling
@@ -426,7 +427,7 @@ export function DashboardPage() {
</ThemeIcon>
}
isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating}
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed}
/>
<HealthScoreCard
@@ -438,7 +439,7 @@ export function DashboardPage() {
</ThemeIcon>
}
isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve}
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed}
/>
</SimpleGrid>

View File

@@ -43,6 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ──
@@ -384,6 +385,7 @@ export function InvestmentPlanningPage() {
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
const isReadOnly = useIsReadOnly();
// Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({
@@ -821,15 +823,17 @@ export function InvestmentPlanningPage() {
</Text>
</div>
</Group>
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
{!isReadOnly && (
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
)}
</Group>
{/* Processing State - shown as banner when refreshing with existing results */}

View File

@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
@@ -64,6 +65,7 @@ export function InvoicesPage() {
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
@@ -124,10 +126,12 @@ export function InvoicesPage() {
<Stack>
<Group justify="space-between">
<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 Invoices</Button>
</Group>
{!isReadOnly && (
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</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>

View File

@@ -47,12 +47,11 @@ const plans = [
{
id: 'enterprise',
name: 'Enterprise',
price: 'Custom',
period: '',
price: '$199',
period: '/month',
description: 'For large communities and management firms',
icon: IconCrown,
color: 'orange',
externalUrl: 'https://www.hoaledgeriq.com/#preview-signup',
features: [
{ text: 'Unlimited units', included: true },
{ text: 'Everything in Professional', included: true },
@@ -163,10 +162,10 @@ export function PricingPage() {
</Group>
<Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
{plan.externalUrl ? 'Request Quote' : plan.price}
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: 36 }}>
{plan.price}
</Text>
{plan.period && <Text size="sm" c="dimmed">{plan.period}</Text>}
<Text size="sm" c="dimmed">{plan.period}</Text>
</Group>
<List spacing="xs" size="sm" center>
@@ -194,14 +193,10 @@ export function PricingPage() {
size="md"
color={plan.color}
variant={plan.popular ? 'filled' : 'light'}
loading={!plan.externalUrl ? loading === plan.id : false}
onClick={() =>
plan.externalUrl
? window.open(plan.externalUrl, '_blank', 'noopener')
: handleSelectPlan(plan.id)
}
loading={loading === plan.id}
onClick={() => handleSelectPlan(plan.id)}
>
{plan.externalUrl ? 'Request Quote' : 'Get Started'}
Get Started
</Button>
</Stack>
</Card>