feat: rebuild finance application shell
This commit is contained in:
@@ -25,6 +25,9 @@ const ImportsPage = lazy(() =>
|
||||
const LoginPage = lazy(() =>
|
||||
import('@/pages/LoginPage').then((module) => ({ default: module.LoginPage })),
|
||||
)
|
||||
const OverviewPage = lazy(() =>
|
||||
import('@/pages/OverviewPage').then((module) => ({ default: module.OverviewPage })),
|
||||
)
|
||||
const NotFoundPage = lazy(() =>
|
||||
import('@/pages/NotFoundPage').then((module) => ({ default: module.NotFoundPage })),
|
||||
)
|
||||
@@ -72,7 +75,7 @@ export function AppRoutes() {
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Navigate to="/accounts" replace />} />
|
||||
<Route index element={<OverviewPage />} />
|
||||
<Route path="accounts" element={<AccountsPage />} />
|
||||
<Route path="budget" element={<BudgetPage />} />
|
||||
<Route path="operations" element={<OperationsPage />} />
|
||||
|
||||
@@ -1,67 +1,97 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { LangSwitcher } from '@/components/layout/LangSwitcher'
|
||||
import { useLogout, useMe } from '@/hooks/useAuth'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/accounts', key: 'nav.accounts' },
|
||||
{ to: '/budget', key: 'nav.budget' },
|
||||
{ to: '/operations', key: 'nav.operations' },
|
||||
{ to: '/review', key: 'nav.review' },
|
||||
{ to: '/imports', key: 'nav.imports' },
|
||||
{ to: '/analytics', key: 'nav.analytics' },
|
||||
{ to: '/settings', key: 'nav.settings' },
|
||||
{ to: '/', key: 'nav.month', end: true },
|
||||
{ to: '/budget', key: 'nav.budget', end: false },
|
||||
{ to: '/operations', key: 'nav.operations', end: false },
|
||||
{ to: '/imports', key: 'nav.imports', end: false },
|
||||
{ to: '/review', key: 'nav.review', end: false },
|
||||
{ to: '/analytics', key: 'nav.analytics', end: false },
|
||||
{ to: '/settings', key: 'nav.settings', end: false },
|
||||
{ to: '/accounts', key: 'nav.accounts', end: false },
|
||||
] as const
|
||||
|
||||
const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) =>
|
||||
['/', '/operations', '/imports', '/analytics', '/settings'].includes(item.to),
|
||||
)
|
||||
|
||||
export function AppLayout() {
|
||||
const { t } = useTranslation()
|
||||
const me = useMe()
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const pageKey = location.pathname.split('/').filter(Boolean)[0] ?? 'overview'
|
||||
|
||||
const handleLogout = () =>
|
||||
logout.mutate(undefined, {
|
||||
onSettled: () => navigate('/login', { replace: true }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main">
|
||||
{t('common.skipToContent')}
|
||||
</a>
|
||||
<header className="app-header">
|
||||
<Link to="/accounts" className="brand">
|
||||
<span className="brand-mark" aria-hidden="true" />
|
||||
{t('app.brand')}
|
||||
</Link>
|
||||
<div className="header-side">
|
||||
<LangSwitcher />
|
||||
{me.data ? (
|
||||
<span className="user-chip" title={me.data.email}>
|
||||
{me.data.fullName}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
logout.mutate(undefined, {
|
||||
onSettled: () => navigate('/login', { replace: true }),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('auth.logout')}
|
||||
</Button>
|
||||
<aside className="app-sidebar">
|
||||
<div className="sidebar-top">
|
||||
<Link to="/" className="brand">
|
||||
<span className="brand-mark" aria-hidden="true">FS</span>
|
||||
<span>FINSPACE</span>
|
||||
</Link>
|
||||
<div className="space-identity">
|
||||
<span>{t('app.financialSpace')}</span>
|
||||
<strong>{me.data?.fullName ?? t('app.brand')}</strong>
|
||||
<small>{t('app.ownerActive')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="app-nav" aria-label={t('nav.label')}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink key={item.to} end={item.end} to={item.to} className="nav-pill">
|
||||
{t(item.key)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<p className="sync-state"><span aria-hidden="true" />{t('app.synced')}</p>
|
||||
{me.data ? (
|
||||
<div className="profile-block" title={me.data.email}>
|
||||
<span className="profile-avatar" aria-hidden="true">
|
||||
{me.data.fullName.split(/\s+/).map((part) => part[0]).join('').slice(0, 2)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{me.data.fullName}</strong>
|
||||
<small>{me.data.locale.toUpperCase()} · {me.data.timezone}</small>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sidebar-controls">
|
||||
<LangSwitcher />
|
||||
<Button variant="ghost" onClick={handleLogout}>{t('auth.logout')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<header className="mobile-header">
|
||||
<Link to="/" className="brand">
|
||||
<span className="brand-mark" aria-hidden="true">FS</span>
|
||||
<span>FINSPACE</span>
|
||||
</Link>
|
||||
<LangSwitcher />
|
||||
</header>
|
||||
<nav className="app-nav" aria-label={t('nav.label')}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} className="nav-pill">
|
||||
<main id="main" className={`app-main view-${pageKey}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<nav className="mobile-nav" aria-label={t('nav.label')}>
|
||||
{MOBILE_NAV_ITEMS.map((item) => (
|
||||
<NavLink key={item.to} end={item.end} to={item.to}>
|
||||
{t(item.key)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<main id="main" className="app-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="app-footer">
|
||||
<span>{t('app.footer')}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Grossbook — finance tracker",
|
||||
"brand": "Grossbook",
|
||||
"footer": "Closed beta · RUB · zero-based budget"
|
||||
"title": "Finspace — household finance",
|
||||
"brand": "Finspace",
|
||||
"footer": "RUB · private household ledger",
|
||||
"financialSpace": "Financial space",
|
||||
"ownerActive": "Owner · active",
|
||||
"synced": "All data synced"
|
||||
},
|
||||
"nav": {
|
||||
"label": "Primary navigation",
|
||||
"month": "This month",
|
||||
"accounts": "Accounts",
|
||||
"budget": "Budget",
|
||||
"operations": "Operations",
|
||||
"operations": "Ledger",
|
||||
"review": "Review queue",
|
||||
"imports": "Imports",
|
||||
"imports": "Statements",
|
||||
"analytics": "Analytics",
|
||||
"settings": "Settings & data"
|
||||
"settings": "Space & data"
|
||||
},
|
||||
"overview": {
|
||||
"subtitle": "Your household money, explained for the current month.",
|
||||
"openBudget": "Open full budget",
|
||||
"newOperation": "New operation",
|
||||
"availableNow": "cash available now",
|
||||
"afterPlan": "after plan and expected income",
|
||||
"reserved": "reserved into categories",
|
||||
"ofFunded": "spent from funded money",
|
||||
"monthStory": "How the month is moving",
|
||||
"liveState": "Live state",
|
||||
"poolAndIncome": "Pool and actual income",
|
||||
"funded": "Funded to categories",
|
||||
"spent": "Spent from categories",
|
||||
"poolRemaining": "Pool remaining",
|
||||
"closeReadiness": "Close readiness",
|
||||
"blockers": "blockers",
|
||||
"readyToClose": "No blockers. This month is ready to close.",
|
||||
"importsNeedReview": "{{count}} statement needs review",
|
||||
"reviewBlockers": "Review blockers",
|
||||
"categoryPulse": "Category pulse"
|
||||
},
|
||||
"lang": {
|
||||
"label": "Language"
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Гроссбух — финансовый трекер",
|
||||
"brand": "Гроссбух",
|
||||
"footer": "Закрытая бета · RUB · zero-based бюджет"
|
||||
"title": "Finspace — семейные финансы",
|
||||
"brand": "Finspace",
|
||||
"footer": "RUB · приватный семейный гроссбух",
|
||||
"financialSpace": "Финансовое пространство",
|
||||
"ownerActive": "Владелец · активно",
|
||||
"synced": "Все данные синхронизированы"
|
||||
},
|
||||
"nav": {
|
||||
"label": "Основная навигация",
|
||||
"month": "Этот месяц",
|
||||
"accounts": "Счета",
|
||||
"budget": "Бюджет",
|
||||
"operations": "Операции",
|
||||
"operations": "Гроссбух",
|
||||
"review": "Очередь разбора",
|
||||
"imports": "Импорт",
|
||||
"imports": "Выписки",
|
||||
"analytics": "Аналитика",
|
||||
"settings": "Настройки и данные"
|
||||
"settings": "Пространство и данные"
|
||||
},
|
||||
"overview": {
|
||||
"subtitle": "Семейные деньги с объяснением текущего месяца.",
|
||||
"openBudget": "Открыть весь бюджет",
|
||||
"newOperation": "Новая операция",
|
||||
"availableNow": "деньги, доступные сейчас",
|
||||
"afterPlan": "после плана и ожидаемых доходов",
|
||||
"reserved": "зарезервировано по категориям",
|
||||
"ofFunded": "потрачено из профинансированного",
|
||||
"monthStory": "Как движется месяц",
|
||||
"liveState": "Текущее состояние",
|
||||
"poolAndIncome": "Пул и полученный доход",
|
||||
"funded": "Распределено по категориям",
|
||||
"spent": "Потрачено по категориям",
|
||||
"poolRemaining": "Осталось в пуле",
|
||||
"closeReadiness": "Готовность к закрытию",
|
||||
"blockers": "блокировок",
|
||||
"readyToClose": "Блокировок нет. Месяц можно закрывать.",
|
||||
"importsNeedReview": "Выписок на проверке: {{count}}",
|
||||
"reviewBlockers": "Разобрать блокировки",
|
||||
"categoryPulse": "Состояние категорий"
|
||||
},
|
||||
"lang": {
|
||||
"label": "Язык"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { QueryGate } from '@/components/ui/QueryGate'
|
||||
import { useBudgetCategories, useBudgetSummary } from '@/hooks/useBudget'
|
||||
import { useImports } from '@/hooks/useImports'
|
||||
import { categoryLabel } from '@/lib/categoryLabel'
|
||||
import { currentMonth, formatMonth } from '@/lib/dates'
|
||||
import { currentLocale } from '@/lib/locale'
|
||||
import { formatMoney } from '@/lib/money'
|
||||
|
||||
export function OverviewPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = currentLocale(i18n.language)
|
||||
const month = currentMonth()
|
||||
const summary = useBudgetSummary(month)
|
||||
const categories = useBudgetCategories(month)
|
||||
const imports = useImports()
|
||||
const money = (value: number) => formatMoney(value, locale)
|
||||
const attentionImports = (imports.data ?? []).filter((item) => item.status === 'review_required')
|
||||
|
||||
return (
|
||||
<div className="overview-page">
|
||||
<PageHeader
|
||||
title={formatMonth(month, locale)}
|
||||
subtitle={t('overview.subtitle')}
|
||||
actions={
|
||||
<div className="page-actions-row">
|
||||
<Link className="btn btn-subtle" to="/budget">{t('overview.openBudget')}</Link>
|
||||
<Link className="btn btn-primary" to="/operations">{t('overview.newOperation')}</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<QueryGate query={summary}>
|
||||
{(data) => (
|
||||
<>
|
||||
<section className="mobile-pool" aria-label={t('budget.actualPool')}>
|
||||
<span>{t('budget.actualPool')}</span>
|
||||
<strong className="num">{money(data.actualPoolKopecks)}</strong>
|
||||
<small>{t(`budget.status.${data.status}`)}</small>
|
||||
</section>
|
||||
<dl className="stats overview-stats">
|
||||
<div className="stat"><dt>{t('budget.actualPool')}</dt><dd className="num">{money(data.actualPoolKopecks)}</dd><small>{t('overview.availableNow')}</small></div>
|
||||
<div className="stat"><dt>{t('budget.projectedPool')}</dt><dd className="num">{money(data.projectedPoolKopecks)}</dd><small>{t('overview.afterPlan')}</small></div>
|
||||
<div className="stat"><dt>{t('budget.fundedTotal')}</dt><dd className="num">{money(data.fundedTotalKopecks)}</dd><small>{t('overview.reserved')}</small></div>
|
||||
<div className="stat"><dt>{t('budget.spentTotal')}</dt><dd className="num">{money(data.spentTotalKopecks)}</dd><small>{t('overview.ofFunded')}</small></div>
|
||||
</dl>
|
||||
<div className="overview-workbench">
|
||||
<section className="month-story">
|
||||
<div className="section-heading">
|
||||
<h2>{t('overview.monthStory')}</h2>
|
||||
<span>{t('overview.liveState')}</span>
|
||||
</div>
|
||||
<ol className="story-list">
|
||||
<li><span>{t('overview.poolAndIncome')}</span><strong className="num">{money(data.actualPoolKopecks + data.fundedTotalKopecks)}</strong></li>
|
||||
<li><span>{t('overview.funded')}</span><strong className="num neg">−{money(data.fundedTotalKopecks)}</strong></li>
|
||||
<li><span>{t('overview.spent')}</span><strong className="num neg">−{money(data.spentTotalKopecks)}</strong></li>
|
||||
<li><span>{t('overview.poolRemaining')}</span><strong className="num">{money(data.actualPoolKopecks)}</strong></li>
|
||||
</ol>
|
||||
</section>
|
||||
<aside className="decision-panel">
|
||||
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div>
|
||||
{data.blockers.length > 0 ? (
|
||||
<ul>{data.blockers.map((blocker) => <li key={blocker.code}>{t(`budget.blockers.${blocker.code}`, { defaultValue: blocker.message ?? blocker.code })}</li>)}</ul>
|
||||
) : <p>{t('overview.readyToClose')}</p>}
|
||||
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null}
|
||||
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
<section className="category-pulse" aria-label={t('overview.categoryPulse')}>
|
||||
<div className="section-heading"><h2>{t('overview.categoryPulse')}</h2><Link to="/budget">{t('overview.openBudget')}</Link></div>
|
||||
<QueryGate query={categories}>
|
||||
{(items) => (
|
||||
<div className="pulse-grid">
|
||||
{items.filter((item) => !item.archived).slice(0, 4).map((item) => {
|
||||
const ratio = item.fundedKopecks > 0 ? Math.min(item.spentKopecks / item.fundedKopecks, 1) : 0
|
||||
return (
|
||||
<article className="pulse-item" key={item.id}>
|
||||
<span>{categoryLabel(item, t)}</span>
|
||||
<strong className="num">{money(item.availableKopecks)}</strong>
|
||||
<small>{t('budget.available')}</small>
|
||||
<span className="pulse-track"><span style={{ width: `${ratio * 100}%` }} /></span>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</QueryGate>
|
||||
</section>
|
||||
<div className="mobile-primary-action"><Link className="btn btn-primary" to="/operations">{t('overview.newOperation')}</Link></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+701
-1050
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user