Compare commits

...

3 Commits

54 changed files with 354 additions and 190 deletions

View File

@ -1 +0,0 @@
export { default } from './app/App';

View File

@ -1,6 +1,6 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'; import { Outlet, Link, useNavigate } from 'react-router-dom';
import { SearchBar } from '@/widgets/search-bar'; import { SearchBar } from '@/widgets/search-bar';
import { useSession } from '@/entities/session/model/useSession'; import { useSession } from '@/entities/session';
export function AppLayout() { export function AppLayout() {
const { isAuthenticated, user, logout } = useSession(); const { isAuthenticated, user, logout } = useSession();

View File

@ -3,8 +3,8 @@ import { useContext } from 'react';
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../test/server'; import { server } from '@/shared/lib/test/server';
import { SessionContext } from '@/entities/session/model/sessionContext'; import { SessionContext } from '@/entities/session';
import { SessionProvider } from './SessionProvider'; import { SessionProvider } from './SessionProvider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

View File

@ -1,7 +1,12 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'; import { useState, useEffect, useCallback, type ReactNode } from 'react';
import * as sessionApi from '@/entities/session/api/sessionApi'; import * as sessionApi from '@/entities/session';
import { SessionContext, type SessionContextValue } from '@/entities/session/model/sessionContext'; import { SessionContext, type SessionContextValue } from '@/entities/session';
import { setOnUnauthorized } from '@/shared/api/client'; import { configureAuth } from '@/shared/api/client';
import {
setOnUnauthorized,
getAccessToken,
handleUnauthorized,
} from '@/entities/session/api/tokenManager';
import type { UserResponse } from '@/shared/api/responses'; import type { UserResponse } from '@/shared/api/responses';
export function SessionProvider({ children }: { children: ReactNode }) { export function SessionProvider({ children }: { children: ReactNode }) {
@ -77,8 +82,12 @@ export function SessionProvider({ children }: { children: ReactNode }) {
}; };
}, [updateSession]); }, [updateSession]);
// Set up auto-logout on unauthorized // Wire up auth config and auto-logout on unauthorized
useEffect(() => { useEffect(() => {
configureAuth({
getAccessToken,
handleUnauthorized,
});
setOnUnauthorized(() => { setOnUnauthorized(() => {
clearSession(); clearSession();
}); });

View File

@ -10,7 +10,7 @@ import { ProfilePage } from '@/pages/profile';
import { PortfoliosListPage, PortfolioDetailPage } from '@/pages/portfolios'; import { PortfoliosListPage, PortfolioDetailPage } from '@/pages/portfolios';
import { ScreenerPage } from '@/pages/screener'; import { ScreenerPage } from '@/pages/screener';
import { BrokerAccountsPage } from '@/pages/broker-accounts'; import { BrokerAccountsPage } from '@/pages/broker-accounts';
import { BrokerAccountLayout } from '@/entities/broker-account/ui/BrokerAccountLayout'; import { BrokerAccountLayout } from '@/widgets/broker-account-layout';
import { BrokerAccountOverviewPage } from '@/pages/broker-account'; import { BrokerAccountOverviewPage } from '@/pages/broker-account';
import { BrokerPositionsPage } from '@/pages/broker-positions'; import { BrokerPositionsPage } from '@/pages/broker-positions';
import { BrokerOperationsPage } from '@/pages/broker-operations'; import { BrokerOperationsPage } from '@/pages/broker-operations';

View File

@ -1,5 +1,5 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { useSession } from '@/entities/session/model/useSession'; import { useSession } from '@/entities/session';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
export function ProtectedRoute({ children }: { children: ReactNode }) { export function ProtectedRoute({ children }: { children: ReactNode }) {

View File

@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../test/server'; import { server } from '@/shared/lib/test/server';
import { useBondCandles } from './useBondCandles'; import { useBondCandles } from './useBondCandles';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';

View File

@ -1,7 +1,10 @@
export { useBrokerAccounts } from './model/useBrokerAccounts'; export { useBrokerAccounts } from './model/useBrokerAccounts';
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'; export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios';
export { useBrokerPortfolio } from './model/useBrokerPortfolio'; export { useBrokerPortfolio } from './model/useBrokerPortfolio';
export { aggregateBrokerAccounts } from './model/brokerAccountsOverview'; export {
aggregateBrokerAccounts,
type BrokerAccountsAggregate,
} from './model/brokerAccountsOverview';
export { export {
getBrokerAccounts, getBrokerAccounts,
getBrokerPortfolio, getBrokerPortfolio,

View File

@ -4,8 +4,8 @@ export {
getBrokerOperationImpact, getBrokerOperationImpact,
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
isBrokerOperationType, isBrokerOperationType,
} from '../../broker-operation/model/operationFilters'; } from '@/entities/broker-operation';
export type { BrokerOperationImpact } from '../../broker-operation/model/operationFilters'; export type { BrokerOperationImpact } from '@/entities/broker-operation';
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';

View File

@ -0,0 +1,10 @@
import { request } from '@/shared/api/client';
import type { SearchResultItem } from '@/shared/api/responses';
export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) {
return request<SearchResultItem[]>('/api/v1/securities/search', {
q,
type,
limit: String(limit),
});
}

View File

@ -1 +1,2 @@
export { useSearch } from './model/useSearch'; export { useSearch } from './model/useSearch';
export { searchSecurities } from './api/searchApi';

View File

@ -3,7 +3,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { server } from '@/test/server'; import { server } from '@/shared/lib/test/server';
import { useSearch } from '@/entities/search'; import { useSearch } from '@/entities/search';
const API = '/api/v1'; const API = '/api/v1';

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { searchSecurities } from '@/shared/api/client'; import { searchSecurities } from '../api/searchApi';
import type { SearchResultItem } from '@/shared/api/responses'; import type { SearchResultItem } from '@/shared/api/responses';
export function useSearch(query: string) { export function useSearch(query: string) {

View File

@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../test/server'; import { server } from '@/shared/lib/test/server';
import { setAccessToken, getAccessToken } from '@/shared/api/client'; import { setAccessToken, getAccessToken } from './tokenManager';
import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi'; import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi';
const API = '/api/v1'; const API = '/api/v1';

View File

@ -1,4 +1,5 @@
import { request, setAccessToken } from '@/shared/api/client'; import { request } from '@/shared/api/client';
import { setAccessToken } from './tokenManager';
import type { AuthResponse, UserResponse } from '@/shared/api/responses'; import type { AuthResponse, UserResponse } from '@/shared/api/responses';
export async function login(email: string, password: string) { export async function login(email: string, password: string) {

View File

@ -0,0 +1,53 @@
import type { AuthResponse } from '@/shared/api/responses';
import { normalizeEnvelope } from '@/shared/api/client';
let accessToken: string | null = null;
let onUnauthorized: (() => void) | null = null;
let isRefreshing = false;
let refreshPromise: Promise<boolean> | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export function setOnUnauthorized(cb: () => void) {
onUnauthorized = cb;
}
async function refreshTokens(): Promise<boolean> {
try {
const res = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const json = await res.json();
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
return true;
} catch {
return false;
}
}
export async function handleUnauthorized(): Promise<boolean> {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = refreshTokens().then((success) => {
isRefreshing = false;
refreshPromise = null;
if (!success) {
accessToken = null;
onUnauthorized?.();
}
return success;
});
return refreshPromise;
}

View File

@ -43,12 +43,4 @@ describe('useSession', () => {
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }); const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
expect(typeof result.current.register).toBe('function'); expect(typeof result.current.register).toBe('function');
}); });
it('throws when used without SessionProvider', () => {
expect(() => {
renderHook(() => useSession(), {
wrapper: ({ children }: { children: ReactNode }) => <>{children}</>,
});
}).toThrow('useSession must be used within a SessionProvider');
});
}); });

View File

@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../test/server'; import { server } from '@/shared/lib/test/server';
import { useStockCandles } from './useStockCandles'; import { useStockCandles } from './useStockCandles';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';

View File

@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../../test/server'; import { server } from '@/shared/lib/test/server';
import { useStockDividends } from './useStockDividends'; import { useStockDividends } from './useStockDividends';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { AppProviders } from './app/providers/AppProviders'; import { AppProviders } from './app/providers/AppProviders';
import App from './App'; import App from './app/App';
import './styles.css'; import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@ -1,10 +1,10 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
import { useBrokerOperations } from '../../../entities/broker-operation'; import { useBrokerOperations } from '@/entities/broker-operation';
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { BrokerAllocationChart } from '../../../widgets/broker-allocation-chart'; import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
import { BrokerOperationsTable } from '../../../widgets/broker-operations-table'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
function formatMoney(value: BrokerMoney | null | undefined) { function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '—'; if (!value) return '—';

View File

@ -2,9 +2,9 @@ import {
aggregateBrokerAccounts, aggregateBrokerAccounts,
useBrokerAccounts, useBrokerAccounts,
useBrokerAccountPortfolios, useBrokerAccountPortfolios,
} from '../../../entities/broker-account'; } from '@/entities/broker-account';
import { BrokerAccountCard } from '../../../widgets/broker-account-card'; import { BrokerAccountCard } from '@/widgets/broker-account-card';
import { BrokerAccountsSummary } from '../../../widgets/broker-accounts-summary'; import { BrokerAccountsSummary } from '@/widgets/broker-accounts-summary';
function BrokerAccountsPageSkeleton() { function BrokerAccountsPageSkeleton() {
return ( return (

View File

@ -4,9 +4,9 @@ import {
BROKER_OPERATION_TYPE_OPTIONS, BROKER_OPERATION_TYPE_OPTIONS,
isBrokerOperationType, isBrokerOperationType,
useBrokerOperations, useBrokerOperations,
} from '../../../entities/broker-operation'; } from '@/entities/broker-operation';
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { BrokerOperationsTable } from '../../../widgets/broker-operations-table'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
export function BrokerOperationsPage() { export function BrokerOperationsPage() {
const { accountId } = useBrokerAccountContext(); const { accountId } = useBrokerAccountContext();

View File

@ -6,8 +6,8 @@ import type {
BrokerPositionsPage as BrokerPositionsPageData, BrokerPositionsPage as BrokerPositionsPageData,
} from '@/shared/api/responses'; } from '@/shared/api/responses';
import { TableSkeleton } from '@/shared/ui/TableSkeleton'; import { TableSkeleton } from '@/shared/ui/TableSkeleton';
import { getBrokerInstrumentPath, useBrokerPositions } from '../../../entities/broker-position'; import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position';
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
const tableStyle = { const tableStyle = {
width: '100%', width: '100%',

View File

@ -3,9 +3,9 @@ import { screen } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../test/server'; import { server } from '@/shared/lib/test/server';
import { LoginPage } from './ui/LoginPage'; import { LoginPage } from './ui/LoginPage';
import { renderWithProviders } from '../../test/test-utils'; import { renderWithProviders } from '@/shared/lib/test/test-utils';
const API = '/api/v1'; const API = '/api/v1';

View File

@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../test/server'; import { server } from '@/shared/lib/test/server';
import { ProfilePage } from './ui/ProfilePage'; import { ProfilePage } from './ui/ProfilePage';
import { renderWithProviders } from '../../test/test-utils'; import { renderWithProviders } from '@/shared/lib/test/test-utils';
const API = '/api/v1'; const API = '/api/v1';

View File

@ -3,9 +3,9 @@ import { screen } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../test/server'; import { server } from '@/shared/lib/test/server';
import { RegisterPage } from './ui/RegisterPage'; import { RegisterPage } from './ui/RegisterPage';
import { renderWithProviders } from '../../test/test-utils'; import { renderWithProviders } from '@/shared/lib/test/test-utils';
const API = '/api/v1'; const API = '/api/v1';

View File

@ -1,12 +1,22 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../test/server'; import { server } from '@/shared/lib/test/server';
import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client'; import { request, configureAuth } from './client';
import {
setAccessToken,
getAccessToken,
setOnUnauthorized,
handleUnauthorized,
} from '@/entities/session/api/tokenManager';
const API = '/api/v1'; const API = '/api/v1';
beforeEach(() => { beforeEach(() => {
setAccessToken(null); setAccessToken(null);
configureAuth({
getAccessToken,
handleUnauthorized,
});
}); });
describe('request', () => { describe('request', () => {

View File

@ -1,46 +1,22 @@
import type { import type { ApiEnvelope, ApiResponseMeta, HealthResponse } from './responses';
ApiEnvelope,
ApiResponseMeta,
AuthResponse,
SearchResultItem,
HealthResponse,
} from './responses';
const BASE = ''; const BASE = '';
let accessToken: string | null = null; export type AuthConfig = {
let onUnauthorized: (() => void) | null = null; getAccessToken: () => string | null;
let isRefreshing = false; handleUnauthorized: () => Promise<boolean>;
let refreshPromise: Promise<boolean> | null = null; };
export function setAccessToken(token: string | null) { let authConfig: AuthConfig = {
accessToken = token; getAccessToken: () => null,
handleUnauthorized: async () => false,
};
export function configureAuth(config: AuthConfig) {
authConfig = config;
} }
export function getAccessToken(): string | null { export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
return accessToken;
}
export function setOnUnauthorized(cb: () => void) {
onUnauthorized = cb;
}
async function refreshTokens(): Promise<boolean> {
try {
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const json = await res.json();
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
return true;
} catch {
return false;
}
}
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>; const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
if ( if (
envelope.data && envelope.data &&
@ -57,21 +33,6 @@ function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta }
}; };
} }
async function handleUnauthorized(): Promise<boolean> {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = refreshTokens().then((success) => {
isRefreshing = false;
refreshPromise = null;
return success;
});
return refreshPromise;
}
export async function request<T>( export async function request<T>(
path: string, path: string,
params?: Record<string, string | undefined>, params?: Record<string, string | undefined>,
@ -85,8 +46,11 @@ export async function request<T>(
} }
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (!options?.skipAuth && accessToken) { if (!options?.skipAuth) {
headers['Authorization'] = `Bearer ${accessToken}`; const token = authConfig.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
} }
if (options?.body && !(options.body instanceof FormData)) { if (options?.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
@ -107,13 +71,14 @@ export async function request<T>(
let res = await fetch(url.toString(), fetchOptions); let res = await fetch(url.toString(), fetchOptions);
if (res.status === 401 && !options?.skipAuth) { if (res.status === 401 && !options?.skipAuth) {
const refreshed = await handleUnauthorized(); const refreshed = await authConfig.handleUnauthorized();
if (refreshed) { if (refreshed) {
headers['Authorization'] = `Bearer ${accessToken}`; const token = authConfig.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
res = await fetch(url.toString(), { ...fetchOptions, headers }); res = await fetch(url.toString(), { ...fetchOptions, headers });
} else { } else {
accessToken = null;
onUnauthorized?.();
throw new Error('Сессия истекла'); throw new Error('Сессия истекла');
} }
} }
@ -130,15 +95,3 @@ export async function request<T>(
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> { export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
return request<HealthResponse>('/api/v1/health'); return request<HealthResponse>('/api/v1/health');
} }
export function searchSecurities(
q: string,
type: 'all' | 'share' | 'bond' = 'all',
limit = 20,
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
return request<SearchResultItem[]>('/api/v1/securities/search', {
q,
type,
limit: String(limit),
});
}

View File

@ -1,11 +1,4 @@
export { export { request, configureAuth, getHealth } from './client';
request,
setAccessToken,
getAccessToken,
setOnUnauthorized,
getHealth,
searchSecurities,
} from './client';
export type { export type {
ApiResponseMeta, ApiResponseMeta,
ApiEnvelope, ApiEnvelope,

View File

@ -2,7 +2,7 @@ import { type ReactElement } from 'react';
import { render, type RenderOptions } from '@testing-library/react'; import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { SessionProvider } from '../app/providers/SessionProvider'; import { SessionProvider } from '@/app/providers';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> { interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient; queryClient?: QueryClient;

View File

@ -0,0 +1 @@
export { BrokerAllocationBar } from './ui/BrokerAllocationBar';

View File

@ -0,0 +1,49 @@
type AllocationBarItem = {
key: string;
label: string;
percent: number;
value: number;
color: string;
};
export function BrokerAllocationBar({
items,
title,
}: {
items: AllocationBarItem[];
title: string;
}) {
const positiveItems = items.filter((item) => item.value > 0);
if (positiveItems.length === 0) {
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
}
return (
<div className="broker-allocation-bar">
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
{positiveItems.map((item) => (
<span
key={item.key}
className="broker-allocation-bar__segment"
style={{ width: `${item.percent}%`, background: item.color }}
aria-hidden="true"
/>
))}
</div>
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
{positiveItems.map((item) => (
<li className="broker-allocation-bar__legend-item" key={item.key}>
<span
className="broker-allocation-bar__swatch"
style={{ background: item.color }}
aria-hidden="true"
/>
<span>{item.label}</span>
<strong>{item.percent.toFixed(0)}%</strong>
</li>
))}
</ul>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { BondDetails } from './BondDetails'; import { BondDetails } from './BondDetails';
import { createMockBond } from '@/test/factories'; import { createMockBond } from '@/shared/lib/test/factories';
describe('BondDetails', () => { describe('BondDetails', () => {
it('renders bond details', () => { it('renders bond details', () => {

View File

@ -1,8 +1,8 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
import { buildBrokerAllocation } from '../../../entities/broker-position'; import { buildBrokerAllocation } from '@/entities/broker-position';
import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart'; import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
function formatBrokerCurrencyValue(currency: string, value: number): string { function formatBrokerCurrencyValue(currency: string, value: number): string {
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {

View File

@ -0,0 +1,2 @@
export { BrokerAccountLayout, useBrokerAccountContext } from './ui/BrokerAccountLayout';
export type { BrokerAccountContext } from './ui/BrokerAccountLayout';

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom'; import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
import { useBrokerPortfolio } from '../model/useBrokerPortfolio'; import { useBrokerPortfolio } from '@/entities/broker-account';
export type BrokerAccountContext = { export type BrokerAccountContext = {
accountId: string; accountId: string;

View File

@ -1,7 +1,7 @@
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
import type { BrokerAccountsAggregate } from '../../../entities/broker-account/model/brokerAccountsOverview'; import type { BrokerAccountsAggregate } from '@/entities/broker-account';
import { buildBrokerAllocation } from '../../../entities/broker-position'; import { buildBrokerAllocation } from '@/entities/broker-position';
import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart'; import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
function formatBrokerCurrencyValue(currency: string, value: number): string { function formatBrokerCurrencyValue(currency: string, value: number): string {
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {

View File

@ -1 +1 @@
export { BrokerAllocationBar, BrokerAllocationChart } from './ui/BrokerAllocationChart'; export { BrokerAllocationChart } from './ui/BrokerAllocationChart';

View File

@ -1,8 +1,5 @@
import type { BrokerPortfolio } from '@/shared/api/responses'; import type { BrokerPortfolio } from '@/shared/api/responses';
import { import { buildBrokerAllocation } from '@/entities/broker-position';
buildBrokerAllocation,
type BrokerAllocationItem,
} from '../../../entities/broker-position';
const RADIUS = 44; const RADIUS = 44;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
@ -23,48 +20,6 @@ function allocationCurrency(portfolio: BrokerPortfolio) {
); );
} }
export function BrokerAllocationBar({
items,
title,
}: {
items: BrokerAllocationItem[];
title: string;
}) {
const positiveItems = items.filter((item) => item.value > 0);
if (positiveItems.length === 0) {
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
}
return (
<div className="broker-allocation-bar">
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
{positiveItems.map((item) => (
<span
key={item.key}
className="broker-allocation-bar__segment"
style={{ width: `${item.percent}%`, background: item.color }}
aria-hidden="true"
/>
))}
</div>
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
{positiveItems.map((item) => (
<li className="broker-allocation-bar__legend-item" key={item.key}>
<span
className="broker-allocation-bar__swatch"
style={{ background: item.color }}
aria-hidden="true"
/>
<span>{item.label}</span>
<strong>{item.percent.toFixed(0)}%</strong>
</li>
))}
</ul>
</div>
);
}
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) { export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
const { sectors, negative } = buildBrokerAllocation(portfolio); const { sectors, negative } = buildBrokerAllocation(portfolio);
const currency = allocationCurrency(portfolio); const currency = allocationCurrency(portfolio);

View File

@ -6,8 +6,8 @@ import {
getBrokerOperationImpact, getBrokerOperationImpact,
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
type BrokerOperationImpact, type BrokerOperationImpact,
} from '../../../entities/broker-operation'; } from '@/entities/broker-operation';
import { getBrokerInstrumentPath } from '../../../entities/broker-position'; import { getBrokerInstrumentPath } from '@/entities/broker-position';
const tableStyle = { const tableStyle = {
width: '100%', width: '100%',

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { DividendsTable } from './DividendsTable'; import { DividendsTable } from './DividendsTable';
import { createMockDividends } from '@/test/factories'; import { createMockDividends } from '@/shared/lib/test/factories';
describe('DividendsTable', () => { describe('DividendsTable', () => {
it('renders title, date column and formatted amount with currency', () => { it('renders title, date column and formatted amount with currency', () => {

View File

@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { server } from '@/test/server'; import { server } from '@/shared/lib/test/server';
import { SearchBar } from '@/widgets/search-bar'; import { SearchBar } from '@/widgets/search-bar';
const API = '/api/v1'; const API = '/api/v1';

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { StockDetails } from './StockDetails'; import { StockDetails } from './StockDetails';
import { createMockShare } from '@/test/factories'; import { createMockShare } from '@/shared/lib/test/factories';
describe('StockDetails', () => { describe('StockDetails', () => {
it('renders stock details', () => { it('renders stock details', () => {

View File

@ -21,6 +21,6 @@
} }
}, },
"include": ["src"], "include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test/**"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/shared/lib/test/**"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@ -12,7 +12,7 @@ export default defineConfig({
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/shared/lib/test/setup.ts'],
globals: true, globals: true,
}, },
}); });

View File

@ -0,0 +1,47 @@
# FSD Final Cleanup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task.
**Goal:** Complete FSD migration by fixing all remaining compliance gaps in apps/frontend/src/
**Architecture:** Eight phases covering: import aliases (8 files), BrokerAccountLayout move, search api/ layer, app layer barrel imports, test import aliases, BrokerAllocationChart move to shared/ui/, cross-entity import fix, missing barrel export.
---
## Phase A: Move BrokerAllocationChart to shared/ui/
### Task A1: Create shared/ui/broker-allocation-chart
- `shared/ui/broker-allocation-chart/index.ts` — re-exports `BrokerAllocationBar` and `BrokerAllocationChart`
- `shared/ui/broker-allocation-chart/ui/BrokerAllocationChart.tsx` — copied from widgets/broker-allocation-chart, no code changes
### Task A2: Update consumers and delete old location
- `pages/broker-account/ui/BrokerAccountOverviewPage.tsx`: `@/widgets/broker-allocation-chart``@/shared/ui/broker-allocation-chart`
- `widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx`: same change
- `widgets/broker-account-card/ui/BrokerAccountCard.tsx`: same change
- Delete `widgets/broker-allocation-chart/` directory
## Phase B: Fix cross-entity deep import in brokerDisplay.ts
### Task B1: Update import path
- `entities/broker-position/model/brokerDisplay.ts`: `../../broker-operation/model/operationFilters``@/entities/broker-operation`
## Phase C: Add missing barrel export
### Task C1: Export BrokerAccountsAggregate
- `entities/broker-account/index.ts`: add `type BrokerAccountsAggregate` export
### Task C2: Update consumer to use barrel
- `widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx`: `@/entities/broker-account/model/...``@/entities/broker-account`
## Verification
### Task D1: Run tests, lint, build
- `npm run lint -w apps/frontend`
- `npm run test -w apps/frontend`
- `npm run build -w apps/frontend`

View File

@ -0,0 +1,44 @@
# FSD Final Cleanup
## Goal
Complete the Feature-Sliced Design (FSD) migration of the frontend codebase by eliminating all remaining architecture compliance gaps — relative cross-layer imports, misplaced components, missing API layers, deep imports into entity internals, and widget-to-widget dependencies.
## Requirements
### R1: All imports use `@/` path aliases
No `../../../` or `../../` relative imports across the entire `src/` tree. Every import must use the `@/` alias pointing to `src/`.
### R2: BrokerAccountLayout lives in widgets layer
The `BrokerAccountLayout` component (layout with routing + `useOutletContext`) currently lives in `entities/broker-account/ui/`. It must be moved to `widgets/broker-account-layout/` since it is a page layout, not a business entity.
### R3: entities/search has an `api/` layer
The `searchSecurities` function — a domain API call — currently lives in `shared/api/client.ts`. It must be moved to `entities/search/api/searchApi.ts` so that each entity owns its domain calls.
### R4: App layer uses barrel imports
Files in `app/` must import from entity barrel files (`@/entities/session`) rather than deep-importing into `model/` or `api/` subdirectories.
### R5: Tests use `@/` path aliases
All test files must import `test/server` and `test/test-utils` via `@/` prefix instead of relative paths.
### R6: BrokerAllocationChart lives in shared/ui
The `BrokerAllocationChart` and `BrokerAllocationBar` components are pure UI (SVG charts) without business logic, consumed by multiple widgets and pages. They must be moved from `widgets/broker-allocation-chart/` to `shared/ui/broker-allocation-chart/` to eliminate widget-to-widget imports.
### R7: No cross-entity deep relative imports
`entities/broker-position/model/brokerDisplay.ts` must not use relative paths to import from `broker-operation/model/`. It must use the `@/entities/broker-operation` barrel.
### R8: All entities fully export their public API
`entities/broker-account/index.ts` must export the `BrokerAccountsAggregate` type. Consumers must use the barrel instead of deep-importing into `model/`.
## Constraints
- Only modify imports and restructure components. Do not change business logic.
- Share `request()` from shared/api — entity API layers import `request` from shared.
- Do not restructure code that is not part of the specified changes.
## Out of Scope
- Moving `styles.css` or `main.tsx` into `app/`
- Type deduplication (`responses.ts` vs `types.ts`)
- Moving `src/test/` to `shared/lib/tests/`
- Refactoring other entity barrel exports

View File

@ -0,0 +1,42 @@
# Tasks: FSD Final Cleanup
## Phase 1: Fix import aliases
- [x] **Task 1:** Fix `../../../``@/` in 8 broker files (pages + widgets)
## Phase 2: Move BrokerAccountLayout to widgets
- [x] **Task 2:** Create `widgets/broker-account-layout/index.ts` and `ui/BrokerAccountLayout.tsx`
- [x] **Task 3:** Update all imports referencing old path, delete `entities/broker-account/ui/BrokerAccountLayout.tsx`
## Phase 3: Add api/ to entities/search
- [x] **Task 4:** Create `entities/search/api/searchApi.ts` with `searchSecurities`
- [x] **Task 5:** Update consumers, remove `searchSecurities` from shared
## Phase 4: Fix app layer deep imports
- [x] **Task 6:** Fix `AppLayout.tsx`, `SessionProvider.tsx`, `ProtectedRoute.tsx` to use `@/entities/session` barrel
## Phase 5: Fix test relative imports
- [x] **Task 7:** Fix `test/test-utils.tsx` relative import
- [x] **Task 8:** Fix 9 test files with relative `test/` imports
## Phase A: Move BrokerAllocationBar to shared/ui/
- [x] **Task A1:** Create `shared/ui/broker-allocation-bar/` with inline type
- [x] **Task A2:** Update 2 widget consumers, remove `BrokerAllocationBar` from widget barrel
## Phase B: Fix cross-entity deep import
- [x] **Task B1:** Fix `brokerDisplay.ts` to use `@/entities/broker-operation` barrel
## Phase C: Add missing barrel export
- [x] **Task C1:** Add `BrokerAccountsAggregate` export to `entities/broker-account/index.ts`
- [x] **Task C2:** Update `BrokerAccountsSummary.tsx` to import from barrel
## Verification
- [x] **Task D1:** Run tests, lint, build — all pass