Phase 7: Add user onboarding tour and tenant setup wizard

Feature 1 - How-To Intro Tour (react-joyride):
- 8-step guided walkthrough highlighting Dashboard, Accounts, Assessments,
  Transactions, Budgets, Reports, and AI Investment Planning
- Runs automatically on first login, tracked via has_seen_intro flag on user
- Centralized step config in config/tourSteps.ts for easy text editing
- data-tour attributes on Sidebar nav items and Dashboard for targeting

Feature 2 - Tenant Onboarding Wizard:
- 3-step modal wizard: create operating account, assessment group + units,
  import budget CSV
- Runs after tour completes, tracked via onboardingComplete in org settings JSONB
- Reuses existing API endpoints (POST /accounts, /assessment-groups, /units,
  /budgets/:year/import)

Backend changes:
- Add has_seen_intro column to shared.users + migration
- Add PATCH /auth/intro-seen endpoint to mark tour complete
- Add PATCH /organizations/settings endpoint for org settings updates
- Include hasSeenIntro in login response, settings in switch-org response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 09:47:45 -05:00
parent d1c40c633f
commit f1e66966f3
15 changed files with 4111 additions and 10 deletions

View File

@@ -1,6 +1,7 @@
import { import {
Controller, Controller,
Post, Post,
Patch,
Body, Body,
UseGuards, UseGuards,
Request, Request,
@@ -42,6 +43,15 @@ export class AuthController {
return this.authService.getProfile(req.user.sub); return this.authService.getProfile(req.user.sub);
} }
@Patch('intro-seen')
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async markIntroSeen(@Request() req: any) {
await this.authService.markIntroSeen(req.user.sub);
return { success: true };
}
@Post('switch-org') @Post('switch-org')
@ApiOperation({ summary: 'Switch active organization' }) @ApiOperation({ summary: 'Switch active organization' })
@ApiBearerAuth() @ApiBearerAuth()

View File

@@ -131,10 +131,15 @@ export class AuthService {
id: membership.organization.id, id: membership.organization.id,
name: membership.organization.name, name: membership.organization.name,
role: membership.role, role: membership.role,
settings: membership.organization.settings || {},
}, },
}; };
} }
async markIntroSeen(userId: string): Promise<void> {
await this.usersService.markIntroSeen(userId);
}
private async recordLoginHistory( private async recordLoginHistory(
userId: string, userId: string,
organizationId: string | null, organizationId: string | null,
@@ -185,6 +190,7 @@ export class AuthService {
lastName: user.lastName, lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false, isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false, isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
}, },
organizations: orgs.map((uo) => ({ organizations: orgs.map((uo) => ({
id: uo.organizationId, id: uo.organizationId,

View File

@@ -1,4 +1,4 @@
import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common'; import { Controller, Post, Get, Put, Patch, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service'; import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto';
@@ -23,6 +23,13 @@ export class OrganizationsController {
return this.orgService.findByUser(req.user.sub); return this.orgService.findByUser(req.user.sub);
} }
@Patch('settings')
@ApiOperation({ summary: 'Update settings for the current organization' })
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
this.requireTenantAdmin(req);
return this.orgService.updateSettings(req.user.orgId, body);
}
// ── Org Member Management ── // ── Org Member Management ──
private requireTenantAdmin(req: any) { private requireTenantAdmin(req: any) {

View File

@@ -78,6 +78,13 @@ export class OrganizationsService {
return this.orgRepository.save(org); return this.orgRepository.save(org);
} }
async updateSettings(id: string, settings: Record<string, any>) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new NotFoundException('Organization not found');
org.settings = { ...(org.settings || {}), ...settings };
return this.orgRepository.save(org);
}
async findByUser(userId: string) { async findByUser(userId: string) {
const memberships = await this.userOrgRepository.find({ const memberships = await this.userOrgRepository.find({
where: { userId, isActive: true }, where: { userId, isActive: true },

View File

@@ -49,6 +49,9 @@ export class User {
@Column({ name: 'is_platform_owner', default: false }) @Column({ name: 'is_platform_owner', default: false })
isPlatformOwner: boolean; isPlatformOwner: boolean;
@Column({ name: 'has_seen_intro', default: false })
hasSeenIntro: boolean;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt: Date; lastLoginAt: Date;

View File

@@ -57,6 +57,10 @@ export class UsersService {
`); `);
} }
async markIntroSeen(id: string): Promise<void> {
await this.usersRepository.update(id, { hasSeenIntro: true });
}
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> { async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
// Protect platform owner from having superadmin removed // Protect platform owner from having superadmin removed
const user = await this.usersRepository.findOne({ where: { id: userId } }); const user = await this.usersRepository.findOne({ where: { id: userId } });

View File

@@ -0,0 +1,9 @@
-- Migration: Add onboarding tracking flag to users table
-- Phase 7: Onboarding Features
BEGIN;
ALTER TABLE shared.users
ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
COMMIT;

3192
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core'; import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
@@ -9,17 +10,51 @@ import {
IconUsersGroup, IconUsersGroup,
IconEyeOff, IconEyeOff,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import logoSrc from '../../assets/logo.svg'; import logoSrc from '../../assets/logo.svg';
export function AppLayout() { export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure(); const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore(); const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const isImpersonating = !!impersonationOriginal; const isImpersonating = !!impersonationOriginal;
// ── Onboarding State ──
const [showTour, setShowTour] = useState(false);
const [showWizard, setShowWizard] = useState(false);
useEffect(() => {
// Only run for non-impersonating users with an org selected, on dashboard
if (isImpersonating || !currentOrg || !user) return;
if (!location.pathname.startsWith('/dashboard')) return;
if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) {
// Delay to ensure DOM elements are rendered for tour targeting
const timer = setTimeout(() => setShowTour(true), 800);
return () => clearTimeout(timer);
} else if (currentOrg.settings?.onboardingComplete !== true) {
setShowWizard(true);
}
}, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]);
const handleTourComplete = () => {
setShowTour(false);
// After tour, check if onboarding wizard should run
if (currentOrg && currentOrg.settings?.onboardingComplete !== true) {
// Small delay before showing wizard
setTimeout(() => setShowWizard(true), 500);
}
};
const handleWizardComplete = () => {
setShowWizard(false);
};
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
navigate('/login'); navigate('/login');
@@ -145,6 +180,10 @@ export function AppLayout() {
<AppShell.Main> <AppShell.Main>
<Outlet /> <Outlet />
</AppShell.Main> </AppShell.Main>
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
</AppShell> </AppShell>
); );
} }

View File

@@ -30,23 +30,23 @@ const navSections = [
{ {
label: 'Financials', label: 'Financials',
items: [ items: [
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' }, { label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' }, { label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' }, { label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' }, { label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
], ],
}, },
{ {
label: 'Assessments', label: 'Assessments',
items: [ items: [
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' }, { label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' }, { label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
], ],
}, },
{ {
label: 'Transactions', label: 'Transactions',
items: [ items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' }, { label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' }, { label: 'Payments', icon: IconCash, path: '/payments' },
], ],
@@ -56,7 +56,7 @@ const navSections = [
items: [ items: [
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' }, { label: 'Projects', icon: IconShieldCheck, path: '/projects' },
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' }, { label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning' }, { label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' }, { label: 'Vendors', icon: IconUsers, path: '/vendors' },
], ],
}, },
@@ -66,6 +66,7 @@ const navSections = [
{ {
label: 'Reports', label: 'Reports',
icon: IconChartSankey, icon: IconChartSankey,
tourId: 'nav-reports',
children: [ children: [
{ label: 'Balance Sheet', path: '/reports/balance-sheet' }, { label: 'Balance Sheet', path: '/reports/balance-sheet' },
{ label: 'Income Statement', path: '/reports/income-statement' }, { label: 'Income Statement', path: '/reports/income-statement' },
@@ -128,7 +129,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
} }
return ( return (
<ScrollArea p="sm"> <ScrollArea p="sm" data-tour="sidebar-nav">
{navSections.map((section, sIdx) => ( {navSections.map((section, sIdx) => (
<div key={sIdx}> <div key={sIdx}>
{section.label && ( {section.label && (
@@ -148,6 +149,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
defaultOpened={item.children.some((c: any) => defaultOpened={item.children.some((c: any) =>
location.pathname.startsWith(c.path), location.pathname.startsWith(c.path),
)} )}
data-tour={item.tourId || undefined}
> >
{item.children.map((child: any) => ( {item.children.map((child: any) => (
<NavLink <NavLink
@@ -165,6 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
leftSection={<item.icon size={18} />} leftSection={<item.icon size={18} />}
active={location.pathname === item.path} active={location.pathname === item.path}
onClick={() => go(item.path!)} onClick={() => go(item.path!)}
data-tour={item.tourId || undefined}
/> />
), ),
)} )}

View File

@@ -0,0 +1,93 @@
import { useState, useCallback } from 'react';
import Joyride, { type CallBackProps, STATUS, ACTIONS, EVENTS } from 'react-joyride';
import { TOUR_STEPS } from '../../config/tourSteps';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
interface AppTourProps {
run: boolean;
onComplete: () => void;
}
export function AppTour({ run, onComplete }: AppTourProps) {
const [stepIndex, setStepIndex] = useState(0);
const setUserIntroSeen = useAuthStore((s) => s.setUserIntroSeen);
const handleCallback = useCallback(
async (data: CallBackProps) => {
const { status, action, type } = data;
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
if (finishedStatuses.includes(status)) {
// Mark intro as seen on backend (fire-and-forget)
api.patch('/auth/intro-seen').catch(() => {});
setUserIntroSeen();
onComplete();
return;
}
// Handle step navigation
if (type === EVENTS.STEP_AFTER) {
setStepIndex((prev) =>
action === ACTIONS.PREV ? prev - 1 : prev + 1,
);
}
},
[onComplete, setUserIntroSeen],
);
if (!run) return null;
return (
<Joyride
steps={TOUR_STEPS}
run={run}
stepIndex={stepIndex}
continuous
showProgress
showSkipButton
scrollToFirstStep
disableOverlayClose
callback={handleCallback}
styles={{
options: {
primaryColor: '#228be6',
zIndex: 10000,
arrowColor: '#fff',
backgroundColor: '#fff',
textColor: '#333',
overlayColor: 'rgba(0, 0, 0, 0.5)',
},
tooltip: {
borderRadius: 8,
fontSize: 14,
padding: 20,
},
tooltipTitle: {
fontSize: 16,
fontWeight: 600,
},
buttonNext: {
borderRadius: 6,
fontSize: 14,
padding: '8px 16px',
},
buttonBack: {
borderRadius: 6,
fontSize: 14,
marginRight: 8,
},
buttonSkip: {
fontSize: 13,
},
}}
locale={{
back: 'Previous',
close: 'Close',
last: 'Finish Tour',
next: 'Next',
skip: 'Skip Tour',
}}
/>
);
}

View File

@@ -0,0 +1,646 @@
import { useState } from 'react';
import {
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconBuildingBank, IconUsers, IconFileSpreadsheet,
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
IconAlertCircle,
} from '@tabler/icons-react';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
interface OnboardingWizardProps {
opened: boolean;
onComplete: () => void;
}
interface UnitRow {
unitNumber: string;
ownerName: string;
ownerEmail: string;
}
// ── CSV Parsing (reused from BudgetsPage pattern) ──
function parseCSV(text: string): Record<string, string>[] {
const lines = text.split('\n').filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
return lines.slice(1).map((line) => {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes; }
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += char; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, i) => { row[h] = values[i] || ''; });
return row;
});
}
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
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 ──
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);
// ── Step 2: 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 [units, setUnits] = useState<UnitRow[]>([]);
const [unitsCreated, setUnitsCreated] = useState(false);
// ── Step 3: Budget State ──
const [budgetFile, setBudgetFile] = useState<File | null>(null);
const [budgetUploaded, setBudgetUploaded] = useState(false);
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
const currentYear = new Date().getFullYear();
// ── Step 1: Create 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,
});
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 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;
}
setLoading(true);
setError(null);
try {
const { data: group } = await api.post('/assessment-groups', {
name: groupName.trim(),
regularAssessment: assessment,
frequency,
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);
}
};
// ── Step 3: Budget Import ──
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${currentYear}/template`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `budget_template_${currentYear}.csv`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch {
notifications.show({
title: 'Error',
message: 'Failed to download template',
color: 'red',
});
}
};
const handleUploadBudget = async () => {
if (!budgetFile) {
setError('Please select a CSV file');
return;
}
setLoading(true);
setError(null);
try {
const text = await budgetFile.text();
const rows = parseCSV(text);
if (rows.length === 0) {
setError('CSV file appears to be empty or invalid');
setLoading(false);
return;
}
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
setBudgetUploaded(true);
setBudgetImportResult(data);
notifications.show({
title: 'Budget Imported',
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to import budget';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Finish Wizard ──
const handleFinish = async () => {
setLoading(true);
try {
await api.patch('/organizations/settings', { onboardingComplete: true });
setOrgSettings({ onboardingComplete: true });
onComplete();
} catch {
// Even if API fails, close the wizard — onboarding data is already created
onComplete();
} 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 groupCreated;
if (active === 2) return true; // Budget is optional
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="Set up your primary bank account"
icon={<IconBuildingBank size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Assessment Group"
description="Define homeowner assessments"
icon={<IconUsers size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Budget"
description="Import your annual budget"
icon={<IconFileSpreadsheet 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()}
</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}
/>
<NumberInput
label="Current Balance"
description="Enter the current balance of this bank account"
placeholder="0.00"
value={initialBalance}
onChange={setInitialBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
mb="md"
/>
<Button
onClick={handleCreateAccount}
loading={loading}
leftSection={<IconBuildingBank size={16} />}
>
Create Account
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 2: Assessment Group + Units ── */}
{active === 1 && (
<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={3} mb="md">
<TextInput
label="Group Name"
placeholder="e.g. Standard Assessment"
value={groupName}
onChange={(e) => setGroupName(e.currentTarget.value)}
required
/>
<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>
)}
{/* ── Step 3: Budget Upload ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
<Text size="sm" c="dimmed" mb="md">
Upload a CSV file with your annual budget. If you don&apos;t have one ready, you can download a template
or skip this step and set it up later from the Budgets page.
</Text>
{budgetUploaded ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>Budget imported successfully!</Text>
{budgetImportResult && (
<Text size="sm" c="dimmed">
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
</Text>
)}
</Alert>
) : (
<>
<Group mb="md">
<Button
variant="light"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download CSV Template
</Button>
</Group>
<FileInput
label="Upload Budget CSV"
placeholder="Click to select a .csv file"
accept=".csv"
value={budgetFile}
onChange={setBudgetFile}
mb="md"
leftSection={<IconFileSpreadsheet size={16} />}
/>
<Button
onClick={handleUploadBudget}
loading={loading}
leftSection={<IconFileSpreadsheet size={16} />}
disabled={!budgetFile}
>
Import Budget
</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. You can always update your accounts,
assessment groups, and budgets from the sidebar navigation.
</Text>
<SimpleGrid cols={3} mb="xl" maw={500} 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>
</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="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} />
</ThemeIcon>
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
{budgetUploaded ? 'Done' : 'Skipped'}
</Badge>
<Text size="xs" mt={4}>Budget</Text>
</Card>
</SimpleGrid>
<Button
size="lg"
onClick={handleFinish}
loading={loading}
leftSection={<IconRocket size={18} />}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Start Using LedgerIQ
</Button>
</Card>
)}
{/* ── Navigation Buttons ── */}
{active < 3 && (
<Group justify="flex-end" mt="xl">
{active === 2 && !budgetUploaded && (
<Button variant="subtle" onClick={nextStep}>
Skip for now
</Button>
)}
<Button
onClick={nextStep}
disabled={!canGoNext()}
>
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
</Button>
</Group>
)}
</Box>
</Modal>
);
}

View File

@@ -0,0 +1,68 @@
/**
* How-To Intro Tour Steps
*
* Centralized configuration for the react-joyride walkthrough.
* Edit the title and content fields below to change tour text.
* Steps are ordered to mirror the natural workflow of the platform.
*/
import type { Step } from 'react-joyride';
export const TOUR_STEPS: Step[] = [
{
target: '[data-tour="dashboard-content"]',
title: 'Your Financial Dashboard',
content:
'Welcome to LedgerIQ! This dashboard gives you an at-a-glance view of your HOA\'s financial health — operating funds, reserve funds, receivables, delinquencies, and recent transactions. It updates automatically as you record activity.',
placement: 'center',
disableBeacon: true,
},
{
target: '[data-tour="sidebar-nav"]',
title: 'Navigation',
content:
'The sidebar organizes all your tools into five sections: Financials, Assessments, Transactions, Planning, and Reports. Click any item to navigate directly to that module.',
placement: 'right',
},
{
target: '[data-tour="nav-accounts"]',
title: 'Chart of Accounts',
content:
'Manage your Chart of Accounts here. Set up operating and reserve fund bank accounts, track balances, record opening balances, and manage your investment accounts — all separated by fund type.',
placement: 'right',
},
{
target: '[data-tour="nav-assessment-groups"]',
title: 'Assessments & Homeowners',
content:
'Create assessment groups to define your monthly, quarterly, or annual HOA dues. Add homeowner units, assign them to groups, and generate invoices automatically based on your assessment schedule.',
placement: 'right',
},
{
target: '[data-tour="nav-transactions"]',
title: 'Transactions & Journal Entries',
content:
'Record all financial activity here through double-entry journal entries. The system also automatically creates entries when you record payments, generate invoices, or set opening balances.',
placement: 'right',
},
{
target: '[data-tour="nav-budgets"]',
title: 'Budget Management',
content:
'Create and manage annual budgets for every income and expense account. You can enter amounts manually by month or import your budget from a CSV file for quick setup.',
placement: 'right',
},
{
target: '[data-tour="nav-reports"]',
title: 'Financial Reports',
content:
'Generate comprehensive reports including Balance Sheet, Income Statement, Cash Flow Statement, Budget vs Actual, Aging Report, and more. All reports are generated in real-time from your journal data.',
placement: 'right',
},
{
target: '[data-tour="nav-investment-planning"]',
title: 'AI Investment Planning',
content:
'Use AI-powered recommendations to optimize your reserve fund investments. The system analyzes current market rates for CDs, money market accounts, and high-yield savings to suggest the best allocation strategy.',
placement: 'right',
},
];

View File

@@ -52,7 +52,7 @@ export function DashboardPage() {
}; };
return ( return (
<Stack> <Stack data-tour="dashboard-content">
<div> <div>
<Title order={2}>Dashboard</Title> <Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">

View File

@@ -7,6 +7,7 @@ interface Organization {
role: string; role: string;
schemaName?: string; schemaName?: string;
status?: string; status?: string;
settings?: Record<string, any>;
} }
interface User { interface User {
@@ -16,6 +17,7 @@ interface User {
lastName: string; lastName: string;
isSuperadmin?: boolean; isSuperadmin?: boolean;
isPlatformOwner?: boolean; isPlatformOwner?: boolean;
hasSeenIntro?: boolean;
} }
interface ImpersonationOriginal { interface ImpersonationOriginal {
@@ -33,6 +35,8 @@ interface AuthState {
impersonationOriginal: ImpersonationOriginal | null; impersonationOriginal: ImpersonationOriginal | null;
setAuth: (token: string, user: User, organizations: Organization[]) => void; setAuth: (token: string, user: User, organizations: Organization[]) => void;
setCurrentOrg: (org: Organization, token?: string) => void; setCurrentOrg: (org: Organization, token?: string) => void;
setUserIntroSeen: () => void;
setOrgSettings: (settings: Record<string, any>) => void;
startImpersonation: (token: string, user: User, organizations: Organization[]) => void; startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
stopImpersonation: () => void; stopImpersonation: () => void;
logout: () => void; logout: () => void;
@@ -59,6 +63,16 @@ export const useAuthStore = create<AuthState>()(
currentOrg: org, currentOrg: org,
token: token || state.token, token: token || state.token,
})), })),
setUserIntroSeen: () =>
set((state) => ({
user: state.user ? { ...state.user, hasSeenIntro: true } : null,
})),
setOrgSettings: (settings) =>
set((state) => ({
currentOrg: state.currentOrg
? { ...state.currentOrg, settings: { ...(state.currentOrg.settings || {}), ...settings } }
: null,
})),
startImpersonation: (token, user, organizations) => { startImpersonation: (token, user, organizations) => {
const state = get(); const state = get();
set({ set({
@@ -97,7 +111,7 @@ export const useAuthStore = create<AuthState>()(
}), }),
{ {
name: 'ledgeriq-auth', name: 'ledgeriq-auth',
version: 4, version: 5,
migrate: () => ({ migrate: () => ({
token: null, token: null,
user: null, user: null,