023612ce0a
- Updated timestamps and AST hashes for multiple files in the manifest.json. - Added new test file backend/tests/test_expected_income.py. - Added documentation files: перево́д_между_своими.md and 0002-fixes.md. - Introduced pytest.ini to configure pytest settings, including test paths and warning filters.
191 lines
5.8 KiB
TypeScript
191 lines
5.8 KiB
TypeScript
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import type { ReactElement } from 'react'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { LangSwitcher } from '@/components/layout/LangSwitcher'
|
|
import { useLogout, useMe } from '@/hooks/useAuth'
|
|
|
|
type NavIconName =
|
|
| 'month'
|
|
| 'operations'
|
|
| 'budget'
|
|
| 'imports'
|
|
| 'review'
|
|
| 'analytics'
|
|
| 'accounts'
|
|
| 'settings'
|
|
|
|
const NAV_ICONS: Record<NavIconName, ReactElement> = {
|
|
month: (
|
|
<>
|
|
<rect x="3" y="4.5" width="18" height="16" rx="2" />
|
|
<path d="M8 2.5v4" />
|
|
<path d="M16 2.5v4" />
|
|
<path d="M3 9.5h18" />
|
|
<path d="M8 14h8" />
|
|
</>
|
|
),
|
|
operations: (
|
|
<>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
<path d="M3 9h18" />
|
|
<path d="M3 15h18" />
|
|
</>
|
|
),
|
|
budget: (
|
|
<>
|
|
<path d="M21 12c.552 0 1.005-.449.95-.998a10 10 0 0 0-8.953-8.951c-.55-.055-.998.398-.998.95v8a1 1 0 0 0 1 1z" />
|
|
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
|
|
</>
|
|
),
|
|
imports: (
|
|
<>
|
|
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
|
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
</>
|
|
),
|
|
review: (
|
|
<>
|
|
<path d="m3 17 2 2 4-4" />
|
|
<path d="m3 7 2 2 4-4" />
|
|
<path d="M13 6h8" />
|
|
<path d="M13 12h8" />
|
|
<path d="M13 18h8" />
|
|
</>
|
|
),
|
|
analytics: (
|
|
<>
|
|
<path d="M4 20v-6" />
|
|
<path d="M9.5 20V9" />
|
|
<path d="M15 20v-4" />
|
|
<path d="M20.5 20V7" />
|
|
<path d="m3 12 5-5 4 3 6-6" />
|
|
</>
|
|
),
|
|
accounts: (
|
|
<>
|
|
<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1" />
|
|
<path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4" />
|
|
</>
|
|
),
|
|
settings: (
|
|
<>
|
|
<path d="M20 7h-9" />
|
|
<path d="M14 17H5" />
|
|
<circle cx="17" cy="17" r="3" />
|
|
<circle cx="7" cy="7" r="3" />
|
|
</>
|
|
),
|
|
}
|
|
|
|
function NavIcon({ name }: { name: NavIconName }) {
|
|
return (
|
|
<svg
|
|
className="nav-icon"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
focusable="false"
|
|
>
|
|
{NAV_ICONS[name]}
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
const NAV_ITEMS = [
|
|
{ to: '/', key: 'nav.month', end: true, icon: 'month' },
|
|
{ to: '/operations', key: 'nav.operations', end: false, icon: 'operations' },
|
|
{ to: '/budget', key: 'nav.budget', end: false, icon: 'budget' },
|
|
{ to: '/imports', key: 'nav.imports', end: false, icon: 'imports' },
|
|
{ to: '/review', key: 'nav.review', end: false, icon: 'review' },
|
|
{ to: '/analytics', key: 'nav.analytics', end: false, icon: 'analytics' },
|
|
{ to: '/accounts', key: 'nav.accounts', end: false, icon: 'accounts' },
|
|
{ to: '/settings', key: 'nav.settings', end: false, icon: 'settings' },
|
|
] as const
|
|
|
|
const MOBILE_NAV_ROUTES: readonly string[] = ['/', '/operations', '/budget', '/imports', '/review', '/accounts']
|
|
const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) => MOBILE_NAV_ROUTES.includes(item.to))
|
|
|
|
export function AppLayout() {
|
|
const { t, i18n } = 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" lang={i18n.language}>
|
|
<a className="skip-link" href="#main">
|
|
{t('common.skipToContent')}
|
|
</a>
|
|
<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">
|
|
<NavIcon name={item.icon} />
|
|
{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>
|
|
<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}>
|
|
<NavIcon name={item.icon} />
|
|
{t(item.key)}
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
)
|
|
}
|