From 66e2f87a96e2f2e9bfbf2b1167cf140b125d314f Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 18 Mar 2026 14:47:04 -0400 Subject: [PATCH] feat: UX enhancements, member limits, forecast fix, and menu cleanup (v2026.3.19) - Onboarding wizard: add Reserve Account step between Operating and Assessments, redirect to Budget Planning on completion - Dashboard: health score pending state shows clickable links to set up missing items - Projects/Vendors: rich empty-state wizard screens with real-world examples and CTAs - Investment Planning: auto-refresh AI recommendations when empty or stale (>30 days) - Hide Invoices and Payments menus (see PARKING-LOT.md for re-enablement) - Send welcome email via Resend when new members are added to a tenant - Enforce 5-member limit for Starter/Standard/Professional plans (Enterprise unlimited) - Cash flow forecast: only mark months as "Actual" when journal entries exist, fixing the issue where months without data showed as actuals - Bump version to 2026.3.19 Co-Authored-By: Claude Opus 4.6 --- PARKING-LOT.md | 22 ++ backend/package.json | 2 +- backend/src/modules/email/email.service.ts | 23 ++ .../organizations/organizations.service.ts | 41 +++- .../src/modules/reports/reports.service.ts | 15 +- frontend/package.json | 2 +- frontend/src/components/layout/Sidebar.tsx | 5 +- .../onboarding/OnboardingWizard.tsx | 214 ++++++++++++++++-- .../src/pages/dashboard/DashboardPage.tsx | 46 +++- .../InvestmentPlanningPage.tsx | 26 +++ .../src/pages/org-members/OrgMembersPage.tsx | 7 + frontend/src/pages/projects/ProjectsPage.tsx | 57 ++++- frontend/src/pages/vendors/VendorsPage.tsx | 62 ++++- frontend/src/stores/authStore.ts | 1 + 14 files changed, 482 insertions(+), 41 deletions(-) create mode 100644 PARKING-LOT.md diff --git a/PARKING-LOT.md b/PARKING-LOT.md new file mode 100644 index 0000000..31826ce --- /dev/null +++ b/PARKING-LOT.md @@ -0,0 +1,22 @@ +# Parking Lot — Features Hidden or Deferred + +This document tracks features that have been built but are currently hidden or deferred for future use. + +--- + +## Invoices & Payments (Hidden as of 2026.03.19) + +**Status:** Built but hidden from navigation + +**What exists:** +- Full Invoices page at `/invoices` with CRUD, generation, and management +- Full Payments page at `/payments` with payment tracking and reconciliation +- Backend API endpoints for both modules are fully functional +- Routes remain registered in `App.tsx` (accessible via direct URL if needed) + +**Where hidden:** +- `frontend/src/components/layout/Sidebar.tsx` — Navigation links commented out in the Transactions section + +**To re-enable:** +1. Uncomment the Invoices and Payments entries in `Sidebar.tsx` (search for "PARKING-LOT.md") +2. No other changes needed — routes and backend are intact diff --git a/backend/package.json b/backend/package.json index b25d581..b91551f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.3.17", + "version": "2026.3.19", "description": "HOA LedgerIQ - Backend API", "private": true, "scripts": { diff --git a/backend/src/modules/email/email.service.ts b/backend/src/modules/email/email.service.ts index aeb1caa..399ddf9 100644 --- a/backend/src/modules/email/email.service.ts +++ b/backend/src/modules/email/email.service.ts @@ -132,6 +132,29 @@ export class EmailService { await this.send(email, subject, html, 'trial_expired', { businessName }); } + async sendNewMemberWelcomeEmail( + email: string, + firstName: string, + orgName: string, + ): Promise { + const appUrl = this.configService.get('APP_URL') || 'https://app.hoaledgeriq.com'; + const subject = `Welcome to ${orgName} on HOA LedgerIQ`; + const html = this.buildTemplate({ + preheader: `Your account for ${orgName} on HOA LedgerIQ is ready.`, + heading: `Welcome, ${this.esc(firstName)}!`, + body: ` +

You've been added as a member of ${this.esc(orgName)} on HOA LedgerIQ.

+

Your account is ready to use. Log in with your email address and the temporary password provided by your administrator. You'll be able to change your password after logging in.

+

HOA LedgerIQ gives you access to your community's financial dashboard, budgets, reports, and more.

+ `, + ctaText: 'Log In Now', + ctaUrl: `${appUrl}/login`, + footer: 'If you were not expecting this email, please contact your HOA administrator.', + }); + + await this.send(email, subject, html, 'new_member_welcome', { orgName, firstName }); + } + async sendPasswordResetEmail(email: string, resetUrl: string): Promise { const subject = 'Reset your HOA LedgerIQ password'; const html = this.buildTemplate({ diff --git a/backend/src/modules/organizations/organizations.service.ts b/backend/src/modules/organizations/organizations.service.ts index 0afff92..48bb3db 100644 --- a/backend/src/modules/organizations/organizations.service.ts +++ b/backend/src/modules/organizations/organizations.service.ts @@ -1,20 +1,24 @@ -import { Injectable, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable, ConflictException, BadRequestException, NotFoundException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Organization } from './entities/organization.entity'; import { UserOrganization } from './entities/user-organization.entity'; import { TenantSchemaService } from '../../database/tenant-schema.service'; import { CreateOrganizationDto } from './dto/create-organization.dto'; +import { EmailService } from '../email/email.service'; import * as bcrypt from 'bcryptjs'; @Injectable() export class OrganizationsService { + private readonly logger = new Logger(OrganizationsService.name); + constructor( @InjectRepository(Organization) private orgRepository: Repository, @InjectRepository(UserOrganization) private userOrgRepository: Repository, private tenantSchemaService: TenantSchemaService, + private emailService: EmailService, ) {} async create(dto: CreateOrganizationDto, userId: string) { @@ -124,12 +128,29 @@ export class OrganizationsService { return rows; } + private static readonly MEMBER_LIMIT_PLANS = ['starter', 'standard', 'professional']; + private static readonly MAX_MEMBERS = 5; + async addMember( orgId: string, data: { email: string; firstName: string; lastName: string; password: string; role: string }, ) { const dataSource = this.orgRepository.manager.connection; + // Enforce member limit for starter and professional plans + const org = await this.orgRepository.findOne({ where: { id: orgId } }); + const planLevel = org?.planLevel || 'starter'; + if (OrganizationsService.MEMBER_LIMIT_PLANS.includes(planLevel)) { + const activeMemberCount = await this.userOrgRepository.count({ + where: { organizationId: orgId, isActive: true }, + }); + if (activeMemberCount >= OrganizationsService.MAX_MEMBERS) { + throw new BadRequestException( + `Your ${planLevel === 'starter' ? 'Starter' : 'Professional'} plan is limited to ${OrganizationsService.MAX_MEMBERS} user accounts. Please upgrade to Enterprise for unlimited members.`, + ); + } + } + // Check if user already exists let userRows = await dataSource.query( `SELECT id FROM shared.users WHERE email = $1`, @@ -179,7 +200,23 @@ export class OrganizationsService { organizationId: orgId, role: data.role, }); - return this.userOrgRepository.save(membership); + const saved = await this.userOrgRepository.save(membership); + + // Send welcome email to the new member + try { + const org = await this.orgRepository.findOne({ where: { id: orgId } }); + const orgName = org?.name || 'your organization'; + await this.emailService.sendNewMemberWelcomeEmail( + data.email, + data.firstName, + orgName, + ); + } catch (err) { + this.logger.warn(`Failed to send welcome email to ${data.email}: ${err}`); + // Don't fail the member addition if the email fails + } + + return saved; } async updateMemberRole(orgId: string, membershipId: string, role: string) { diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index e0c778e..8df2e58 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -1021,11 +1021,24 @@ export class ReportsService { let runOpInv = opInv; let runResInv = resInv; + // Determine which months have actual journal entries + // A month is "actual" only if it's not in the future AND has real journal entry data + const monthsWithActuals = new Set(); + for (const key of Object.keys(histIndex)) { + // histIndex keys are "year-month-fund_type", extract year-month + const parts = key.split('-'); + const ym = `${parts[0]}-${parts[1]}`; + monthsWithActuals.add(ym); + } + for (let i = 0; i < months; i++) { const year = startYear + Math.floor(i / 12); const month = (i % 12) + 1; const key = `${year}-${month}`; - const isHistorical = year < currentYear || (year === currentYear && month <= currentMonth); + // A month is historical (actual) only if it's in the past AND has journal entries + const isPastMonth = year < currentYear || (year === currentYear && month < currentMonth); + const hasActuals = monthsWithActuals.has(key); + const isHistorical = isPastMonth && hasActuals; const label = `${monthLabels[month - 1]} ${year}`; if (isHistorical) { diff --git a/frontend/package.json b/frontend/package.json index 809a6b7..1fb3b5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.17", + "version": "2026.3.19", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9085993..7785502 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -73,8 +73,9 @@ const navSections = [ label: 'Transactions', items: [ { label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' }, - { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, - { label: 'Payments', icon: IconCash, path: '/payments' }, + // Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement + // { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, + // { label: 'Payments', icon: IconCash, path: '/payments' }, ], }, { diff --git a/frontend/src/components/onboarding/OnboardingWizard.tsx b/frontend/src/components/onboarding/OnboardingWizard.tsx index 37cad41..fdf3c5d 100644 --- a/frontend/src/components/onboarding/OnboardingWizard.tsx +++ b/frontend/src/components/onboarding/OnboardingWizard.tsx @@ -9,8 +9,9 @@ import { notifications } from '@mantine/notifications'; import { IconBuildingBank, IconUsers, IconPlus, IconTrash, IconCheck, IconRocket, - IconAlertCircle, IconFileSpreadsheet, + IconAlertCircle, IconFileSpreadsheet, IconPigMoney, IconX, } from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; import api from '../../services/api'; import { useAuthStore } from '../../stores/authStore'; @@ -26,12 +27,13 @@ interface UnitRow { } export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) { + const navigate = useNavigate(); const [active, setActive] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const setOrgSettings = useAuthStore((s) => s.setOrgSettings); - // ── Step 1: Account State ── + // ── Step 1: Operating Account State ── const [accountCreated, setAccountCreated] = useState(false); const [accountName, setAccountName] = useState('Operating Checking'); const [accountNumber, setAccountNumber] = useState('1000'); @@ -39,7 +41,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) const [initialBalance, setInitialBalance] = useState(0); const [balanceDate, setBalanceDate] = useState(new Date()); - // ── Step 2: Assessment Group State ── + // ── Step 2: Reserve Account State ── + const [reserveCreated, setReserveCreated] = useState(false); + const [reserveSkipped, setReserveSkipped] = useState(false); + const [reserveName, setReserveName] = useState('Reserve Savings'); + const [reserveNumber, setReserveNumber] = useState('2000'); + const [reserveDescription, setReserveDescription] = useState(''); + const [reserveBalance, setReserveBalance] = useState(0); + const [reserveBalanceDate, setReserveBalanceDate] = useState(new Date()); + + // ── Step 3: Assessment Group State ── const [groupCreated, setGroupCreated] = useState(false); const [groupName, setGroupName] = useState('Standard Assessment'); const [regularAssessment, setRegularAssessment] = useState(0); @@ -48,7 +59,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) const [units, setUnits] = useState([]); const [unitsCreated, setUnitsCreated] = useState(false); - // ── Step 1: Create Account ── + // ── Step 1: Create Operating Account ── const handleCreateAccount = async () => { if (!accountName.trim()) { setError('Account name is required'); @@ -90,7 +101,53 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) } }; - // ── Step 2: Create Assessment Group ── + // ── Step 2: Create Reserve Account ── + const handleCreateReserve = async () => { + if (!reserveName.trim()) { + setError('Account name is required'); + return; + } + if (!reserveNumber.trim()) { + setError('Account number is required'); + return; + } + const balance = typeof reserveBalance === 'string' ? parseFloat(reserveBalance) : reserveBalance; + if (isNaN(balance)) { + setError('Initial balance must be a valid number'); + return; + } + + setLoading(true); + setError(null); + try { + await api.post('/accounts', { + accountNumber: reserveNumber.trim(), + name: reserveName.trim(), + description: reserveDescription.trim(), + accountType: 'asset', + fundType: 'reserve', + initialBalance: balance, + initialBalanceDate: reserveBalanceDate ? reserveBalanceDate.toISOString().split('T')[0] : undefined, + }); + setReserveCreated(true); + notifications.show({ + title: 'Reserve Account Created', + message: `${reserveName} has been created with an initial balance of $${balance.toLocaleString()}`, + color: 'green', + }); + } catch (err: any) { + const msg = err.response?.data?.message || 'Failed to create reserve account'; + setError(typeof msg === 'string' ? msg : JSON.stringify(msg)); + } finally { + setLoading(false); + } + }; + + const handleSkipReserve = () => { + setReserveSkipped(true); + }; + + // ── Step 3: Create Assessment Group ── const handleCreateGroup = async () => { if (!groupName.trim()) { setError('Group name is required'); @@ -154,16 +211,19 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) } }; - // ── Finish Wizard ── + // ── Finish Wizard → Navigate to Budget Planning ── const handleFinish = async () => { setLoading(true); try { await api.patch('/organizations/settings', { onboardingComplete: true }); setOrgSettings({ onboardingComplete: true }); onComplete(); + // Navigate to Budget Planning so user can set up their budget immediately + navigate('/board-planning/budgets'); } catch { // Even if API fails, close the wizard — onboarding data is already created onComplete(); + navigate('/board-planning/budgets'); } finally { setLoading(false); } @@ -187,13 +247,14 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) // ── Navigation ── const canGoNext = () => { if (active === 0) return accountCreated; - if (active === 1) return groupCreated; + if (active === 1) return reserveCreated || reserveSkipped; + if (active === 2) return groupCreated; return false; }; const nextStep = () => { setError(null); - if (active < 2) setActive(active + 1); + if (active < 3) setActive(active + 1); }; return ( @@ -227,10 +288,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) } completedIcon={} /> + } + completedIcon={reserveSkipped ? : } + /> )} - {/* ── Step 2: Assessment Group + Units ── */} + {/* ── Step 2: Reserve Account ── */} {active === 1 && ( + + + Set Up a Reserve Savings Account + + Most HOAs maintain a reserve fund for long-term capital projects like roof replacements, + paving, and major repairs. Setting this up now gives you a more complete financial picture + from the start. + + + {reserveCreated ? ( + } color="green" variant="light"> + {reserveName} created successfully! + + Initial balance: ${(typeof reserveBalance === 'number' ? reserveBalance : parseFloat(reserveBalance as string) || 0).toLocaleString()} + {reserveBalanceDate && ` as of ${reserveBalanceDate.toLocaleDateString()}`} + + + ) : reserveSkipped ? ( + } color="gray" variant="light"> + Reserve account skipped + + You can always add a reserve account later from the Accounts page. + + + ) : ( + <> + + setReserveName(e.currentTarget.value)} + required + /> + setReserveNumber(e.currentTarget.value)} + required + /> + +