feat: rebuild finance application shell

This commit is contained in:
k1nq
2026-09-21 16:32:10 +05:00
parent c17d86cd99
commit b2b1e14cab
6 changed files with 932 additions and 1102 deletions
+4 -1
View File
@@ -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 />} />
+65 -35
View File
@@ -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')}
<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="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>
<div className="space-identity">
<span>{t('app.financialSpace')}</span>
<strong>{me.data?.fullName ?? t('app.brand')}</strong>
<small>{t('app.ownerActive')}</small>
</div>
</div>
</header>
<nav className="app-nav" aria-label={t('nav.label')}>
{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)}
</NavLink>
))}
</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 />
</main>
<footer className="app-footer">
<span>{t('app.footer')}</span>
</footer>
<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>
</div>
)
}
+31 -6
View File
@@ -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"
+31 -6
View File
@@ -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": "Язык"
+96
View File
@@ -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>
)
}
File diff suppressed because it is too large Load Diff