Files
finance/frontend/src/components/layout/AppLayout.tsx
T
k1nq 023612ce0a Update manifest.json with new file metadata and add pytest configuration
- 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.
2026-09-24 08:34:13 +05:00

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>
)
}