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 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 14:47:04 -04:00
parent db8b520009
commit 66e2f87a96
14 changed files with 482 additions and 41 deletions

22
PARKING-LOT.md Normal file
View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.3.19",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {

View File

@@ -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<void> {
const appUrl = this.configService.get<string>('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: `
<p>You've been added as a member of <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
<p>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.</p>
<p>HOA LedgerIQ gives you access to your community's financial dashboard, budgets, reports, and more.</p>
`,
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<void> {
const subject = 'Reset your HOA LedgerIQ password';
const html = this.buildTemplate({

View File

@@ -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<Organization>,
@InjectRepository(UserOrganization)
private userOrgRepository: Repository<UserOrganization>,
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) {

View File

@@ -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<string>();
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) {

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.3.19",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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' },
],
},
{

View File

@@ -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<string | null>(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<number | string>(0);
const [balanceDate, setBalanceDate] = useState<Date | null>(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<number | string>(0);
const [reserveBalanceDate, setReserveBalanceDate] = useState<Date | null>(new Date());
// ── Step 3: Assessment Group State ──
const [groupCreated, setGroupCreated] = useState(false);
const [groupName, setGroupName] = useState('Standard Assessment');
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
@@ -48,7 +59,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
const [units, setUnits] = useState<UnitRow[]>([]);
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)
<Stepper active={active} size="sm" mb="xl">
<Stepper.Step
label="Operating Account"
description="Set up your primary bank account"
description="Primary bank account"
icon={<IconBuildingBank size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Reserve Account"
description={reserveSkipped ? 'Skipped' : 'Savings account'}
icon={<IconPigMoney size={18} />}
completedIcon={reserveSkipped ? <IconX size={18} /> : <IconCheck size={18} />}
/>
<Stepper.Step
label="Assessment Group"
description="Define homeowner assessments"
@@ -322,8 +389,103 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Stack>
)}
{/* ── Step 2: Assessment Group + Units ── */}
{/* ── Step 2: Reserve Account ── */}
{active === 1 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Set Up a Reserve Savings Account</Text>
<Text size="sm" c="dimmed" mb="md">
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.
</Text>
{reserveCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{reserveName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof reserveBalance === 'number' ? reserveBalance : parseFloat(reserveBalance as string) || 0).toLocaleString()}
{reserveBalanceDate && ` as of ${reserveBalanceDate.toLocaleDateString()}`}
</Text>
</Alert>
) : reserveSkipped ? (
<Alert icon={<IconX size={16} />} color="gray" variant="light">
<Text fw={500}>Reserve account skipped</Text>
<Text size="sm" c="dimmed">
You can always add a reserve account later from the Accounts page.
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={2} mb="md">
<TextInput
label="Account Name"
placeholder="e.g. Reserve Savings"
value={reserveName}
onChange={(e) => setReserveName(e.currentTarget.value)}
required
/>
<TextInput
label="Account Number"
placeholder="e.g. 2000"
value={reserveNumber}
onChange={(e) => setReserveNumber(e.currentTarget.value)}
required
/>
</SimpleGrid>
<Textarea
label="Description"
placeholder="Optional description"
value={reserveDescription}
onChange={(e) => setReserveDescription(e.currentTarget.value)}
mb="md"
autosize
minRows={2}
/>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Current Balance"
description="Enter the current balance of this reserve account"
placeholder="0.00"
value={reserveBalance}
onChange={setReserveBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
/>
<DateInput
label="Balance As-Of Date"
description="Date this balance was accurate"
value={reserveBalanceDate}
onChange={setReserveBalanceDate}
maxDate={new Date()}
clearable={false}
/>
</SimpleGrid>
<Group>
<Button
onClick={handleCreateReserve}
loading={loading}
leftSection={<IconPigMoney size={16} />}
>
Create Reserve Account
</Button>
<Button
variant="subtle"
color="gray"
onClick={handleSkipReserve}
>
No Reserve Account
</Button>
</Group>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 3: Assessment Group + Units ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create an Assessment Group</Text>
@@ -458,23 +620,32 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
)}
{/* ── Completion Screen ── */}
{active === 2 && (
{active === 3 && (
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
<IconCheck size={32} />
</ThemeIcon>
<Title order={3} mb="xs">You&apos;re All Set!</Title>
<Text c="dimmed" mb="lg" maw={400} mx="auto">
Your organization is configured and ready to go. You can always update your accounts
and assessment groups from the sidebar navigation.
Your organization is configured and ready to go. The next step is to set up your annual
budget we&apos;ll take you straight to Budget Planning.
</Text>
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
<SimpleGrid cols={4} mb="xl" maw={600} mx="auto">
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconBuildingBank size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Account</Text>
<Text size="xs" mt={4}>Operating</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="violet" variant="light" radius="xl" mx="auto" mb={4}>
<IconPigMoney size={16} />
</ThemeIcon>
<Badge color={reserveSkipped ? 'gray' : 'green'} size="sm">
{reserveSkipped ? 'Skipped' : 'Done'}
</Badge>
<Text size="xs" mt={4}>Reserve</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
@@ -484,7 +655,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Text size="xs" mt={4}>Assessments</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<ThemeIcon size={32} color="cyan" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} />
</ThemeIcon>
<Badge color="cyan" size="sm">Up Next</Badge>
@@ -494,25 +665,26 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Alert icon={<IconFileSpreadsheet size={16} />} color="blue" variant="light" mb="lg" ta="left">
<Text size="sm" fw={500} mb={4}>Set Up Your Budget</Text>
<Text size="sm" c="dimmed">
Head to <Text span fw={600}>Budget Planning</Text> from the sidebar to download a CSV template,
fill in your monthly amounts, and upload your budget. You can do this at any time.
Your budget is critical for accurate financial health scores, cash flow forecasting,
and investment planning. Click below to go directly to Budget Planning where you can
download a CSV template, fill in your monthly amounts, and upload your budget.
</Text>
</Alert>
<Button
size="lg"
onClick={handleFinish}
loading={loading}
leftSection={<IconRocket size={18} />}
leftSection={<IconFileSpreadsheet size={18} />}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Start Using LedgerIQ
Set Up My Budget
</Button>
</Card>
)}
{/* ── Navigation Buttons ── */}
{active < 2 && (
{active < 3 && (
<Group justify="flex-end" mt="xl">
<Button
onClick={nextStep}

View File

@@ -1,7 +1,7 @@
import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
Popover, List,
Popover, List, Anchor,
} from '@mantine/core';
import {
IconCash,
@@ -18,6 +18,7 @@ import {
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api';
@@ -58,6 +59,28 @@ function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
return null;
}
// Map missing data items to navigation links
const missingDataLinks: Record<string, { label: string; path: string }> = {
'reserve fund account': { label: 'Set up a reserve account', path: '/accounts' },
'reserve account': { label: 'Set up a reserve account', path: '/accounts' },
'reserve projects': { label: 'Add reserve projects', path: '/projects' },
'capital projects': { label: 'Add capital projects', path: '/projects' },
'projects': { label: 'Add projects', path: '/projects' },
'budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'operating budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'reserve budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'assessment groups': { label: 'Create assessment groups', path: '/assessment-groups' },
'accounts': { label: 'Set up accounts', path: '/accounts' },
};
function getMissingDataLink(item: string): { label: string; path: string } | null {
const lower = item.toLowerCase();
for (const [key, value] of Object.entries(missingDataLinks)) {
if (lower.includes(key)) return value;
}
return null;
}
function HealthScoreCard({
score,
title,
@@ -65,6 +88,7 @@ function HealthScoreCard({
isRefreshing,
onRefresh,
lastFailed,
onNavigate,
}: {
score: HealthScore | null;
title: string;
@@ -72,6 +96,7 @@ function HealthScoreCard({
isRefreshing?: boolean;
onRefresh?: () => void;
lastFailed?: boolean;
onNavigate?: (path: string) => void;
}) {
// No score at all yet
if (!score) {
@@ -118,9 +143,19 @@ function HealthScoreCard({
<Stack align="center" gap="xs">
<Badge color="gray" variant="light" size="lg">Pending</Badge>
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
{missingItems.map((item: string, i: number) => (
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
))}
{missingItems.map((item: string, i: number) => {
const link = getMissingDataLink(item);
return link ? (
<Anchor key={i} size="xs" href={link.path} onClick={(e: React.MouseEvent) => {
e.preventDefault();
onNavigate?.(link.path);
}}>
{item} &rarr; {link.label}
</Anchor>
) : (
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
);
})}
</Stack>
</Center>
</Card>
@@ -315,6 +350,7 @@ export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient();
const navigate = useNavigate();
// Track whether a refresh is in progress (per score type) for async polling
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
@@ -429,6 +465,7 @@ export function DashboardPage() {
isRefreshing={operatingRefreshing}
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed}
onNavigate={navigate}
/>
<HealthScoreCard
score={healthScores?.reserve || null}
@@ -441,6 +478,7 @@ export function DashboardPage() {
isRefreshing={reserveRefreshing}
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed}
onNavigate={navigate}
/>
</SimpleGrid>

View File

@@ -559,6 +559,32 @@ export function InvestmentPlanningPage() {
}
}, []);
// Auto-refresh: if no recommendations exist or they are older than 30 days, trigger automatically
const autoRefreshTriggered = useRef(false);
useEffect(() => {
if (autoRefreshTriggered.current || isProcessing || isTriggering || isReadOnly) return;
if (savedRec === undefined) return; // still loading
const shouldAutoRefresh = (() => {
// No saved recommendation at all
if (!savedRec) return true;
// Error state with no cached data
if (savedRec.status === 'error' && (!savedRec.recommendations || savedRec.recommendations.length === 0)) return true;
// Recommendations older than 30 days
if (savedRec.created_at) {
const age = Date.now() - new Date(savedRec.created_at).getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (age > thirtyDays) return true;
}
return false;
})();
if (shouldAutoRefresh) {
autoRefreshTriggered.current = true;
handleTriggerAI();
}
}, [savedRec, isProcessing, isTriggering, isReadOnly, handleTriggerAI]);
// Build AI result from saved recommendation for display
const aiResult: AIResponse | null = hasResults
? {

View File

@@ -214,6 +214,13 @@ export function OrgMembersPage() {
As an organization administrator, you can add board members, property managers, and
viewers to give them access to this tenant. Each member can log in with their own
credentials and see the same financial data.
{currentOrg?.planLevel && !['enterprise'].includes(currentOrg.planLevel) && (
<Text size="sm" mt={6} fw={500}>
Your {currentOrg.planLevel === 'professional' ? 'Professional' : 'Starter'} plan
supports up to 5 user accounts ({activeMembers.length}/5 used).
{activeMembers.length >= 5 && ' Upgrade to Enterprise for unlimited members.'}
</Text>
)}
</Alert>
<Table striped highlightOnHover>

View File

@@ -2,13 +2,13 @@ import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
Card, SimpleGrid, Progress, Switch, Tooltip,
Card, SimpleGrid, Progress, Switch, Tooltip, ThemeIcon, List,
} 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, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, IconShieldCheck, IconBulb, IconRocket } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
@@ -465,10 +465,55 @@ export function ProjectsPage() {
))}
{projects.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg">
No projects yet
</Text>
<Table.Td colSpan={9} p={0}>
<Card p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'violet', to: 'blue' }} mx="auto" mb="md">
<IconShieldCheck size={32} />
</ThemeIcon>
<Title order={3} mb="xs">Capital Projects & Reserve Planning</Title>
<Text c="dimmed" maw={550} mx="auto" mb="lg">
Track your community&apos;s capital improvement projects, reserve fund allocations,
and long-term maintenance schedule. This is where you build a comprehensive
picture of your HOA&apos;s future capital needs.
</Text>
<Card withBorder p="md" maw={550} mx="auto" mb="lg" ta="left">
<Text fw={600} mb="xs">
<IconBulb size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />
Common HOA Projects to Get Started
</Text>
<List size="sm" spacing="xs" c="dimmed">
<List.Item><Text span fw={500} c="dark">Roof Replacement</Text> Track the remaining useful life and reserve funding for your building&apos;s roof</List.Item>
<List.Item><Text span fw={500} c="dark">Parking Lot / Paving</Text> Plan for periodic seal-coating and resurfacing</List.Item>
<List.Item><Text span fw={500} c="dark">Pool & Recreation</Text> Budget for pool resurfacing, equipment, and amenity upgrades</List.Item>
<List.Item><Text span fw={500} c="dark">Painting & Exterior</Text> Schedule exterior painting cycles (typically every 5-7 years)</List.Item>
<List.Item><Text span fw={500} c="dark">HVAC Systems</Text> Track common-area heating and cooling equipment lifecycles</List.Item>
<List.Item><Text span fw={500} c="dark">Elevator Modernization</Text> Plan for required elevator upgrades and code compliance</List.Item>
</List>
</Card>
<Group justify="center" gap="md">
{!isReadOnly && (
<>
<Button
size="md"
leftSection={<IconRocket size={18} />}
variant="gradient"
gradient={{ from: 'violet', to: 'blue' }}
onClick={handleNew}
>
Create Your First Project
</Button>
<Button
size="md"
variant="light"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
>
Import from CSV
</Button>
</>
)}
</Group>
</Card>
</Table.Td>
</Table.Tr>
)}

View File

@@ -1,13 +1,13 @@
import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, TextInput, Modal,
Switch, Badge, ActionIcon, Text, Loader, Center,
Switch, Badge, ActionIcon, Text, Loader, Center, Card, ThemeIcon, List,
} 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, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
@@ -153,7 +153,63 @@ export function VendorsPage() {
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
{filtered.length === 0 && vendors.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8} p={0}>
<Card p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'orange', to: 'yellow' }} mx="auto" mb="md">
<IconUsers size={32} />
</ThemeIcon>
<Title order={3} mb="xs">Vendor Management</Title>
<Text c="dimmed" maw={550} mx="auto" mb="lg">
Keep track of your HOA&apos;s service providers, contractors, and suppliers.
Having a centralized vendor directory helps with 1099 reporting, contract
renewal tracking, and comparing year-over-year spending.
</Text>
<Card withBorder p="md" maw={550} mx="auto" mb="lg" ta="left">
<Text fw={600} mb="xs">
<IconBulb size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />
Common HOA Vendors to Track
</Text>
<List size="sm" spacing="xs" c="dimmed">
<List.Item><Text span fw={500} c="dark">Landscaping Company</Text> Lawn care, tree trimming, seasonal planting</List.Item>
<List.Item><Text span fw={500} c="dark">Property Management</Text> Day-to-day management and tenant communications</List.Item>
<List.Item><Text span fw={500} c="dark">Insurance Provider</Text> Master policy for buildings and common areas</List.Item>
<List.Item><Text span fw={500} c="dark">Pool Maintenance</Text> Weekly chemical testing, cleaning, and equipment repair</List.Item>
<List.Item><Text span fw={500} c="dark">Snow Removal / Paving</Text> Winter plowing and parking lot maintenance</List.Item>
<List.Item><Text span fw={500} c="dark">Attorney / CPA</Text> Legal counsel and annual financial review</List.Item>
</List>
</Card>
<Group justify="center" gap="md">
{!isReadOnly && (
<>
<Button
size="md"
leftSection={<IconRocket size={18} />}
variant="gradient"
gradient={{ from: 'orange', to: 'yellow' }}
onClick={() => { setEditing(null); form.reset(); open(); }}
>
Add Your First Vendor
</Button>
<Button
size="md"
variant="light"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
>
Import from CSV
</Button>
</>
)}
</Group>
</Card>
</Table.Td>
</Table.Tr>
)}
{filtered.length === 0 && vendors.length > 0 && (
<Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors match your search</Text></Table.Td></Table.Tr>
)}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>

View File

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