Files
HOA_Financial_Platform/frontend/src/components/onboarding/OnboardingWizard.tsx
olsch01 66e2f87a96 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>
2026-03-18 14:47:04 -04:00

701 lines
27 KiB
TypeScript

import { useState } from 'react';
import {
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
Select, Stack, Text, Title, Alert, ActionIcon, Table,
Card, ThemeIcon, Divider, Badge, SimpleGrid, Box,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import {
IconBuildingBank, IconUsers,
IconPlus, IconTrash, IconCheck, IconRocket,
IconAlertCircle, IconFileSpreadsheet, IconPigMoney, IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
interface OnboardingWizardProps {
opened: boolean;
onComplete: () => void;
}
interface UnitRow {
unitNumber: string;
ownerName: string;
ownerEmail: string;
}
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: Operating Account State ──
const [accountCreated, setAccountCreated] = useState(false);
const [accountName, setAccountName] = useState('Operating Checking');
const [accountNumber, setAccountNumber] = useState('1000');
const [accountDescription, setAccountDescription] = useState('');
const [initialBalance, setInitialBalance] = useState<number | string>(0);
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
// ── 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);
const [frequency, setFrequency] = useState('monthly');
const [unitCount, setUnitCount] = useState<number | string>(0);
const [units, setUnits] = useState<UnitRow[]>([]);
const [unitsCreated, setUnitsCreated] = useState(false);
// ── Step 1: Create Operating Account ──
const handleCreateAccount = async () => {
if (!accountName.trim()) {
setError('Account name is required');
return;
}
if (!accountNumber.trim()) {
setError('Account number is required');
return;
}
const balance = typeof initialBalance === 'string' ? parseFloat(initialBalance) : initialBalance;
if (isNaN(balance)) {
setError('Initial balance must be a valid number');
return;
}
setLoading(true);
setError(null);
try {
await api.post('/accounts', {
accountNumber: accountNumber.trim(),
name: accountName.trim(),
description: accountDescription.trim(),
accountType: 'asset',
fundType: 'operating',
initialBalance: balance,
initialBalanceDate: balanceDate ? balanceDate.toISOString().split('T')[0] : undefined,
});
setAccountCreated(true);
notifications.show({
title: 'Account Created',
message: `${accountName} has been created with an initial balance of $${balance.toLocaleString()}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create account';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── 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');
return;
}
const assessment = typeof regularAssessment === 'string' ? parseFloat(regularAssessment) : regularAssessment;
if (isNaN(assessment) || assessment <= 0) {
setError('Assessment amount must be greater than zero');
return;
}
const count = typeof unitCount === 'string' ? parseInt(unitCount) : unitCount;
setLoading(true);
setError(null);
try {
const { data: group } = await api.post('/assessment-groups', {
name: groupName.trim(),
regularAssessment: assessment,
frequency,
unitCount: isNaN(count) ? 0 : count,
isDefault: true,
});
setGroupCreated(true);
// Create units if any were added
if (units.length > 0) {
let created = 0;
for (const unit of units) {
if (!unit.unitNumber.trim()) continue;
try {
await api.post('/units', {
unitNumber: unit.unitNumber.trim(),
ownerName: unit.ownerName.trim() || null,
ownerEmail: unit.ownerEmail.trim() || null,
assessmentGroupId: group.id,
});
created++;
} catch {
// Continue even if a unit fails
}
}
setUnitsCreated(true);
notifications.show({
title: 'Assessment Group Created',
message: `${groupName} created with ${created} unit(s)`,
color: 'green',
});
} else {
notifications.show({
title: 'Assessment Group Created',
message: `${groupName} created successfully`,
color: 'green',
});
}
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create assessment group';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── 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);
}
};
// ── Unit Rows ──
const addUnit = () => {
setUnits([...units, { unitNumber: '', ownerName: '', ownerEmail: '' }]);
};
const updateUnit = (index: number, field: keyof UnitRow, value: string) => {
const updated = [...units];
updated[index] = { ...updated[index], [field]: value };
setUnits(updated);
};
const removeUnit = (index: number) => {
setUnits(units.filter((_, i) => i !== index));
};
// ── Navigation ──
const canGoNext = () => {
if (active === 0) return accountCreated;
if (active === 1) return reserveCreated || reserveSkipped;
if (active === 2) return groupCreated;
return false;
};
const nextStep = () => {
setError(null);
if (active < 3) setActive(active + 1);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing without completing
withCloseButton={false}
size="xl"
centered
overlayProps={{ opacity: 0.6, blur: 3 }}
styles={{
body: { padding: 0 },
}}
>
{/* Header */}
<Box px="xl" pt="xl" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
<Group>
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
<IconRocket size={24} />
</ThemeIcon>
<div>
<Title order={3}>Set Up Your Organization</Title>
<Text c="dimmed" size="sm">
Let&apos;s get the essentials configured so you can start managing your HOA finances.
</Text>
</div>
</Group>
</Box>
<Box px="xl" py="lg">
<Stepper active={active} size="sm" mb="xl">
<Stepper.Step
label="Operating 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"
icon={<IconUsers size={18} />}
completedIcon={<IconCheck size={18} />}
/>
</Stepper>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md" withCloseButton onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* ── Step 1: Create Operating Account ── */}
{active === 0 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create Your Primary Operating Account</Text>
<Text size="sm" c="dimmed" mb="md">
This is your HOA&apos;s main bank account for day-to-day operations. You can add more accounts later.
</Text>
{accountCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{accountName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
{balanceDate && ` as of ${balanceDate.toLocaleDateString()}`}
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={2} mb="md">
<TextInput
label="Account Name"
placeholder="e.g. Operating Checking"
value={accountName}
onChange={(e) => setAccountName(e.currentTarget.value)}
required
/>
<TextInput
label="Account Number"
placeholder="e.g. 1000"
value={accountNumber}
onChange={(e) => setAccountNumber(e.currentTarget.value)}
required
/>
</SimpleGrid>
<Textarea
label="Description"
placeholder="Optional description"
value={accountDescription}
onChange={(e) => setAccountDescription(e.currentTarget.value)}
mb="md"
autosize
minRows={2}
/>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Current Balance"
description="Enter the current balance of this bank account"
placeholder="0.00"
value={initialBalance}
onChange={setInitialBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
/>
<DateInput
label="Balance As-Of Date"
description="Date this balance was accurate (e.g. last statement date)"
value={balanceDate}
onChange={setBalanceDate}
maxDate={new Date()}
clearable={false}
/>
</SimpleGrid>
<Button
onClick={handleCreateAccount}
loading={loading}
leftSection={<IconBuildingBank size={16} />}
>
Create Account
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── 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>
<Text size="sm" c="dimmed" mb="md">
Assessment groups define how much each homeowner pays and how often. You can create additional groups later for different unit types.
</Text>
{groupCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{groupName} created successfully!</Text>
<Text size="sm" c="dimmed">
${(typeof regularAssessment === 'number' ? regularAssessment : parseFloat(regularAssessment as string) || 0).toLocaleString()} {frequency}
{unitsCreated && ` with ${units.length} unit(s)`}
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={2} mb="md">
<TextInput
label="Group Name"
placeholder="e.g. Standard Assessment"
value={groupName}
onChange={(e) => setGroupName(e.currentTarget.value)}
required
/>
<NumberInput
label="Total Unit Count"
description="How many units/lots does your community have?"
placeholder="e.g. 50"
value={unitCount}
onChange={setUnitCount}
min={0}
required
/>
</SimpleGrid>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Assessment Amount"
placeholder="0.00"
value={regularAssessment}
onChange={setRegularAssessment}
thousandSeparator=","
prefix="$"
decimalScale={2}
required
/>
<Select
label="Frequency"
value={frequency}
onChange={(v) => setFrequency(v || 'monthly')}
data={[
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' },
]}
/>
</SimpleGrid>
<Divider my="md" label="Add Homeowner Units (Optional)" labelPosition="center" />
{units.length > 0 && (
<Table mb="md" striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Unit Number</Table.Th>
<Table.Th>Owner Name</Table.Th>
<Table.Th>Owner Email</Table.Th>
<Table.Th w={40}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((unit, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<TextInput
size="xs"
placeholder="e.g. 101"
value={unit.unitNumber}
onChange={(e) => updateUnit(idx, 'unitNumber', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="John Smith"
value={unit.ownerName}
onChange={(e) => updateUnit(idx, 'ownerName', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="john@example.com"
value={unit.ownerEmail}
onChange={(e) => updateUnit(idx, 'ownerEmail', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<ActionIcon color="red" variant="subtle" size="sm" onClick={() => removeUnit(idx)}>
<IconTrash size={14} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group mb="md">
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={addUnit}
>
Add Unit
</Button>
<Text size="xs" c="dimmed">You can also import units in bulk later from the Units page.</Text>
</Group>
<Button
onClick={handleCreateGroup}
loading={loading}
leftSection={<IconUsers size={16} />}
>
Create Assessment Group
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Completion Screen ── */}
{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. The next step is to set up your annual
budget we&apos;ll take you straight to Budget Planning.
</Text>
<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}>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}>
<IconUsers size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Assessments</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<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>
<Text size="xs" mt={4}>Budget</Text>
</Card>
</SimpleGrid>
<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">
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={<IconFileSpreadsheet size={18} />}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Set Up My Budget
</Button>
</Card>
)}
{/* ── Navigation Buttons ── */}
{active < 3 && (
<Group justify="flex-end" mt="xl">
<Button
onClick={nextStep}
disabled={!canGoNext()}
>
Next Step
</Button>
</Group>
)}
</Box>
</Modal>
);
}