Files
Novault-Frontend-web/src/pages/Home/Home.tsx

491 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import './Home.css';
import { getAccounts, calculateTotalAssets, calculateTotalLiabilities } from '../../services/accountService';
import { getTransactions } from '../../services/transactionService';
import { getCategories } from '../../services/categoryService';
import { getLedgers, reorderLedgers } from '../../services/ledgerService';
import { getSettings, updateSettings } from '../../services/settingsService';
import { Icon } from '@iconify/react';
import { SpendingTrendChart } from '../../components/charts/SpendingTrendChart';
import { Skeleton } from '../../components/common/Skeleton/Skeleton';
import { LedgerSelector } from '../../components/ledger/LedgerSelector/LedgerSelector';
import VoiceInputModal from '../../components/ai/VoiceInputModal/VoiceInputModal';
import { CreateFirstAccountModal } from '../../components/account/CreateFirstAccountModal/CreateFirstAccountModal';
import { AccountForm } from '../../components/account/AccountForm/AccountForm';
import { createAccount } from '../../services/accountService';
import { Confetti } from '../../components/common/Confetti';
import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types';
/**
* Home Page Component
* Displays account balance overview and recent transactions
* Provides quick access to create new transactions
*
* Requirements:
* - 8.1: Quick transaction entry (3 steps or less)
* - 8.2: Fast loading (< 2 seconds)
*/
function Home() {
const navigate = useNavigate();
const [accounts, setAccounts] = useState<Account[]>([]);
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [settings, setSettings] = useState<UserSettings | null>(null);
const [ledgerSelectorOpen, setLedgerSelectorOpen] = useState(false);
const [voiceModalOpen, setVoiceModalOpen] = useState(false);
const [showAccountForm, setShowAccountForm] = useState(false);
const [showConfetti, setShowConfetti] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
setError(null);
// Load accounts, recent transactions, categories, ledgers, and settings in parallel
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData] = await Promise.all([
getAccounts(),
getTransactions({ page: 1, pageSize: 5 }), // Get 5 most recent transactions
getCategories(),
getLedgers().catch(() => []), // Gracefully handle if ledgers not available
getSettings().catch(() => null), // Gracefully handle if settings not available
]);
setAccounts(accountsData || []);
setRecentTransactions(transactionsData?.items || []);
setCategories(categoriesData || []);
setLedgers(ledgersData || []);
setSettings(settingsData);
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败');
console.error('Failed to load home page data:', err);
} finally {
setLoading(false);
}
};
const handleQuickTransaction = () => {
navigate('/transactions?action=new');
};
const handleAIBookkeeping = () => {
setVoiceModalOpen(true);
};
const handleAIConfirm = () => {
// 确认后刷新数据
loadData();
setVoiceModalOpen(false);
};
const handleViewAllAccounts = () => {
navigate('/accounts');
};
const handleViewAllTransactions = () => {
navigate('/transactions');
};
const handleLedgerSelect = async (ledgerId: number) => {
try {
// Update settings with new current ledger
if (settings) {
await updateSettings({ ...settings, currentLedgerId: ledgerId });
setSettings({ ...settings, currentLedgerId: ledgerId });
}
setLedgerSelectorOpen(false);
// Reload data to show transactions from selected ledger
await loadData();
} catch (err) {
console.error('Failed to switch ledger:', err);
setError('切换账本失败');
}
};
const handleLedgerReorder = async (reorderedLedgers: Ledger[]) => {
try {
setLedgers(reorderedLedgers);
const ledgerIds = reorderedLedgers.map(l => l.id);
await reorderLedgers(ledgerIds);
} catch (err) {
console.error('Failed to reorder ledgers:', err);
// Revert on error
await loadData();
}
};
const handleAddLedger = () => {
setLedgerSelectorOpen(false);
navigate('/ledgers/new');
};
const handleManageLedgers = () => {
setLedgerSelectorOpen(false);
navigate('/ledgers');
};
const currentLedger = ledgers.find(l => l.id === settings?.currentLedgerId) || ledgers.find(l => l.isDefault) || ledgers[0];
const totalAssets = calculateTotalAssets(accounts);
const totalLiabilities = calculateTotalLiabilities(accounts);
const netWorth = totalAssets - totalLiabilities;
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(amount);
};
// Lock body scroll when modal is open
useEffect(() => {
if (showAccountForm) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [showAccountForm]);
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return '今天';
} else if (date.toDateString() === yesterday.toDateString()) {
return '昨天';
} else {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
}
};
// Phase 3: Daily Briefing Logic
const getDailyBriefing = () => {
const hour = new Date().getHours();
const todaySpend = recentTransactions
.filter(t => {
const tDate = new Date(t.transactionDate);
const today = new Date();
return tDate.getDate() === today.getDate() &&
tDate.getMonth() === today.getMonth() &&
tDate.getFullYear() === today.getFullYear() &&
t.type === 'expense';
})
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
let greeting = '你好';
if (hour < 5) greeting = '夜深了';
else if (hour < 11) greeting = '早上好';
else if (hour < 13) greeting = '中午好';
else if (hour < 18) greeting = '下午好';
else greeting = '晚上好';
let insight = '今天还没有记账哦';
if (todaySpend > 0) {
insight = `今天已支出 ${formatCurrency(todaySpend)}`;
}
return { greeting, insight };
};
const { greeting, insight } = getDailyBriefing();
// Phase 3: Financial Health Score (Mock Logic)
// In a real app, this would be complex. Here we use a simple placeholder derived from net worth/assets ratio
const calculateHealthScore = () => {
if (totalAssets === 0) return 60; // Baseline
const ratio = (totalAssets - totalLiabilities) / totalAssets;
let score = Math.round(ratio * 100);
if (score < 40) score = 40;
if (score > 98) score = 98;
return score;
};
const healthScore = calculateHealthScore();
if (loading) {
return (
<div className="home-page">
<header className="home-header">
<Skeleton width={120} height={32} />
</header>
<main className="home-content">
<section className="quick-actions">
<Skeleton width="100%" height={60} variant="rectangular" />
</section>
<section className="account-overview">
<div className="section-header">
<Skeleton width={100} height={24} />
<Skeleton width={60} height={24} />
</div>
<div className="balance-summary">
<Skeleton width="100%" height={120} variant="card" style={{ marginBottom: '1rem' }} />
<div style={{ display: 'flex', gap: '1rem' }}>
<Skeleton width="50%" height={80} variant="card" />
<Skeleton width="50%" height={80} variant="card" />
</div>
</div>
</section>
</main>
</div>
);
}
if (error) {
return (
<div className="home-page">
<div className="error-state">
<Icon icon="solar:danger-circle-bold" width="48" color="#ef4444" />
<p>{error}</p>
<button className="retry-btn" onClick={loadData}></button>
</div>
</div>
);
}
return (
<div className="home-page">
<header className="home-header">
<header className="home-header">
<div className="home-greeting animate-slide-up">
<div className="greeting-top-row">
<div className="greeting-pill" onClick={() => setLedgerSelectorOpen(true)}>
{currentLedger && (
<>
<Icon icon="solar:notebook-minimalistic-bold-duotone" width="14" />
<span>{currentLedger.name}</span>
<Icon icon="solar:alt-arrow-down-bold" width="10" className="chevron-icon" />
</>
)}
</div>
<span className="home-date">{new Date().toLocaleDateString('zh-CN', { weekday: 'short', month: 'long', day: 'numeric' })}</span>
</div>
<h1 className="greeting-text">
{greeting}<span className="greeting-highlight"></span>
</h1>
<p className="greeting-insight animate-slide-up delay-100">
<Icon icon="solar:bell-bing-bold-duotone" width="16" className="insight-icon" />
{insight}
</p>
</div>
<div className="header-actions animate-slide-up delay-200">
<button className="health-score-btn" onClick={() => setShowConfetti(true)}>
<div className="health-ring" style={{ '--score': `${healthScore}%`, '--color': 'var(--accent-success)' } as any}>
<svg viewBox="0 0 36 36">
<path className="ring-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path className="ring-fill" strokeDasharray={`${healthScore}, 100`} d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<span className="health-val">{healthScore}</span>
</div>
<span className="health-label"></span>
</button>
<button className="quick-action-btn-small" onClick={handleQuickTransaction}>
<Icon icon="solar:add-circle-bold-duotone" width="20" />
<span></span>
</button>
</div>
</header>
</header>
<main className="home-content">
{/* Asset Dashboard - Requirement 8.1 */}
<section className="dashboard-grid">
{/* Net Worth Card - Main Hero */}
<div className="dashboard-card home-net-worth-card">
<div className="card-content">
<span className="card-label"></span>
<div className="card-value-group">
<span className="currency-symbol">¥</span>
<span className="card-value-main">{formatCurrency(netWorth).replace(/[^0-9.,-]/g, '')}</span>
</div>
<div className="card-footer">
<span className="trend-neutral"></span>
</div>
</div>
<div className="card-bg-decoration"></div>
</div>
{/* Assets Card */}
<div className="dashboard-card assets-card" onClick={handleViewAllAccounts}>
<div className="card-icon-wrapper income">
<Icon icon="solar:graph-up-bold-duotone" width="20" />
</div>
<div className="card-content">
<span className="card-label"></span>
<span className="card-value-sub">{formatCurrency(totalAssets)}</span>
</div>
</div>
{/* Liabilities Card */}
<div className="dashboard-card liabilities-card" onClick={handleViewAllAccounts}>
<div className="card-icon-wrapper expense">
<Icon icon="solar:graph-down-bold-duotone" width="20" />
</div>
<div className="card-content">
<span className="card-label"></span>
<span className="card-value-sub">{formatCurrency(totalLiabilities)}</span>
</div>
</div>
</section>
{/* Quick Actions Section */}
<section className="quick-actions-section">
<button className="action-card primary" onClick={handleQuickTransaction}>
<div className="action-icon blur-bg">
<Icon icon="solar:add-circle-bold-duotone" width="24" />
</div>
<div className="action-info">
<span className="action-title"></span>
<span className="action-desc"></span>
</div>
</button>
<button className="action-card ai" onClick={handleAIBookkeeping}>
<div className="action-icon blur-bg">
<Icon icon="solar:microphone-3-bold-duotone" width="24" />
</div>
<div className="action-info">
<span className="action-title">AI </span>
<span className="action-desc"></span>
</div>
</button>
<button className="action-card secondary" onClick={handleViewAllAccounts}>
<div className="action-icon blur-bg">
<Icon icon="solar:wallet-bold-duotone" width="24" />
</div>
<div className="action-info">
<span className="action-title"></span>
<span className="action-desc"> {accounts.length} </span>
</div>
</button>
</section>
<div className="content-columns">
{/* Spending Trend Chart */}
<div className="chart-container">
<SpendingTrendChart transactions={recentTransactions} />
</div>
{/* Recent Transactions List */}
<section className="recent-transactions-section">
<div className="section-header">
<h2></h2>
<button className="view-all-link" onClick={handleViewAllTransactions}>
</button>
</div>
{recentTransactions.length > 0 ? (
<div className="transaction-list-compact">
{recentTransactions.map((transaction) => (
<div key={transaction.id} className="transaction-row" onClick={() => navigate('/transactions')}>
<div className={`transaction-icon ${transaction.type}`}>
{transaction.type === 'income' ? <Icon icon="solar:graph-up-bold-duotone" width="16" /> : <Icon icon="solar:graph-down-bold-duotone" width="16" />}
</div>
<div className="transaction-details">
<span className="transaction-category">
{categories.find(c => c.id === transaction.categoryId)?.name || '无分类'}
</span>
<span className="transaction-note-compact">{transaction.note || '无备注'}</span>
</div>
<div className="transaction-meta">
<span className={`transaction-amount-compact ${transaction.type}`}>
{transaction.type === 'income' ? '+' : '-'}{formatCurrency(Math.abs(transaction.amount))}
</span>
<span className="transaction-time">{formatDate(transaction.transactionDate)}</span>
</div>
</div>
))}
</div>
) : (
<div className="empty-state-compact">
<Icon icon="solar:document-text-bold-duotone" width="32" />
<p></p>
</div>
)}
</section>
</div>
</main>
{/* Ledger Selector Modal - Requirements 3.2, 3.3 */}
{ledgers.length > 0 && (
<LedgerSelector
ledgers={ledgers}
currentLedgerId={currentLedger?.id || 0}
onSelect={handleLedgerSelect}
onAdd={handleAddLedger}
onManage={handleManageLedgers}
onReorder={handleLedgerReorder}
open={ledgerSelectorOpen}
onClose={() => setLedgerSelectorOpen(false)}
/>
)}
{/* AI Voice Input Modal */}
<VoiceInputModal
isOpen={voiceModalOpen}
onClose={() => setVoiceModalOpen(false)}
onConfirm={handleAIConfirm}
/>
{/* First Account Guide Modal */}
<CreateFirstAccountModal
isOpen={!loading && !error && accounts.length === 0 && !showAccountForm}
onCreate={() => setShowAccountForm(true)}
/>
{/* Account Creation Modal */}
{showAccountForm && (
<div className="modal-overlay">
<div className="modal-content">
<AccountForm
onSubmit={async (data) => {
try {
setLoading(true);
await createAccount(data);
setShowAccountForm(false);
await loadData();
} catch (err) {
setError('创建账户失败,请重试');
console.error(err);
setLoading(false);
}
}}
onCancel={() => {
// Should not allow cancel if it's the first account?
// Let's allow it but the Guide Modal will pop up again immediately because accounts.length is still 0
setShowAccountForm(false);
}}
loading={loading}
/>
</div>
</div>
)}
<Confetti active={showConfetti} recycle={false} onComplete={() => setShowConfetti(false)} />
</div>
);
}
export default Home;