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:
22
PARKING-LOT.md
Normal file
22
PARKING-LOT.md
Normal 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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "2026.3.17",
|
||||
"version": "2026.3.19",
|
||||
"description": "HOA LedgerIQ - Backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.17",
|
||||
"version": "2026.3.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'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'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}
|
||||
|
||||
@@ -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} → {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>
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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's capital improvement projects, reserve fund allocations,
|
||||
and long-term maintenance schedule. This is where you build a comprehensive
|
||||
picture of your HOA'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'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>
|
||||
)}
|
||||
|
||||
62
frontend/src/pages/vendors/VendorsPage.tsx
vendored
62
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -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'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'}>
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Organization {
|
||||
name: string;
|
||||
role: string;
|
||||
status?: string;
|
||||
planLevel?: string;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user