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",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ export class EmailService {
|
|||||||
await this.send(email, subject, html, 'trial_expired', { businessName });
|
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> {
|
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
|
||||||
const subject = 'Reset your HOA LedgerIQ password';
|
const subject = 'Reset your HOA LedgerIQ password';
|
||||||
const html = this.buildTemplate({
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Organization } from './entities/organization.entity';
|
import { Organization } from './entities/organization.entity';
|
||||||
import { UserOrganization } from './entities/user-organization.entity';
|
import { UserOrganization } from './entities/user-organization.entity';
|
||||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
|
import { EmailService } from '../email/email.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsService {
|
export class OrganizationsService {
|
||||||
|
private readonly logger = new Logger(OrganizationsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Organization)
|
@InjectRepository(Organization)
|
||||||
private orgRepository: Repository<Organization>,
|
private orgRepository: Repository<Organization>,
|
||||||
@InjectRepository(UserOrganization)
|
@InjectRepository(UserOrganization)
|
||||||
private userOrgRepository: Repository<UserOrganization>,
|
private userOrgRepository: Repository<UserOrganization>,
|
||||||
private tenantSchemaService: TenantSchemaService,
|
private tenantSchemaService: TenantSchemaService,
|
||||||
|
private emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateOrganizationDto, userId: string) {
|
async create(dto: CreateOrganizationDto, userId: string) {
|
||||||
@@ -124,12 +128,29 @@ export class OrganizationsService {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly MEMBER_LIMIT_PLANS = ['starter', 'standard', 'professional'];
|
||||||
|
private static readonly MAX_MEMBERS = 5;
|
||||||
|
|
||||||
async addMember(
|
async addMember(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
data: { email: string; firstName: string; lastName: string; password: string; role: string },
|
data: { email: string; firstName: string; lastName: string; password: string; role: string },
|
||||||
) {
|
) {
|
||||||
const dataSource = this.orgRepository.manager.connection;
|
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
|
// Check if user already exists
|
||||||
let userRows = await dataSource.query(
|
let userRows = await dataSource.query(
|
||||||
`SELECT id FROM shared.users WHERE email = $1`,
|
`SELECT id FROM shared.users WHERE email = $1`,
|
||||||
@@ -179,7 +200,23 @@ export class OrganizationsService {
|
|||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
role: data.role,
|
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) {
|
async updateMemberRole(orgId: string, membershipId: string, role: string) {
|
||||||
|
|||||||
@@ -1021,11 +1021,24 @@ export class ReportsService {
|
|||||||
let runOpInv = opInv;
|
let runOpInv = opInv;
|
||||||
let runResInv = resInv;
|
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++) {
|
for (let i = 0; i < months; i++) {
|
||||||
const year = startYear + Math.floor(i / 12);
|
const year = startYear + Math.floor(i / 12);
|
||||||
const month = (i % 12) + 1;
|
const month = (i % 12) + 1;
|
||||||
const key = `${year}-${month}`;
|
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}`;
|
const label = `${monthLabels[month - 1]} ${year}`;
|
||||||
|
|
||||||
if (isHistorical) {
|
if (isHistorical) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ const navSections = [
|
|||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
|
||||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||||
|
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import {
|
import {
|
||||||
IconBuildingBank, IconUsers,
|
IconBuildingBank, IconUsers,
|
||||||
IconPlus, IconTrash, IconCheck, IconRocket,
|
IconPlus, IconTrash, IconCheck, IconRocket,
|
||||||
IconAlertCircle, IconFileSpreadsheet,
|
IconAlertCircle, IconFileSpreadsheet, IconPigMoney, IconX,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -26,12 +27,13 @@ interface UnitRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
|
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
|
||||||
|
|
||||||
// ── Step 1: Account State ──
|
// ── Step 1: Operating Account State ──
|
||||||
const [accountCreated, setAccountCreated] = useState(false);
|
const [accountCreated, setAccountCreated] = useState(false);
|
||||||
const [accountName, setAccountName] = useState('Operating Checking');
|
const [accountName, setAccountName] = useState('Operating Checking');
|
||||||
const [accountNumber, setAccountNumber] = useState('1000');
|
const [accountNumber, setAccountNumber] = useState('1000');
|
||||||
@@ -39,7 +41,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
||||||
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
|
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 [groupCreated, setGroupCreated] = useState(false);
|
||||||
const [groupName, setGroupName] = useState('Standard Assessment');
|
const [groupName, setGroupName] = useState('Standard Assessment');
|
||||||
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
||||||
@@ -48,7 +59,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
const [units, setUnits] = useState<UnitRow[]>([]);
|
const [units, setUnits] = useState<UnitRow[]>([]);
|
||||||
const [unitsCreated, setUnitsCreated] = useState(false);
|
const [unitsCreated, setUnitsCreated] = useState(false);
|
||||||
|
|
||||||
// ── Step 1: Create Account ──
|
// ── Step 1: Create Operating Account ──
|
||||||
const handleCreateAccount = async () => {
|
const handleCreateAccount = async () => {
|
||||||
if (!accountName.trim()) {
|
if (!accountName.trim()) {
|
||||||
setError('Account name is required');
|
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 () => {
|
const handleCreateGroup = async () => {
|
||||||
if (!groupName.trim()) {
|
if (!groupName.trim()) {
|
||||||
setError('Group name is required');
|
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 () => {
|
const handleFinish = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.patch('/organizations/settings', { onboardingComplete: true });
|
await api.patch('/organizations/settings', { onboardingComplete: true });
|
||||||
setOrgSettings({ onboardingComplete: true });
|
setOrgSettings({ onboardingComplete: true });
|
||||||
onComplete();
|
onComplete();
|
||||||
|
// Navigate to Budget Planning so user can set up their budget immediately
|
||||||
|
navigate('/board-planning/budgets');
|
||||||
} catch {
|
} catch {
|
||||||
// Even if API fails, close the wizard — onboarding data is already created
|
// Even if API fails, close the wizard — onboarding data is already created
|
||||||
onComplete();
|
onComplete();
|
||||||
|
navigate('/board-planning/budgets');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -187,13 +247,14 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
// ── Navigation ──
|
// ── Navigation ──
|
||||||
const canGoNext = () => {
|
const canGoNext = () => {
|
||||||
if (active === 0) return accountCreated;
|
if (active === 0) return accountCreated;
|
||||||
if (active === 1) return groupCreated;
|
if (active === 1) return reserveCreated || reserveSkipped;
|
||||||
|
if (active === 2) return groupCreated;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (active < 2) setActive(active + 1);
|
if (active < 3) setActive(active + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -227,10 +288,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
<Stepper active={active} size="sm" mb="xl">
|
<Stepper active={active} size="sm" mb="xl">
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
label="Operating Account"
|
label="Operating Account"
|
||||||
description="Set up your primary bank account"
|
description="Primary bank account"
|
||||||
icon={<IconBuildingBank size={18} />}
|
icon={<IconBuildingBank size={18} />}
|
||||||
completedIcon={<IconCheck 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
|
<Stepper.Step
|
||||||
label="Assessment Group"
|
label="Assessment Group"
|
||||||
description="Define homeowner assessments"
|
description="Define homeowner assessments"
|
||||||
@@ -322,8 +389,103 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Step 2: Assessment Group + Units ── */}
|
{/* ── Step 2: Reserve Account ── */}
|
||||||
{active === 1 && (
|
{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">
|
<Stack gap="md">
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg">
|
||||||
<Text fw={600} mb="xs">Create an Assessment Group</Text>
|
<Text fw={600} mb="xs">Create an Assessment Group</Text>
|
||||||
@@ -458,23 +620,32 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Completion Screen ── */}
|
{/* ── Completion Screen ── */}
|
||||||
{active === 2 && (
|
{active === 3 && (
|
||||||
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
||||||
<IconCheck size={32} />
|
<IconCheck size={32} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Title order={3} mb="xs">You're All Set!</Title>
|
<Title order={3} mb="xs">You're All Set!</Title>
|
||||||
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
||||||
Your organization is configured and ready to go. You can always update your accounts
|
Your organization is configured and ready to go. The next step is to set up your annual
|
||||||
and assessment groups from the sidebar navigation.
|
budget — we'll take you straight to Budget Planning.
|
||||||
</Text>
|
</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' }}>
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
<IconBuildingBank size={16} />
|
<IconBuildingBank size={16} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Badge color="green" size="sm">Done</Badge>
|
<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>
|
||||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
<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>
|
<Text size="xs" mt={4}>Assessments</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
<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} />
|
<IconFileSpreadsheet size={16} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Badge color="cyan" size="sm">Up Next</Badge>
|
<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">
|
<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" fw={500} mb={4}>Set Up Your Budget</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Head to <Text span fw={600}>Budget Planning</Text> from the sidebar to download a CSV template,
|
Your budget is critical for accurate financial health scores, cash flow forecasting,
|
||||||
fill in your monthly amounts, and upload your budget. You can do this at any time.
|
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>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
leftSection={<IconRocket size={18} />}
|
leftSection={<IconFileSpreadsheet size={18} />}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{ from: 'blue', to: 'cyan' }}
|
gradient={{ from: 'blue', to: 'cyan' }}
|
||||||
>
|
>
|
||||||
Start Using LedgerIQ
|
Set Up My Budget
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Navigation Buttons ── */}
|
{/* ── Navigation Buttons ── */}
|
||||||
{active < 2 && (
|
{active < 3 && (
|
||||||
<Group justify="flex-end" mt="xl">
|
<Group justify="flex-end" mt="xl">
|
||||||
<Button
|
<Button
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||||
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
|
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
|
||||||
Popover, List,
|
Popover, List, Anchor,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCash,
|
IconCash,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
@@ -58,6 +59,28 @@ function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
|
|||||||
return 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({
|
function HealthScoreCard({
|
||||||
score,
|
score,
|
||||||
title,
|
title,
|
||||||
@@ -65,6 +88,7 @@ function HealthScoreCard({
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
lastFailed,
|
lastFailed,
|
||||||
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
score: HealthScore | null;
|
score: HealthScore | null;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -72,6 +96,7 @@ function HealthScoreCard({
|
|||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
lastFailed?: boolean;
|
lastFailed?: boolean;
|
||||||
|
onNavigate?: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
// No score at all yet
|
// No score at all yet
|
||||||
if (!score) {
|
if (!score) {
|
||||||
@@ -118,9 +143,19 @@ function HealthScoreCard({
|
|||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
||||||
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
|
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
|
||||||
{missingItems.map((item: string, i: number) => (
|
{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>
|
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -315,6 +350,7 @@ export function DashboardPage() {
|
|||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Track whether a refresh is in progress (per score type) for async polling
|
// Track whether a refresh is in progress (per score type) for async polling
|
||||||
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
|
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
|
||||||
@@ -429,6 +465,7 @@ export function DashboardPage() {
|
|||||||
isRefreshing={operatingRefreshing}
|
isRefreshing={operatingRefreshing}
|
||||||
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
|
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
|
||||||
lastFailed={!!healthScores?.operating_last_failed}
|
lastFailed={!!healthScores?.operating_last_failed}
|
||||||
|
onNavigate={navigate}
|
||||||
/>
|
/>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.reserve || null}
|
score={healthScores?.reserve || null}
|
||||||
@@ -441,6 +478,7 @@ export function DashboardPage() {
|
|||||||
isRefreshing={reserveRefreshing}
|
isRefreshing={reserveRefreshing}
|
||||||
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
|
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
|
||||||
lastFailed={!!healthScores?.reserve_last_failed}
|
lastFailed={!!healthScores?.reserve_last_failed}
|
||||||
|
onNavigate={navigate}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</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
|
// Build AI result from saved recommendation for display
|
||||||
const aiResult: AIResponse | null = hasResults
|
const aiResult: AIResponse | null = hasResults
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -214,6 +214,13 @@ export function OrgMembersPage() {
|
|||||||
As an organization administrator, you can add board members, property managers, and
|
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
|
viewers to give them access to this tenant. Each member can log in with their own
|
||||||
credentials and see the same financial data.
|
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>
|
</Alert>
|
||||||
|
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useState, useRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
||||||
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
||||||
Card, SimpleGrid, Progress, Switch, Tooltip,
|
Card, SimpleGrid, Progress, Switch, Tooltip, ThemeIcon, List,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { DateInput } from '@mantine/dates';
|
import { DateInput } from '@mantine/dates';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
@@ -465,10 +465,55 @@ export function ProjectsPage() {
|
|||||||
))}
|
))}
|
||||||
{projects.length === 0 && (
|
{projects.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={9}>
|
<Table.Td colSpan={9} p={0}>
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
<Card p="xl" style={{ textAlign: 'center' }}>
|
||||||
No projects yet
|
<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>
|
</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.Td>
|
||||||
</Table.Tr>
|
</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 { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
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';
|
} from '@mantine/core';
|
||||||
import { DateInput } from '@mantine/dates';
|
import { DateInput } from '@mantine/dates';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
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.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
|
||||||
</Table.Tr>
|
</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.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface Organization {
|
|||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
planLevel?: string;
|
||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user