feat: rebuild finance application shell
This commit is contained in:
@@ -25,6 +25,9 @@ const ImportsPage = lazy(() =>
|
|||||||
const LoginPage = lazy(() =>
|
const LoginPage = lazy(() =>
|
||||||
import('@/pages/LoginPage').then((module) => ({ default: module.LoginPage })),
|
import('@/pages/LoginPage').then((module) => ({ default: module.LoginPage })),
|
||||||
)
|
)
|
||||||
|
const OverviewPage = lazy(() =>
|
||||||
|
import('@/pages/OverviewPage').then((module) => ({ default: module.OverviewPage })),
|
||||||
|
)
|
||||||
const NotFoundPage = lazy(() =>
|
const NotFoundPage = lazy(() =>
|
||||||
import('@/pages/NotFoundPage').then((module) => ({ default: module.NotFoundPage })),
|
import('@/pages/NotFoundPage').then((module) => ({ default: module.NotFoundPage })),
|
||||||
)
|
)
|
||||||
@@ -72,7 +75,7 @@ export function AppRoutes() {
|
|||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route path="/" element={<AppLayout />}>
|
<Route path="/" element={<AppLayout />}>
|
||||||
<Route index element={<Navigate to="/accounts" replace />} />
|
<Route index element={<OverviewPage />} />
|
||||||
<Route path="accounts" element={<AccountsPage />} />
|
<Route path="accounts" element={<AccountsPage />} />
|
||||||
<Route path="budget" element={<BudgetPage />} />
|
<Route path="budget" element={<BudgetPage />} />
|
||||||
<Route path="operations" element={<OperationsPage />} />
|
<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 { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { LangSwitcher } from '@/components/layout/LangSwitcher'
|
import { LangSwitcher } from '@/components/layout/LangSwitcher'
|
||||||
import { useLogout, useMe } from '@/hooks/useAuth'
|
import { useLogout, useMe } from '@/hooks/useAuth'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/accounts', key: 'nav.accounts' },
|
{ to: '/', key: 'nav.month', end: true },
|
||||||
{ to: '/budget', key: 'nav.budget' },
|
{ to: '/budget', key: 'nav.budget', end: false },
|
||||||
{ to: '/operations', key: 'nav.operations' },
|
{ to: '/operations', key: 'nav.operations', end: false },
|
||||||
{ to: '/review', key: 'nav.review' },
|
{ to: '/imports', key: 'nav.imports', end: false },
|
||||||
{ to: '/imports', key: 'nav.imports' },
|
{ to: '/review', key: 'nav.review', end: false },
|
||||||
{ to: '/analytics', key: 'nav.analytics' },
|
{ to: '/analytics', key: 'nav.analytics', end: false },
|
||||||
{ to: '/settings', key: 'nav.settings' },
|
{ to: '/settings', key: 'nav.settings', end: false },
|
||||||
|
{ to: '/accounts', key: 'nav.accounts', end: false },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) =>
|
||||||
|
['/', '/operations', '/imports', '/analytics', '/settings'].includes(item.to),
|
||||||
|
)
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const logout = useLogout()
|
const logout = useLogout()
|
||||||
const navigate = useNavigate()
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<a className="skip-link" href="#main">
|
<a className="skip-link" href="#main">
|
||||||
{t('common.skipToContent')}
|
{t('common.skipToContent')}
|
||||||
</a>
|
</a>
|
||||||
<header className="app-header">
|
<aside className="app-sidebar">
|
||||||
<Link to="/accounts" className="brand">
|
<div className="sidebar-top">
|
||||||
<span className="brand-mark" aria-hidden="true" />
|
<Link to="/" className="brand">
|
||||||
{t('app.brand')}
|
<span className="brand-mark" aria-hidden="true">FS</span>
|
||||||
|
<span>FINSPACE</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="header-side">
|
<div className="space-identity">
|
||||||
<LangSwitcher />
|
<span>{t('app.financialSpace')}</span>
|
||||||
{me.data ? (
|
<strong>{me.data?.fullName ?? t('app.brand')}</strong>
|
||||||
<span className="user-chip" title={me.data.email}>
|
<small>{t('app.ownerActive')}</small>
|
||||||
{me.data.fullName}
|
</div>
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
logout.mutate(undefined, {
|
|
||||||
onSettled: () => navigate('/login', { replace: true }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('auth.logout')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
<nav className="app-nav" aria-label={t('nav.label')}>
|
<nav className="app-nav" aria-label={t('nav.label')}>
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<NavLink key={item.to} to={item.to} className="nav-pill">
|
<NavLink key={item.to} end={item.end} to={item.to} className="nav-pill">
|
||||||
{t(item.key)}
|
{t(item.key)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<main id="main" className="app-main">
|
<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>
|
||||||
|
<main id="main" className={`app-main view-${pageKey}`}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<footer className="app-footer">
|
<nav className="mobile-nav" aria-label={t('nav.label')}>
|
||||||
<span>{t('app.footer')}</span>
|
{MOBILE_NAV_ITEMS.map((item) => (
|
||||||
</footer>
|
<NavLink key={item.to} end={item.end} to={item.to}>
|
||||||
|
{t(item.key)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,43 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"title": "Grossbook — finance tracker",
|
"title": "Finspace — household finance",
|
||||||
"brand": "Grossbook",
|
"brand": "Finspace",
|
||||||
"footer": "Closed beta · RUB · zero-based budget"
|
"footer": "RUB · private household ledger",
|
||||||
|
"financialSpace": "Financial space",
|
||||||
|
"ownerActive": "Owner · active",
|
||||||
|
"synced": "All data synced"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"label": "Primary navigation",
|
"label": "Primary navigation",
|
||||||
|
"month": "This month",
|
||||||
"accounts": "Accounts",
|
"accounts": "Accounts",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"operations": "Operations",
|
"operations": "Ledger",
|
||||||
"review": "Review queue",
|
"review": "Review queue",
|
||||||
"imports": "Imports",
|
"imports": "Statements",
|
||||||
"analytics": "Analytics",
|
"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": {
|
"lang": {
|
||||||
"label": "Language"
|
"label": "Language"
|
||||||
|
|||||||
@@ -1,18 +1,43 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"title": "Гроссбух — финансовый трекер",
|
"title": "Finspace — семейные финансы",
|
||||||
"brand": "Гроссбух",
|
"brand": "Finspace",
|
||||||
"footer": "Закрытая бета · RUB · zero-based бюджет"
|
"footer": "RUB · приватный семейный гроссбух",
|
||||||
|
"financialSpace": "Финансовое пространство",
|
||||||
|
"ownerActive": "Владелец · активно",
|
||||||
|
"synced": "Все данные синхронизированы"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"label": "Основная навигация",
|
"label": "Основная навигация",
|
||||||
|
"month": "Этот месяц",
|
||||||
"accounts": "Счета",
|
"accounts": "Счета",
|
||||||
"budget": "Бюджет",
|
"budget": "Бюджет",
|
||||||
"operations": "Операции",
|
"operations": "Гроссбух",
|
||||||
"review": "Очередь разбора",
|
"review": "Очередь разбора",
|
||||||
"imports": "Импорт",
|
"imports": "Выписки",
|
||||||
"analytics": "Аналитика",
|
"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": {
|
"lang": {
|
||||||
"label": "Язык"
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+699
-1048
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user