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,3 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
@@ -9,17 +10,51 @@ import {
|
||||
IconUsersGroup,
|
||||
IconEyeOff,
|
||||
} 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 { Sidebar } from './Sidebar';
|
||||
import { AppTour } from '../onboarding/AppTour';
|
||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||
import logoSrc from '../../assets/logo.svg';
|
||||
|
||||
export function AppLayout() {
|
||||
const [opened, { toggle, close }] = useDisclosure();
|
||||
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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 = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
@@ -145,6 +180,10 @@ export function AppLayout() {
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
|
||||
{/* ── Onboarding Components ── */}
|
||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,23 +30,23 @@ const navSections = [
|
||||
{
|
||||
label: 'Financials',
|
||||
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: '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',
|
||||
items: [
|
||||
{ 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',
|
||||
items: [
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||
],
|
||||
@@ -56,7 +56,7 @@ const navSections = [
|
||||
items: [
|
||||
{ label: 'Projects', icon: IconShieldCheck, path: '/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' },
|
||||
],
|
||||
},
|
||||
@@ -66,6 +66,7 @@ const navSections = [
|
||||
{
|
||||
label: 'Reports',
|
||||
icon: IconChartSankey,
|
||||
tourId: 'nav-reports',
|
||||
children: [
|
||||
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
||||
{ label: 'Income Statement', path: '/reports/income-statement' },
|
||||
@@ -128,7 +129,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea p="sm">
|
||||
<ScrollArea p="sm" data-tour="sidebar-nav">
|
||||
{navSections.map((section, sIdx) => (
|
||||
<div key={sIdx}>
|
||||
{section.label && (
|
||||
@@ -148,6 +149,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
defaultOpened={item.children.some((c: any) =>
|
||||
location.pathname.startsWith(c.path),
|
||||
)}
|
||||
data-tour={item.tourId || undefined}
|
||||
>
|
||||
{item.children.map((child: any) => (
|
||||
<NavLink
|
||||
@@ -165,6 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
leftSection={<item.icon size={18} />}
|
||||
active={location.pathname === 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 (
|
||||
<Stack>
|
||||
<Stack data-tour="dashboard-content">
|
||||
<div>
|
||||
<Title order={2}>Dashboard</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
|
||||
@@ -7,6 +7,7 @@ interface Organization {
|
||||
role: string;
|
||||
schemaName?: string;
|
||||
status?: string;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -16,6 +17,7 @@ interface User {
|
||||
lastName: string;
|
||||
isSuperadmin?: boolean;
|
||||
isPlatformOwner?: boolean;
|
||||
hasSeenIntro?: boolean;
|
||||
}
|
||||
|
||||
interface ImpersonationOriginal {
|
||||
@@ -33,6 +35,8 @@ interface AuthState {
|
||||
impersonationOriginal: ImpersonationOriginal | null;
|
||||
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
||||
setCurrentOrg: (org: Organization, token?: string) => void;
|
||||
setUserIntroSeen: () => void;
|
||||
setOrgSettings: (settings: Record<string, any>) => void;
|
||||
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
||||
stopImpersonation: () => void;
|
||||
logout: () => void;
|
||||
@@ -59,6 +63,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
currentOrg: org,
|
||||
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) => {
|
||||
const state = get();
|
||||
set({
|
||||
@@ -97,7 +111,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}),
|
||||
{
|
||||
name: 'ledgeriq-auth',
|
||||
version: 4,
|
||||
version: 5,
|
||||
migrate: () => ({
|
||||
token: null,
|
||||
user: null,
|
||||
|
||||
Reference in New Issue
Block a user