feat: UX enhancements, member limits, forecast fix, and menu cleanup (v2026.3.19)
- Onboarding wizard: add Reserve Account step between Operating and Assessments, redirect to Budget Planning on completion - Dashboard: health score pending state shows clickable links to set up missing items - Projects/Vendors: rich empty-state wizard screens with real-world examples and CTAs - Investment Planning: auto-refresh AI recommendations when empty or stale (>30 days) - Hide Invoices and Payments menus (see PARKING-LOT.md for re-enablement) - Send welcome email via Resend when new members are added to a tenant - Enforce 5-member limit for Starter/Standard/Professional plans (Enterprise unlimited) - Cash flow forecast: only mark months as "Actual" when journal entries exist, fixing the issue where months without data showed as actuals - Bump version to 2026.3.19 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.17",
|
||||
"version": "2026.3.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -73,8 +73,9 @@ const navSections = [
|
||||
label: 'Transactions',
|
||||
items: [
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
|
||||
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,8 +9,9 @@ import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBuildingBank, IconUsers,
|
||||
IconPlus, IconTrash, IconCheck, IconRocket,
|
||||
IconAlertCircle, IconFileSpreadsheet,
|
||||
IconAlertCircle, IconFileSpreadsheet, IconPigMoney, IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
@@ -26,12 +27,13 @@ interface UnitRow {
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
||||
const navigate = useNavigate();
|
||||
const [active, setActive] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
|
||||
|
||||
// ── Step 1: Account State ──
|
||||
// ── Step 1: Operating Account State ──
|
||||
const [accountCreated, setAccountCreated] = useState(false);
|
||||
const [accountName, setAccountName] = useState('Operating Checking');
|
||||
const [accountNumber, setAccountNumber] = useState('1000');
|
||||
@@ -39,7 +41,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
||||
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
|
||||
|
||||
// ── Step 2: Assessment Group State ──
|
||||
// ── Step 2: Reserve Account State ──
|
||||
const [reserveCreated, setReserveCreated] = useState(false);
|
||||
const [reserveSkipped, setReserveSkipped] = useState(false);
|
||||
const [reserveName, setReserveName] = useState('Reserve Savings');
|
||||
const [reserveNumber, setReserveNumber] = useState('2000');
|
||||
const [reserveDescription, setReserveDescription] = useState('');
|
||||
const [reserveBalance, setReserveBalance] = useState<number | string>(0);
|
||||
const [reserveBalanceDate, setReserveBalanceDate] = useState<Date | null>(new Date());
|
||||
|
||||
// ── Step 3: Assessment Group State ──
|
||||
const [groupCreated, setGroupCreated] = useState(false);
|
||||
const [groupName, setGroupName] = useState('Standard Assessment');
|
||||
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
||||
@@ -48,7 +59,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
const [units, setUnits] = useState<UnitRow[]>([]);
|
||||
const [unitsCreated, setUnitsCreated] = useState(false);
|
||||
|
||||
// ── Step 1: Create Account ──
|
||||
// ── Step 1: Create Operating Account ──
|
||||
const handleCreateAccount = async () => {
|
||||
if (!accountName.trim()) {
|
||||
setError('Account name is required');
|
||||
@@ -90,7 +101,53 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 2: Create Assessment Group ──
|
||||
// ── Step 2: Create Reserve Account ──
|
||||
const handleCreateReserve = async () => {
|
||||
if (!reserveName.trim()) {
|
||||
setError('Account name is required');
|
||||
return;
|
||||
}
|
||||
if (!reserveNumber.trim()) {
|
||||
setError('Account number is required');
|
||||
return;
|
||||
}
|
||||
const balance = typeof reserveBalance === 'string' ? parseFloat(reserveBalance) : reserveBalance;
|
||||
if (isNaN(balance)) {
|
||||
setError('Initial balance must be a valid number');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post('/accounts', {
|
||||
accountNumber: reserveNumber.trim(),
|
||||
name: reserveName.trim(),
|
||||
description: reserveDescription.trim(),
|
||||
accountType: 'asset',
|
||||
fundType: 'reserve',
|
||||
initialBalance: balance,
|
||||
initialBalanceDate: reserveBalanceDate ? reserveBalanceDate.toISOString().split('T')[0] : undefined,
|
||||
});
|
||||
setReserveCreated(true);
|
||||
notifications.show({
|
||||
title: 'Reserve Account Created',
|
||||
message: `${reserveName} has been created with an initial balance of $${balance.toLocaleString()}`,
|
||||
color: 'green',
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || 'Failed to create reserve account';
|
||||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipReserve = () => {
|
||||
setReserveSkipped(true);
|
||||
};
|
||||
|
||||
// ── Step 3: Create Assessment Group ──
|
||||
const handleCreateGroup = async () => {
|
||||
if (!groupName.trim()) {
|
||||
setError('Group name is required');
|
||||
@@ -154,16 +211,19 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
}
|
||||
};
|
||||
|
||||
// ── Finish Wizard ──
|
||||
// ── Finish Wizard → Navigate to Budget Planning ──
|
||||
const handleFinish = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.patch('/organizations/settings', { onboardingComplete: true });
|
||||
setOrgSettings({ onboardingComplete: true });
|
||||
onComplete();
|
||||
// Navigate to Budget Planning so user can set up their budget immediately
|
||||
navigate('/board-planning/budgets');
|
||||
} catch {
|
||||
// Even if API fails, close the wizard — onboarding data is already created
|
||||
onComplete();
|
||||
navigate('/board-planning/budgets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -187,13 +247,14 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
// ── Navigation ──
|
||||
const canGoNext = () => {
|
||||
if (active === 0) return accountCreated;
|
||||
if (active === 1) return groupCreated;
|
||||
if (active === 1) return reserveCreated || reserveSkipped;
|
||||
if (active === 2) return groupCreated;
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
setError(null);
|
||||
if (active < 2) setActive(active + 1);
|
||||
if (active < 3) setActive(active + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -227,10 +288,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
<Stepper active={active} size="sm" mb="xl">
|
||||
<Stepper.Step
|
||||
label="Operating Account"
|
||||
description="Set up your primary bank account"
|
||||
description="Primary bank account"
|
||||
icon={<IconBuildingBank size={18} />}
|
||||
completedIcon={<IconCheck size={18} />}
|
||||
/>
|
||||
<Stepper.Step
|
||||
label="Reserve Account"
|
||||
description={reserveSkipped ? 'Skipped' : 'Savings account'}
|
||||
icon={<IconPigMoney size={18} />}
|
||||
completedIcon={reserveSkipped ? <IconX size={18} /> : <IconCheck size={18} />}
|
||||
/>
|
||||
<Stepper.Step
|
||||
label="Assessment Group"
|
||||
description="Define homeowner assessments"
|
||||
@@ -322,8 +389,103 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Assessment Group + Units ── */}
|
||||
{/* ── Step 2: Reserve Account ── */}
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<Card withBorder p="lg">
|
||||
<Text fw={600} mb="xs">Set Up a Reserve Savings Account</Text>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Most HOAs maintain a reserve fund for long-term capital projects like roof replacements,
|
||||
paving, and major repairs. Setting this up now gives you a more complete financial picture
|
||||
from the start.
|
||||
</Text>
|
||||
|
||||
{reserveCreated ? (
|
||||
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||
<Text fw={500}>{reserveName} created successfully!</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Initial balance: ${(typeof reserveBalance === 'number' ? reserveBalance : parseFloat(reserveBalance as string) || 0).toLocaleString()}
|
||||
{reserveBalanceDate && ` as of ${reserveBalanceDate.toLocaleDateString()}`}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : reserveSkipped ? (
|
||||
<Alert icon={<IconX size={16} />} color="gray" variant="light">
|
||||
<Text fw={500}>Reserve account skipped</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
You can always add a reserve account later from the Accounts page.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={2} mb="md">
|
||||
<TextInput
|
||||
label="Account Name"
|
||||
placeholder="e.g. Reserve Savings"
|
||||
value={reserveName}
|
||||
onChange={(e) => setReserveName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Account Number"
|
||||
placeholder="e.g. 2000"
|
||||
value={reserveNumber}
|
||||
onChange={(e) => setReserveNumber(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Optional description"
|
||||
value={reserveDescription}
|
||||
onChange={(e) => setReserveDescription(e.currentTarget.value)}
|
||||
mb="md"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<SimpleGrid cols={2} mb="md">
|
||||
<NumberInput
|
||||
label="Current Balance"
|
||||
description="Enter the current balance of this reserve account"
|
||||
placeholder="0.00"
|
||||
value={reserveBalance}
|
||||
onChange={setReserveBalance}
|
||||
thousandSeparator=","
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
/>
|
||||
<DateInput
|
||||
label="Balance As-Of Date"
|
||||
description="Date this balance was accurate"
|
||||
value={reserveBalanceDate}
|
||||
onChange={setReserveBalanceDate}
|
||||
maxDate={new Date()}
|
||||
clearable={false}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Group>
|
||||
<Button
|
||||
onClick={handleCreateReserve}
|
||||
loading={loading}
|
||||
leftSection={<IconPigMoney size={16} />}
|
||||
>
|
||||
Create Reserve Account
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleSkipReserve}
|
||||
>
|
||||
No Reserve Account
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Assessment Group + Units ── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<Card withBorder p="lg">
|
||||
<Text fw={600} mb="xs">Create an Assessment Group</Text>
|
||||
@@ -458,23 +620,32 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
)}
|
||||
|
||||
{/* ── Completion Screen ── */}
|
||||
{active === 2 && (
|
||||
{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
|
||||
and assessment groups from the sidebar navigation.
|
||||
Your organization is configured and ready to go. The next step is to set up your annual
|
||||
budget — we'll take you straight to Budget Planning.
|
||||
</Text>
|
||||
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
||||
<SimpleGrid cols={4} mb="xl" maw={600} mx="auto">
|
||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||
<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>
|
||||
<Text size="xs" mt={4}>Operating</Text>
|
||||
</Card>
|
||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={32} color="violet" variant="light" radius="xl" mx="auto" mb={4}>
|
||||
<IconPigMoney size={16} />
|
||||
</ThemeIcon>
|
||||
<Badge color={reserveSkipped ? 'gray' : 'green'} size="sm">
|
||||
{reserveSkipped ? 'Skipped' : 'Done'}
|
||||
</Badge>
|
||||
<Text size="xs" mt={4}>Reserve</Text>
|
||||
</Card>
|
||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||
@@ -484,7 +655,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
<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}>
|
||||
<ThemeIcon size={32} color="cyan" variant="light" radius="xl" mx="auto" mb={4}>
|
||||
<IconFileSpreadsheet size={16} />
|
||||
</ThemeIcon>
|
||||
<Badge color="cyan" size="sm">Up Next</Badge>
|
||||
@@ -494,25 +665,26 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
<Alert icon={<IconFileSpreadsheet size={16} />} color="blue" variant="light" mb="lg" ta="left">
|
||||
<Text size="sm" fw={500} mb={4}>Set Up Your Budget</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Head to <Text span fw={600}>Budget Planning</Text> from the sidebar to download a CSV template,
|
||||
fill in your monthly amounts, and upload your budget. You can do this at any time.
|
||||
Your budget is critical for accurate financial health scores, cash flow forecasting,
|
||||
and investment planning. Click below to go directly to Budget Planning where you can
|
||||
download a CSV template, fill in your monthly amounts, and upload your budget.
|
||||
</Text>
|
||||
</Alert>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleFinish}
|
||||
loading={loading}
|
||||
leftSection={<IconRocket size={18} />}
|
||||
leftSection={<IconFileSpreadsheet size={18} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'blue', to: 'cyan' }}
|
||||
>
|
||||
Start Using LedgerIQ
|
||||
Set Up My Budget
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Navigation Buttons ── */}
|
||||
{active < 2 && (
|
||||
{active < 3 && (
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
|
||||
Popover, List,
|
||||
Popover, List, Anchor,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCash,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||
import api from '../../services/api';
|
||||
|
||||
@@ -58,6 +59,28 @@ function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map missing data items to navigation links
|
||||
const missingDataLinks: Record<string, { label: string; path: string }> = {
|
||||
'reserve fund account': { label: 'Set up a reserve account', path: '/accounts' },
|
||||
'reserve account': { label: 'Set up a reserve account', path: '/accounts' },
|
||||
'reserve projects': { label: 'Add reserve projects', path: '/projects' },
|
||||
'capital projects': { label: 'Add capital projects', path: '/projects' },
|
||||
'projects': { label: 'Add projects', path: '/projects' },
|
||||
'budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
|
||||
'operating budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
|
||||
'reserve budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
|
||||
'assessment groups': { label: 'Create assessment groups', path: '/assessment-groups' },
|
||||
'accounts': { label: 'Set up accounts', path: '/accounts' },
|
||||
};
|
||||
|
||||
function getMissingDataLink(item: string): { label: string; path: string } | null {
|
||||
const lower = item.toLowerCase();
|
||||
for (const [key, value] of Object.entries(missingDataLinks)) {
|
||||
if (lower.includes(key)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function HealthScoreCard({
|
||||
score,
|
||||
title,
|
||||
@@ -65,6 +88,7 @@ function HealthScoreCard({
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
lastFailed,
|
||||
onNavigate,
|
||||
}: {
|
||||
score: HealthScore | null;
|
||||
title: string;
|
||||
@@ -72,6 +96,7 @@ function HealthScoreCard({
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
lastFailed?: boolean;
|
||||
onNavigate?: (path: string) => void;
|
||||
}) {
|
||||
// No score at all yet
|
||||
if (!score) {
|
||||
@@ -118,9 +143,19 @@ function HealthScoreCard({
|
||||
<Stack align="center" gap="xs">
|
||||
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
||||
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
|
||||
{missingItems.map((item: string, i: number) => (
|
||||
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
|
||||
))}
|
||||
{missingItems.map((item: string, i: number) => {
|
||||
const link = getMissingDataLink(item);
|
||||
return link ? (
|
||||
<Anchor key={i} size="xs" href={link.path} onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onNavigate?.(link.path);
|
||||
}}>
|
||||
{item} → {link.label}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
@@ -315,6 +350,7 @@ export function DashboardPage() {
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const isReadOnly = useIsReadOnly();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Track whether a refresh is in progress (per score type) for async polling
|
||||
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
|
||||
@@ -429,6 +465,7 @@ export function DashboardPage() {
|
||||
isRefreshing={operatingRefreshing}
|
||||
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
|
||||
lastFailed={!!healthScores?.operating_last_failed}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<HealthScoreCard
|
||||
score={healthScores?.reserve || null}
|
||||
@@ -441,6 +478,7 @@ export function DashboardPage() {
|
||||
isRefreshing={reserveRefreshing}
|
||||
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
|
||||
lastFailed={!!healthScores?.reserve_last_failed}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
|
||||
@@ -559,6 +559,32 @@ export function InvestmentPlanningPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-refresh: if no recommendations exist or they are older than 30 days, trigger automatically
|
||||
const autoRefreshTriggered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoRefreshTriggered.current || isProcessing || isTriggering || isReadOnly) return;
|
||||
if (savedRec === undefined) return; // still loading
|
||||
|
||||
const shouldAutoRefresh = (() => {
|
||||
// No saved recommendation at all
|
||||
if (!savedRec) return true;
|
||||
// Error state with no cached data
|
||||
if (savedRec.status === 'error' && (!savedRec.recommendations || savedRec.recommendations.length === 0)) return true;
|
||||
// Recommendations older than 30 days
|
||||
if (savedRec.created_at) {
|
||||
const age = Date.now() - new Date(savedRec.created_at).getTime();
|
||||
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
|
||||
if (age > thirtyDays) return true;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (shouldAutoRefresh) {
|
||||
autoRefreshTriggered.current = true;
|
||||
handleTriggerAI();
|
||||
}
|
||||
}, [savedRec, isProcessing, isTriggering, isReadOnly, handleTriggerAI]);
|
||||
|
||||
// Build AI result from saved recommendation for display
|
||||
const aiResult: AIResponse | null = hasResults
|
||||
? {
|
||||
|
||||
@@ -214,6 +214,13 @@ export function OrgMembersPage() {
|
||||
As an organization administrator, you can add board members, property managers, and
|
||||
viewers to give them access to this tenant. Each member can log in with their own
|
||||
credentials and see the same financial data.
|
||||
{currentOrg?.planLevel && !['enterprise'].includes(currentOrg.planLevel) && (
|
||||
<Text size="sm" mt={6} fw={500}>
|
||||
Your {currentOrg.planLevel === 'professional' ? 'Professional' : 'Starter'} plan
|
||||
supports up to 5 user accounts ({activeMembers.length}/5 used).
|
||||
{activeMembers.length >= 5 && ' Upgrade to Enterprise for unlimited members.'}
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useState, useRef } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
||||
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
||||
Card, SimpleGrid, Progress, Switch, Tooltip,
|
||||
Card, SimpleGrid, Progress, Switch, Tooltip, ThemeIcon, List,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen } from '@tabler/icons-react';
|
||||
import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, IconShieldCheck, IconBulb, IconRocket } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||
@@ -465,10 +465,55 @@ export function ProjectsPage() {
|
||||
))}
|
||||
{projects.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={9}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No projects yet
|
||||
</Text>
|
||||
<Table.Td colSpan={9} p={0}>
|
||||
<Card p="xl" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'violet', to: 'blue' }} mx="auto" mb="md">
|
||||
<IconShieldCheck size={32} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} mb="xs">Capital Projects & Reserve Planning</Title>
|
||||
<Text c="dimmed" maw={550} mx="auto" mb="lg">
|
||||
Track your community's capital improvement projects, reserve fund allocations,
|
||||
and long-term maintenance schedule. This is where you build a comprehensive
|
||||
picture of your HOA's future capital needs.
|
||||
</Text>
|
||||
<Card withBorder p="md" maw={550} mx="auto" mb="lg" ta="left">
|
||||
<Text fw={600} mb="xs">
|
||||
<IconBulb size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||
Common HOA Projects to Get Started
|
||||
</Text>
|
||||
<List size="sm" spacing="xs" c="dimmed">
|
||||
<List.Item><Text span fw={500} c="dark">Roof Replacement</Text> — Track the remaining useful life and reserve funding for your building's roof</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Parking Lot / Paving</Text> — Plan for periodic seal-coating and resurfacing</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Pool & Recreation</Text> — Budget for pool resurfacing, equipment, and amenity upgrades</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Painting & Exterior</Text> — Schedule exterior painting cycles (typically every 5-7 years)</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">HVAC Systems</Text> — Track common-area heating and cooling equipment lifecycles</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Elevator Modernization</Text> — Plan for required elevator upgrades and code compliance</List.Item>
|
||||
</List>
|
||||
</Card>
|
||||
<Group justify="center" gap="md">
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<IconRocket size={18} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'violet', to: 'blue' }}
|
||||
onClick={handleNew}
|
||||
>
|
||||
Create Your First Project
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Import from CSV
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
62
frontend/src/pages/vendors/VendorsPage.tsx
vendored
62
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -1,13 +1,13 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||
Switch, Badge, ActionIcon, Text, Loader, Center,
|
||||
Switch, Badge, ActionIcon, Text, Loader, Center, Card, ThemeIcon, List,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
@@ -153,7 +153,63 @@ export function VendorsPage() {
|
||||
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
|
||||
{filtered.length === 0 && vendors.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8} p={0}>
|
||||
<Card p="xl" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'orange', to: 'yellow' }} mx="auto" mb="md">
|
||||
<IconUsers size={32} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} mb="xs">Vendor Management</Title>
|
||||
<Text c="dimmed" maw={550} mx="auto" mb="lg">
|
||||
Keep track of your HOA's service providers, contractors, and suppliers.
|
||||
Having a centralized vendor directory helps with 1099 reporting, contract
|
||||
renewal tracking, and comparing year-over-year spending.
|
||||
</Text>
|
||||
<Card withBorder p="md" maw={550} mx="auto" mb="lg" ta="left">
|
||||
<Text fw={600} mb="xs">
|
||||
<IconBulb size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||
Common HOA Vendors to Track
|
||||
</Text>
|
||||
<List size="sm" spacing="xs" c="dimmed">
|
||||
<List.Item><Text span fw={500} c="dark">Landscaping Company</Text> — Lawn care, tree trimming, seasonal planting</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Property Management</Text> — Day-to-day management and tenant communications</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Insurance Provider</Text> — Master policy for buildings and common areas</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Pool Maintenance</Text> — Weekly chemical testing, cleaning, and equipment repair</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Snow Removal / Paving</Text> — Winter plowing and parking lot maintenance</List.Item>
|
||||
<List.Item><Text span fw={500} c="dark">Attorney / CPA</Text> — Legal counsel and annual financial review</List.Item>
|
||||
</List>
|
||||
</Card>
|
||||
<Group justify="center" gap="md">
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<IconRocket size={18} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'orange', to: 'yellow' }}
|
||||
onClick={() => { setEditing(null); form.reset(); open(); }}
|
||||
>
|
||||
Add Your First Vendor
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Import from CSV
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{filtered.length === 0 && vendors.length > 0 && (
|
||||
<Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors match your search</Text></Table.Td></Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Organization {
|
||||
name: string;
|
||||
role: string;
|
||||
status?: string;
|
||||
planLevel?: string;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user