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:
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
9
db/migrations/009-onboarding-flags.sql
Normal file
9
db/migrations/009-onboarding-flags.sql
Normal 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
3192
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
93
frontend/src/components/onboarding/AppTour.tsx
Normal file
93
frontend/src/components/onboarding/AppTour.tsx
Normal 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal 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'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'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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/config/tourSteps.ts
Normal file
68
frontend/src/config/tourSteps.ts
Normal 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',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user