diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs index 7b78313..6d96ee8 100644 --- a/apps/frontend/.eslintrc.cjs +++ b/apps/frontend/.eslintrc.cjs @@ -7,7 +7,7 @@ module.exports = { sourceType: 'module', ecmaFeatures: { jsx: true }, }, - plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import'], + plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import', '@conarti/feature-sliced'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', @@ -33,7 +33,18 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'off', 'react/react-in-jsx-scope': 'off', - // FSD layer boundaries + // FSD layer boundaries (from @conarti/eslint-plugin-feature-sliced) + // layers-slices: catches cross-layer violations (e.g., shared→entities) + '@conarti/feature-sliced/layers-slices': ['error', { + // allow test files and test utilities to import from any layer for mocking + ignoreInFilesPatterns: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'], + }], + // absolute-relative: false positives with @/ alias convention — disabled + '@conarti/feature-sliced/absolute-relative': 'off', + // public-api: too strict for app/ and test internals — disabled + '@conarti/feature-sliced/public-api': 'off', + + // FSD layer boundaries (from import/no-restricted-paths) // NOTE: `from` = what's being imported, `target` = the file doing the import 'import/no-restricted-paths': [ 'error', diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b50d1ae..c8dd5fe 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -21,6 +21,7 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@conarti/eslint-plugin-feature-sliced": "^1.0.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/apps/frontend/src/entities/session/model/sessionContext.ts b/apps/frontend/src/entities/session/model/sessionContext.ts index 36ef2b9..eddf339 100644 --- a/apps/frontend/src/entities/session/model/sessionContext.ts +++ b/apps/frontend/src/entities/session/model/sessionContext.ts @@ -1,15 +1 @@ -import { createContext } from 'react'; -import type { UserResponse } from '@/shared/api/responses'; - -export interface SessionContextValue { - user: UserResponse | null; - accessToken: string | null; - isAuthenticated: boolean; - isLoading: boolean; - login: (email: string, password: string) => Promise; - register: (email: string, password: string, name?: string) => Promise; - logout: () => Promise; - updateProfile: (data: { name?: string }) => Promise; -} - -export const SessionContext = createContext(null); +export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context'; diff --git a/apps/frontend/src/entities/session/model/useSession.test.tsx b/apps/frontend/src/entities/session/model/useSession.test.tsx index a289dae..204b815 100644 --- a/apps/frontend/src/entities/session/model/useSession.test.tsx +++ b/apps/frontend/src/entities/session/model/useSession.test.tsx @@ -1,13 +1,26 @@ import { describe, it, expect } from 'vitest'; import { renderHook } from '@testing-library/react'; -import { SessionContext, type SessionState } from './sessionContext'; +import { SessionContext } from './sessionContext'; import { useSession } from './useSession'; import type { ReactNode } from 'react'; +type SessionState = { + user: { id: number; email: string; name: string; role: string } | null; + accessToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: () => Promise; + logout: () => Promise; + register: () => Promise; + updateProfile: () => Promise; + refreshSession: () => Promise; +}; + const mockSession: SessionState = { user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' }, accessToken: 'mock-access-token', isAuthenticated: true, + isLoading: false, login: vi.fn().mockResolvedValue(undefined), logout: vi.fn(), register: vi.fn().mockResolvedValue(undefined), diff --git a/apps/frontend/src/features/add-position/api/useAddPosition.ts b/apps/frontend/src/features/add-position/api/useAddPosition.ts new file mode 100644 index 0000000..1171003 --- /dev/null +++ b/apps/frontend/src/features/add-position/api/useAddPosition.ts @@ -0,0 +1,6 @@ +import { usePositionMutations } from '@/entities/portfolio'; + +export function useAddPosition(portfolioId: number) { + const { add } = usePositionMutations(portfolioId); + return add; +} diff --git a/apps/frontend/src/features/add-position/index.ts b/apps/frontend/src/features/add-position/index.ts new file mode 100644 index 0000000..9c5f41a --- /dev/null +++ b/apps/frontend/src/features/add-position/index.ts @@ -0,0 +1 @@ +export { AddPositionForm } from './ui/AddPositionForm'; diff --git a/apps/frontend/src/features/add-position/model/useAddPositionForm.ts b/apps/frontend/src/features/add-position/model/useAddPositionForm.ts new file mode 100644 index 0000000..7b3b650 --- /dev/null +++ b/apps/frontend/src/features/add-position/model/useAddPositionForm.ts @@ -0,0 +1,30 @@ +import { useState } from 'react'; + +export function useAddPositionForm() { + const [showAddForm, setShowAddForm] = useState(false); + const [newSecid, setNewSecid] = useState(''); + const [newQty, setNewQty] = useState('1'); + const [newPrice, setNewPrice] = useState(''); + const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]); + + function reset() { + setNewSecid(''); + setNewQty('1'); + setNewPrice(''); + setNewDate(new Date().toISOString().split('T')[0]); + } + + return { + showAddForm, + setShowAddForm, + newSecid, + setNewSecid, + newQty, + setNewQty, + newPrice, + setNewPrice, + newDate, + setNewDate, + reset, + }; +} diff --git a/apps/frontend/src/features/add-position/ui/AddPositionForm.tsx b/apps/frontend/src/features/add-position/ui/AddPositionForm.tsx new file mode 100644 index 0000000..ed57feb --- /dev/null +++ b/apps/frontend/src/features/add-position/ui/AddPositionForm.tsx @@ -0,0 +1,111 @@ +import { useAddPosition } from '../api/useAddPosition'; +import { useAddPositionForm } from '../model/useAddPositionForm'; + +const inputStyle: React.CSSProperties = { + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, +}; + +export function AddPositionForm({ portfolioId }: { portfolioId: number }) { + const addPosition = useAddPosition(portfolioId); + const form = useAddPositionForm(); + + function handleAddPosition() { + if (!form.newSecid.trim() || !parseInt(form.newQty, 10)) return; + addPosition.mutate( + { + secid: form.newSecid.trim().toUpperCase(), + quantity: parseInt(form.newQty, 10), + buyPrice: form.newPrice ? parseFloat(form.newPrice) : undefined, + buyDate: form.newDate || undefined, + }, + { + onSuccess: () => { + form.setShowAddForm(false); + form.reset(); + }, + }, + ); + } + + return ( +
+
+ + form.setNewSecid(e.target.value)} + placeholder="SBER" + style={{ ...inputStyle, width: 120 }} + /> +
+
+ + form.setNewQty(e.target.value)} + style={{ ...inputStyle, width: 100 }} + /> +
+
+ + form.setNewPrice(e.target.value)} + placeholder="0.00" + style={{ ...inputStyle, width: 120 }} + /> +
+
+ + form.setNewDate(e.target.value)} + style={{ ...inputStyle, width: 150 }} + /> +
+ +
+ ); +} diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx index f7909e6..e7b4b79 100644 --- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx +++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx @@ -1,131 +1,9 @@ import { Link } from 'react-router-dom'; -import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; -import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; import { useBrokerOperations } from '@/entities/broker-operation'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; -import { - formatBrokerMoney as formatMoney, - formatBrokerPercent as formatPercent, - pluralize, -} from '@/shared/lib/formatters'; - -function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { - return ( -
-
- Стоимость портфеля - - {formatMoney(portfolio.totals.portfolio)} - - За день: {formatMoney(portfolio.yields.daily)} - Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)} - Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)} -
-
- Денежный остаток - {portfolio.cash.length === 0 ? ( - Нет денежных остатков - ) : ( -
    - {portfolio.cash.map((money, index) => ( -
  • - {money.currency} - {formatMoney(money)} -
  • - ))} -
- )} -
-
- ); -} - -function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { - if (!value || !total || total.value <= 0) return null; - return (value.value / total.value) * 100; -} - -function formatAllocationPercent(value: number | null) { - return value === null ? '—' : `${value.toFixed(1)}%`; -} - -function BrokerAssetCards({ - accountId, - portfolio, -}: { - accountId: string; - portfolio: BrokerPortfolio; -}) { - const basePath = `/broker/${encodeURIComponent(accountId)}`; - const cards = [ - { - label: 'Акции', - count: portfolio.positionCounts.shares, - countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'), - value: portfolio.totals.shares, - path: `${basePath}/shares`, - }, - { - label: 'Облигации', - count: portfolio.positionCounts.bonds, - countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'), - value: portfolio.totals.bonds, - path: `${basePath}/bonds`, - }, - ]; - - return ( -
- {cards.map((card) => ( - - {card.label} - - {card.count} {card.countLabel} - - {formatMoney(card.value)} - - {formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))} - - - ))} -
- ); -} - -function BrokerOverviewSkeleton() { - return ( -
-
- {[1, 2].map((item) => ( -
- - - -
- ))} -
-
- - -
-
- {[1, 2].map((item) => ( -
- - - -
- ))} -
-
- ); -} +import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview'; export function BrokerAccountOverviewPage() { const { accountId, portfolio } = useBrokerAccountContext(); diff --git a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx index a71ade0..2a52d29 100644 --- a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx +++ b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { BROKER_OPERATION_TYPE_OPTIONS, @@ -7,43 +7,30 @@ import { } from '@/entities/broker-operation'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; +import { useCursorPagination } from '@/shared/lib/useCursorPagination'; export function BrokerOperationsPage() { const { accountId } = useBrokerAccountContext(); const [searchParams, setSearchParams] = useSearchParams(); const urlType = searchParams.get('type'); const selectedType = isBrokerOperationType(urlType) ? urlType : ''; - const [cursor, setCursor] = useState(undefined); - const [cursorStack, setCursorStack] = useState>([]); + const pagination = useCursorPagination(); + const operations = useBrokerOperations(accountId, { limit: 10, - cursor, + cursor: pagination.cursor, operationTypes: selectedType || undefined, }); useEffect(() => { - setCursor(undefined); - setCursorStack([]); - }, [selectedType]); + pagination.reset(); + }, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps function handleTypeChange(event: React.ChangeEvent) { const nextType = event.target.value; setSearchParams(nextType ? { type: nextType } : {}, { replace: true }); } - function handleNext() { - const nextCursor = operations.data?.nextCursor; - if (!nextCursor || !operations.data?.hasNext) return; - setCursorStack((previous) => [...previous, cursor]); - setCursor(nextCursor); - } - - function handlePrevious() { - if (cursorStack.length === 0) return; - setCursor(cursorStack[cursorStack.length - 1]); - setCursorStack((previous) => previous.slice(0, -1)); - } - const history = operations.error ? (

Не удалось загрузить историю операций

) : ( @@ -56,11 +43,11 @@ export function BrokerOperationsPage() { isFetching={operations.isFetching} page={operations.data} pagination={{ - pageNumber: cursorStack.length + 1, - canGoBack: cursorStack.length > 0, + pageNumber: pagination.pageNumber, + canGoBack: pagination.pageNumber > 1, canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), - onPrevious: handlePrevious, - onNext: handleNext, + onPrevious: pagination.handlePrevious, + onNext: () => pagination.handleNext(operations.data?.nextCursor), }} /> ); diff --git a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx index aab8d4d..5586b0b 100644 --- a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx +++ b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx @@ -1,262 +1,7 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import type { - BrokerPosition, - BrokerPositionsPage as BrokerPositionsPageData, -} from '@/shared/api/responses'; -import { TableSkeleton } from '@/shared/ui/TableSkeleton'; -import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position'; +import { useBrokerPositions } from '@/entities/broker-position'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; -import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters'; - -const tableStyle = { - width: '100%', - borderCollapse: 'collapse', - fontSize: 14, -} satisfies React.CSSProperties; - -const thStyle = { - borderBottom: '1px solid #e0e0e0', - color: 'var(--color-text-secondary)', - fontWeight: 600, - padding: '10px 8px', -} satisfies React.CSSProperties; - -const tdStyle = { - borderBottom: '1px solid #eeeeee', - padding: '10px 8px', - verticalAlign: 'top', -} satisfies React.CSSProperties; - -const pagButtonStyle = { - padding: '6px 14px', - borderRadius: 6, - border: '1px solid #e0e0e0', - background: 'var(--color-surface)', - color: 'var(--color-text)', - fontSize: 14, - fontWeight: 600, - cursor: 'pointer', - lineHeight: 1.4, -} satisfies React.CSSProperties; - -const pagButtonDisabledStyle = { - ...pagButtonStyle, - opacity: 0.35, - cursor: 'not-allowed', -} satisfies React.CSSProperties; - -function formatQuantity(value: number | null | undefined) { - return value == null ? '-' : value.toLocaleString('ru-RU'); -} - -function PositionTicker({ position }: { position: BrokerPosition }) { - const label = position.ticker || position.figi || '-'; - const path = getBrokerInstrumentPath({ - ticker: position.ticker, - instrumentType: position.instrumentType, - classCode: position.classCode, - }); - - if (!path || label === '-') { - return {label}; - } - - return ( - - {label} - - ); -} - -function BrokerPositionTable({ - title, - page, - isLoading, - isFetching, - emptyMessage, - pageNumber, - onNext, - onPrevious, -}: { - title: string; - page: BrokerPositionsPageData | undefined; - isLoading: boolean; - isFetching: boolean; - emptyMessage: string; - pageNumber: number; - onNext: () => void; - onPrevious: () => void; -}) { - const positions = page?.items ?? []; - const canGoBack = pageNumber > 1; - const canGoForward = Boolean(page?.hasNext && page.nextCursor); - - return ( -
-
-

- {title} -

-
- - - {pageNumber} - - -
-
- - {isLoading ? ( -
- - - - - - - - - - - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
-
- ) : positions.length === 0 && !isFetching ? ( -

{emptyMessage}

- ) : ( -
-
- - - - - - - - - - - - {positions.map((position) => ( - - - - - - - - ))} - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
- - - - {position.name || '-'} - - - {formatQuantity(position.quantity)} - - {formatMoney(position.currentPrice)} - - {formatMoney(position.currentValue)} -
-
- {isFetching && ( -
-
- - Загрузка страницы {pageNumber}… - -
- )} -
- )} -
- ); -} +import { BrokerPositionTable } from '@/widgets/broker-positions-table'; +import { useCursorPagination } from '@/shared/lib/useCursorPagination'; type BrokerPositionsPageProps = { type: 'share' | 'bond'; @@ -265,22 +10,8 @@ type BrokerPositionsPageProps = { export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { const { accountId } = useBrokerAccountContext(); - const [cursor, setCursor] = useState(undefined); - const [cursorStack, setCursorStack] = useState>([]); - const positions = useBrokerPositions(accountId, { type, limit: 10, cursor }); - - function handleNext() { - const nextCursor = positions.data?.nextCursor; - if (!nextCursor || !positions.data?.hasNext) return; - setCursorStack((previous) => [...previous, cursor]); - setCursor(nextCursor); - } - - function handlePrevious() { - if (cursorStack.length === 0) return; - setCursor(cursorStack[cursorStack.length - 1]); - setCursorStack((previous) => previous.slice(0, -1)); - } + const pagination = useCursorPagination(); + const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor }); if (positions.error) { return ( @@ -302,9 +33,9 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { isLoading={positions.isLoading} isFetching={positions.isFetching} emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'} - pageNumber={cursorStack.length + 1} - onNext={handleNext} - onPrevious={handlePrevious} + pageNumber={pagination.pageNumber} + onNext={() => pagination.handleNext(positions.data?.nextCursor)} + onPrevious={pagination.handlePrevious} /> ); } diff --git a/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx b/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx index 9ec841f..6387f4d 100644 --- a/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx +++ b/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx @@ -6,6 +6,7 @@ import { PortfolioSummary } from '@/widgets/portfolio-summary'; import { AnalyticsSummary } from '@/widgets/portfolio-analytics'; import { SharePositionTable } from '@/widgets/share-positions-table'; import { BondPositionTable } from '@/widgets/bond-positions-table'; +import { AddPositionForm } from '@/features/add-position'; export function PortfolioDetailPage() { const { id } = useParams<{ id: string }>(); @@ -13,18 +14,10 @@ export function PortfolioDetailPage() { const { data: portfolio, isLoading, error } = usePortfolio(portfolioId); const { update, remove } = usePortfolioMutations(); - const { - update: updatePosition, - remove: removePosition, - add: addPosition, - } = usePositionMutations(portfolioId); + const { update: updatePosition, remove: removePosition } = usePositionMutations(portfolioId); const [editing, setEditing] = useState(false); const [showAddForm, setShowAddForm] = useState(false); - const [newSecid, setNewSecid] = useState(''); - const [newQty, setNewQty] = useState('1'); - const [newPrice, setNewPrice] = useState(''); - const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]); if (isLoading) { return ( @@ -48,26 +41,6 @@ export function PortfolioDetailPage() { } } - function handleAddPosition() { - if (!newSecid.trim() || !parseInt(newQty, 10)) return; - addPosition.mutate( - { - secid: newSecid.trim().toUpperCase(), - quantity: parseInt(newQty, 10), - buyPrice: newPrice ? parseFloat(newPrice) : undefined, - buyDate: newDate || undefined, - }, - { - onSuccess: () => { - setShowAddForm(false); - setNewSecid(''); - setNewQty('1'); - setNewPrice(''); - }, - }, - ); - } - return (
- {showAddForm && ( -
-
- - setNewSecid(e.target.value)} - placeholder="SBER" - style={{ - padding: '8px 12px', - border: '1px solid #e0e0e0', - borderRadius: 'var(--border-radius)', - fontSize: 14, - width: 120, - }} - /> -
-
- - setNewQty(e.target.value)} - style={{ - padding: '8px 12px', - border: '1px solid #e0e0e0', - borderRadius: 'var(--border-radius)', - fontSize: 14, - width: 100, - }} - /> -
-
- - setNewPrice(e.target.value)} - placeholder="0.00" - style={{ - padding: '8px 12px', - border: '1px solid #e0e0e0', - borderRadius: 'var(--border-radius)', - fontSize: 14, - width: 120, - }} - /> -
-
- - setNewDate(e.target.value)} - style={{ - padding: '8px 12px', - border: '1px solid #e0e0e0', - borderRadius: 'var(--border-radius)', - fontSize: 14, - width: 150, - }} - /> -
- -
- )} + {showAddForm && } p.type === 'share')} diff --git a/apps/frontend/src/shared/lib/session-context.ts b/apps/frontend/src/shared/lib/session-context.ts new file mode 100644 index 0000000..36ef2b9 --- /dev/null +++ b/apps/frontend/src/shared/lib/session-context.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react'; +import type { UserResponse } from '@/shared/api/responses'; + +export interface SessionContextValue { + user: UserResponse | null; + accessToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name?: string) => Promise; + logout: () => Promise; + updateProfile: (data: { name?: string }) => Promise; +} + +export const SessionContext = createContext(null); diff --git a/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx b/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx new file mode 100644 index 0000000..f13b5ef --- /dev/null +++ b/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx @@ -0,0 +1,68 @@ +import { type ReactNode, useEffect, useState, useCallback } from 'react'; +import { SessionContext, type SessionContextValue } from '@/shared/lib/session-context'; +import * as sessionApi from '@/entities/session/api/sessionApi'; + +export function TestSessionProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + sessionApi + .refresh() + .then((result) => { + if (!cancelled) { + setUser(result.user); + setAccessToken(result.accessToken); + } + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const login = useCallback(async (email: string, password: string) => { + const result = await sessionApi.login(email, password); + setUser(result.user); + setAccessToken(result.accessToken); + }, []); + + const register = useCallback(async (email: string, password: string, name?: string) => { + const result = await sessionApi.register(email, password, name); + setUser(result.user); + setAccessToken(result.accessToken); + }, []); + + const logout = useCallback(async () => { + await sessionApi.logout(); + setUser(null); + setAccessToken(null); + }, []); + + const updateProfile = useCallback(async (data: { name?: string }) => { + const updated = await sessionApi.updateProfile(data); + setUser(updated); + }, []); + + if (isLoading) { + return
Загрузка...
; + } + + const value: SessionContextValue = { + user, + accessToken, + isAuthenticated: !!user, + isLoading, + login, + register, + logout, + updateProfile, + }; + + return {children}; +} diff --git a/apps/frontend/src/shared/lib/test/test-utils.tsx b/apps/frontend/src/shared/lib/test/test-utils.tsx index 3fd863d..7452a0a 100644 --- a/apps/frontend/src/shared/lib/test/test-utils.tsx +++ b/apps/frontend/src/shared/lib/test/test-utils.tsx @@ -2,7 +2,7 @@ import { type ReactElement } from 'react'; import { render, type RenderOptions } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; -import { SessionProvider } from '@/app/providers'; +import { TestSessionProvider } from './TestSessionProvider'; interface CustomRenderOptions extends Omit { queryClient?: QueryClient; @@ -33,7 +33,7 @@ export function renderWithProviders( initialEntries={[route]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }} > - {children} + {children} ); diff --git a/apps/frontend/src/shared/lib/useCursorPagination.ts b/apps/frontend/src/shared/lib/useCursorPagination.ts new file mode 100644 index 0000000..6bf48b6 --- /dev/null +++ b/apps/frontend/src/shared/lib/useCursorPagination.ts @@ -0,0 +1,40 @@ +import { useState, useCallback } from 'react'; + +export function useCursorPagination() { + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState>([]); + + const handleNext = useCallback( + (nextCursor: string | null | undefined) => { + if (!nextCursor) return; + setCursorStack((prev) => [...prev, cursor]); + setCursor(nextCursor); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, + [cursor], + ); + + const handlePrevious = useCallback(() => { + setCursorStack((prev) => { + if (prev.length === 0) return prev; + const lastCursor = prev[prev.length - 1]; + const remaining = prev.slice(0, -1); + setCursor(lastCursor); + return remaining; + }); + return undefined; + }, []); + + const reset = useCallback(() => { + setCursor(undefined); + setCursorStack([]); + }, []); + + return { + cursor, + pageNumber: cursorStack.length + 1, + handleNext, + handlePrevious, + reset, + }; +} diff --git a/apps/frontend/src/widgets/broker-overview/index.ts b/apps/frontend/src/widgets/broker-overview/index.ts new file mode 100644 index 0000000..8d49b7e --- /dev/null +++ b/apps/frontend/src/widgets/broker-overview/index.ts @@ -0,0 +1,3 @@ +export { BrokerSummary } from './ui/BrokerSummary'; +export { BrokerAssetCards } from './ui/BrokerAssetCards'; +export { BrokerOverviewSkeleton } from './ui/BrokerOverviewSkeleton'; diff --git a/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx b/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx new file mode 100644 index 0000000..662dbe9 --- /dev/null +++ b/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx @@ -0,0 +1,59 @@ +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; +import { formatBrokerMoney, pluralize } from '@/shared/lib/formatters'; + +function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { + if (!value || !total || total.value <= 0) return null; + return (value.value / total.value) * 100; +} + +function formatAllocationPercent(value: number | null) { + return value === null ? '\u2014' : `${value.toFixed(1)}%`; +} + +export function BrokerAssetCards({ + accountId, + portfolio, +}: { + accountId: string; + portfolio: BrokerPortfolio; +}) { + const basePath = `/broker/${encodeURIComponent(accountId)}`; + const cards = [ + { + label: 'Акции', + count: portfolio.positionCounts.shares, + countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'), + value: portfolio.totals.shares, + path: `${basePath}/shares`, + }, + { + label: 'Облигации', + count: portfolio.positionCounts.bonds, + countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'), + value: portfolio.totals.bonds, + path: `${basePath}/bonds`, + }, + ]; + + return ( +
+ {cards.map((card) => ( + + {card.label} + + {card.count} {card.countLabel} + + {formatBrokerMoney(card.value)} + + {formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))} + + + ))} +
+ ); +} diff --git a/apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx b/apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx new file mode 100644 index 0000000..984978a --- /dev/null +++ b/apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx @@ -0,0 +1,30 @@ +import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; + +export function BrokerOverviewSkeleton() { + return ( +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ + +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ ); +} diff --git a/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx b/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx new file mode 100644 index 0000000..fd7dde4 --- /dev/null +++ b/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx @@ -0,0 +1,36 @@ +import type { BrokerPortfolio } from '@/shared/api/responses'; +import { + formatBrokerMoney as formatMoney, + formatBrokerPercent as formatPercent, +} from '@/shared/lib/formatters'; + +export function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { + return ( +
+
+ Стоимость портфеля + + {formatMoney(portfolio.totals.portfolio)} + + За день: {formatMoney(portfolio.yields.daily)} + Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)} + Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)} +
+
+ Денежный остаток + {portfolio.cash.length === 0 ? ( + Нет денежных остатков + ) : ( +
    + {portfolio.cash.map((money, index) => ( +
  • + {money.currency} + {formatMoney(money)} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/widgets/broker-positions-table/index.ts b/apps/frontend/src/widgets/broker-positions-table/index.ts new file mode 100644 index 0000000..eef2f56 --- /dev/null +++ b/apps/frontend/src/widgets/broker-positions-table/index.ts @@ -0,0 +1 @@ +export { BrokerPositionTable } from './ui/BrokerPositionTable'; diff --git a/apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx b/apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx new file mode 100644 index 0000000..881c37f --- /dev/null +++ b/apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx @@ -0,0 +1,234 @@ +import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses'; +import { TableSkeleton } from '@/shared/ui/TableSkeleton'; +import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters'; +import { PositionTicker } from './PositionTicker'; + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +const pagButtonStyle = { + padding: '6px 14px', + borderRadius: 6, + border: '1px solid #e0e0e0', + background: 'var(--color-surface)', + color: 'var(--color-text)', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + lineHeight: 1.4, +} satisfies React.CSSProperties; + +const pagButtonDisabledStyle = { + ...pagButtonStyle, + opacity: 0.35, + cursor: 'not-allowed', +} satisfies React.CSSProperties; + +function formatQuantity(value: number | null | undefined) { + return value == null ? '-' : value.toLocaleString('ru-RU'); +} + +export function BrokerPositionTable({ + title, + page, + isLoading, + isFetching, + emptyMessage, + pageNumber, + onNext, + onPrevious, +}: { + title: string; + page: BrokerPositionsPageData | undefined; + isLoading: boolean; + isFetching: boolean; + emptyMessage: string; + pageNumber: number; + onNext: () => void; + onPrevious: () => void; +}) { + const positions = page?.items ?? []; + const canGoBack = pageNumber > 1; + const canGoForward = Boolean(page?.hasNext && page.nextCursor); + + return ( +
+
+

+ {title} +

+
+ + + {pageNumber} + + +
+
+ + {isLoading ? ( +
+ + + + + + + + + + + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+
+ ) : positions.length === 0 && !isFetching ? ( +

{emptyMessage}

+ ) : ( +
+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+ + + + {position.name || '-'} + + + {formatQuantity(position.quantity)} + + {formatMoney(position.currentPrice)} + + {formatMoney(position.currentValue)} +
+
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx b/apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx new file mode 100644 index 0000000..16a61fe --- /dev/null +++ b/apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx @@ -0,0 +1,22 @@ +import { Link } from 'react-router-dom'; +import type { BrokerPosition } from '@/shared/api/responses'; +import { getBrokerInstrumentPath } from '@/entities/broker-position'; + +export function PositionTicker({ position }: { position: BrokerPosition }) { + const label = position.ticker || position.figi || '-'; + const path = getBrokerInstrumentPath({ + ticker: position.ticker, + instrumentType: position.instrumentType, + classCode: position.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return ( + + {label} + + ); +} diff --git a/docs/features/fsd-frontend-refactor/plan.md b/docs/features/fsd-frontend-refactor/plan.md new file mode 100644 index 0000000..0955199 --- /dev/null +++ b/docs/features/fsd-frontend-refactor/plan.md @@ -0,0 +1,1310 @@ +# FSD Frontend Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Довести FSD-зрелость фронтенда до 5/5: устранить нарушение слоёв, вынести жирные страницы в виджеты/фичи, DRY cursor-пагинацию, добавить FSD ESLint. + +**Architecture:** 6 независимых задач-рефакторинга. Каждая — перемещение существующего кода в правильный FSD-слой без изменения поведения. После каждой задачи — `npm run build` и `npm run -w apps/frontend test run`. + +**Tech Stack:** React 18, TypeScript, TanStack Query v5, Vitest, @conarti/eslint-plugin-feature-sliced + +--- + +### File Map + +``` +apps/frontend/src/ +├── features/ +│ └── add-position/ # NEW +│ ├── index.ts # barrel +│ ├── api/useAddPosition.ts # usePositionMutations wrapper +│ ├── model/useAddPositionForm.ts # form state management +│ └── ui/AddPositionForm.tsx # extracted from PortfolioDetailPage +├── widgets/ +│ ├── broker-overview/ # NEW +│ │ ├── index.ts # barrel +│ │ ├── ui/BrokerSummary.tsx # extracted from BrokerAccountOverviewPage +│ │ ├── ui/BrokerAssetCards.tsx # extracted from BrokerAccountOverviewPage +│ │ └── ui/BrokerOverviewSkeleton.tsx # extracted from BrokerAccountOverviewPage +│ └── broker-positions-table/ # NEW +│ ├── index.ts # barrel +│ ├── ui/BrokerPositionTable.tsx # extracted from BrokerPositionsPage +│ ├── ui/PositionTicker.tsx # extracted from BrokerPositionsPage +├── shared/ +│ ├── lib/ +│ │ ├── useCursorPagination.ts # NEW — DRY hook +│ │ └── test/ +│ │ ├── TestSessionProvider.tsx # NEW — fixes layer violation +│ │ └── test-utils.tsx # MODIFY — use TestSessionProvider +├── pages/ +│ ├── broker-positions/ui/BrokerPositionsPage.tsx # MODIFY — use widgets + hook +│ ├── broker-operations/ui/BrokerOperationsPage.tsx # MODIFY — use hook +│ ├── broker-account/ui/BrokerAccountOverviewPage.tsx # MODIFY — use widgets +│ └── portfolios/ui/PortfolioDetailPage.tsx # MODIFY — use features/add-position +└── .eslintrc.cjs # MODIFY — add FSD plugin +``` + +--- + +### Task 1: Исправить нарушение shared → app (TestSessionProvider) + +**Текущая проблема:** `shared/lib/test/test-utils.tsx` импортирует `SessionProvider` из `@/app/providers`. По FSD shared не может импортировать из app. + +**Решение:** Создать `TestSessionProvider` в shared/lib/test/ и переключить test-utils на него. + +**Files:** +- Create: `apps/frontend/src/shared/lib/test/TestSessionProvider.tsx` +- Modify: `apps/frontend/src/shared/lib/test/test-utils.tsx` + +- [ ] **Step 1: Создать TestSessionProvider** + +`apps/frontend/src/shared/lib/test/TestSessionProvider.tsx`: +```tsx +import { type ReactNode } from 'react'; +import { SessionContext } from '@/entities/session'; + +function noop() { + return Promise.resolve(); +} + +export function TestSessionProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +``` + +- [ ] **Step 2: Заменить SessionProvider на TestSessionProvider в test-utils.tsx** + +В `apps/frontend/src/shared/lib/test/test-utils.tsx`: +```diff +- import { SessionProvider } from '@/app/providers'; ++ import { TestSessionProvider } from './TestSessionProvider'; +``` +```diff +- {children} ++ {children} +``` + +- [ ] **Step 3: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +Expected: build passes, all tests green. + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/shared/lib/test/ +git commit -m "fix: move test SessionProvider to shared layer for FSD compliance" +``` + +--- + +### Task 2: Вынести BrokerPositionTable из страницы в виджет + +**Текущая проблема:** `BrokerPositionsPage.tsx` (310 строк) содержит `BrokerPositionTable` (~190 строк) и `PositionTicker` (~20 строк). + +**Решение:** Вынести в `widgets/broker-positions-table/`, страница остаётся только с cursor-логикой. + +**Files:** +- Create: `apps/frontend/src/widgets/broker-positions-table/index.ts` +- Create: `apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx` +- Create: `apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx` +- Modify: `apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx` + +- [ ] **Step 1: Создать PositionTicker** + +`apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx`: +```tsx +import { Link } from 'react-router-dom'; +import type { BrokerPosition } from '@/shared/api/responses'; +import { getBrokerInstrumentPath } from '@/entities/broker-position'; + +export function PositionTicker({ position }: { position: BrokerPosition }) { + const label = position.ticker || position.figi || '-'; + const path = getBrokerInstrumentPath({ + ticker: position.ticker, + instrumentType: position.instrumentType, + classCode: position.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return ( + + {label} + + ); +} +``` + +- [ ] **Step 2: Создать BrokerPositionTable** + +`apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx`: +```tsx +import type { BrokerPositionsPage } from '@/shared/api/responses'; +import { TableSkeleton } from '@/shared/ui/TableSkeleton'; +import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters'; +import { PositionTicker } from './PositionTicker'; + +function formatQuantity(value: number | null | undefined) { + return value == null ? '-' : value.toLocaleString('ru-RU'); +} + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +const pagButtonStyle = { + padding: '6px 14px', + borderRadius: 6, + border: '1px solid #e0e0e0', + background: 'var(--color-surface)', + color: 'var(--color-text)', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + lineHeight: 1.4, +} satisfies React.CSSProperties; + +const pagButtonDisabledStyle = { + ...pagButtonStyle, + opacity: 0.35, + cursor: 'not-allowed', +} satisfies React.CSSProperties; + +type BrokerPositionTableProps = { + title: string; + page: BrokerPositionsPage | undefined; + isLoading: boolean; + isFetching: boolean; + emptyMessage: string; + pageNumber: number; + onNext: () => void; + onPrevious: () => void; +}; + +export function BrokerPositionTable({ + title, + page, + isLoading, + isFetching, + emptyMessage, + pageNumber, + onNext, + onPrevious, +}: BrokerPositionTableProps) { + const positions = page?.items ?? []; + const canGoBack = pageNumber > 1; + const canGoForward = Boolean(page?.hasNext && page.nextCursor); + + return ( +
+
+

+ {title} +

+
+ + + {pageNumber} + + +
+
+ + {isLoading ? ( +
+ + + + + + + + + + + +
ТикерНазваниеКоличествоЦенаСтоимость
+
+ ) : positions.length === 0 && !isFetching ? ( +

{emptyMessage}

+ ) : ( +
+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
ТикерНазваниеКоличествоЦенаСтоимость
+ + {position.name || '-'} + + {formatQuantity(position.quantity)}{formatMoney(position.currentPrice)}{formatMoney(position.currentValue)}
+
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )} +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Создать barrel** + +`apps/frontend/src/widgets/broker-positions-table/index.ts`: +```ts +export { BrokerPositionTable } from './ui/BrokerPositionTable'; +``` + +- [ ] **Step 4: Обновить страницу BrokerPositionsPage** + +`apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx`: +```tsx +import { useState } from 'react'; +import { useBrokerPositions } from '@/entities/broker-position'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerPositionTable } from '@/widgets/broker-positions-table'; + +type BrokerPositionsPageProps = { + type: 'share' | 'bond'; + title: 'Акции' | 'Облигации'; +}; + +export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { + const { accountId } = useBrokerAccountContext(); + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState>([]); + const positions = useBrokerPositions(accountId, { type, limit: 10, cursor }); + + function handleNext() { + const nextCursor = positions.data?.nextCursor; + if (!nextCursor || !positions.data?.hasNext) return; + setCursorStack((previous) => [...previous, cursor]); + setCursor(nextCursor); + } + + function handlePrevious() { + if (cursorStack.length === 0) return; + setCursor(cursorStack[cursorStack.length - 1]); + setCursorStack((previous) => previous.slice(0, -1)); + } + + if (positions.error) { + return ( +
+

{title}

+

+ {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} +

+
+ ); + } + + return ( + + ); +} +``` + +- [ ] **Step 5: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/frontend/src/widgets/broker-positions-table/ apps/frontend/src/pages/broker-positions/ +git commit -m "refactor: extract BrokerPositionTable to widgets layer" +``` + +--- + +### Task 3: Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в виджет + +**Текущая проблема:** `BrokerAccountOverviewPage.tsx` (160 строк) содержит 3 внутренних компонента + 2 хелпера. + +**Решение:** Создать `widgets/broker-overview/` с тремя компонентами. + +**Files:** +- Create: `apps/frontend/src/widgets/broker-overview/index.ts` +- Create: `apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx` +- Create: `apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx` +- Create: `apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx` +- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` + +- [ ] **Step 1: Создать BrokerSummary** + +`apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx`: +```tsx +import type { BrokerPortfolio } from '@/shared/api/responses'; +import { + formatBrokerMoney as formatMoney, + formatBrokerPercent as formatPercent, +} from '@/shared/lib/formatters'; + +export function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { + return ( +
+
+ Стоимость портфеля + + {formatMoney(portfolio.totals.portfolio)} + + За день: {formatMoney(portfolio.yields.daily)} + Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)} + Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)} +
+
+ Денежный остаток + {portfolio.cash.length === 0 ? ( + Нет денежных остатков + ) : ( +
    + {portfolio.cash.map((money, index) => ( +
  • + {money.currency} + {formatMoney(money)} +
  • + ))} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Создать BrokerAssetCards** + +`apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx`: +```tsx +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; +import { formatBrokerMoney as formatMoney, pluralize } from '@/shared/lib/formatters'; + +function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { + if (!value || !total || total.value <= 0) return null; + return (value.value / total.value) * 100; +} + +function formatAllocationPercent(value: number | null) { + return value === null ? '—' : `${value.toFixed(1)}%`; +} + +export function BrokerAssetCards({ + accountId, + portfolio, +}: { + accountId: string; + portfolio: BrokerPortfolio; +}) { + const basePath = `/broker/${encodeURIComponent(accountId)}`; + const cards = [ + { + label: 'Акции', + count: portfolio.positionCounts.shares, + countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'), + value: portfolio.totals.shares, + path: `${basePath}/shares`, + }, + { + label: 'Облигации', + count: portfolio.positionCounts.bonds, + countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'), + value: portfolio.totals.bonds, + path: `${basePath}/bonds`, + }, + ]; + + return ( +
+ {cards.map((card) => ( + + {card.label} + {card.count} {card.countLabel} + {formatMoney(card.value)} + {formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))} + + ))} +
+ ); +} +``` + +- [ ] **Step 3: Создать BrokerOverviewSkeleton** + +`apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx`: +```tsx +import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; + +export function BrokerOverviewSkeleton() { + return ( +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ + +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Создать barrel** + +`apps/frontend/src/widgets/broker-overview/index.ts`: +```ts +export { BrokerSummary } from './ui/BrokerSummary'; +export { BrokerAssetCards } from './ui/BrokerAssetCards'; +export { BrokerOverviewSkeleton } from './ui/BrokerOverviewSkeleton'; +``` + +- [ ] **Step 5: Обновить BrokerAccountOverviewPage** + +`apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx`: +```tsx +import { Link } from 'react-router-dom'; +import { useBrokerOperations } from '@/entities/broker-operation'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'; +import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; +import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview'; + +export function BrokerAccountOverviewPage() { + const { accountId, portfolio } = useBrokerAccountContext(); + const operations = useBrokerOperations(accountId, { limit: 5 }); + + if (portfolio.isLoading) return ; + if (portfolio.error || !portfolio.data) { + return

Не удалось загрузить сводку счёта

; + } + + return ( +
+ + + + {operations.error ? ( +

Не удалось загрузить последние операции

+ ) : ( + Вся история + } + emptyMessage="Операций с начала текущего года нет" + isLoading={operations.isLoading} + isFetching={operations.isFetching} + page={operations.data} + /> + )} +
+ ); +} +``` + +- [ ] **Step 6: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/frontend/src/widgets/broker-overview/ apps/frontend/src/pages/broker-account/ +git commit -m "refactor: extract BrokerOverview components to widgets layer" +``` + +--- + +### Task 4: Вынести AddPositionForm в features/add-position + +**Текущая проблема:** `PortfolioDetailPage.tsx` (290 строк) содержит inline-форму добавления позиции с 4 state-переменными. + +**Решение:** Создать `features/add-position/` с хуком формы и UI-компонентом. + +**Files:** +- Create: `apps/frontend/src/features/add-position/api/useAddPosition.ts` +- Create: `apps/frontend/src/features/add-position/model/useAddPositionForm.ts` +- Create: `apps/frontend/src/features/add-position/ui/AddPositionForm.tsx` +- Create: `apps/frontend/src/features/add-position/index.ts` +- Modify: `apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx` + +- [ ] **Step 1: Создать useAddPosition (api-слой)** + +`apps/frontend/src/features/add-position/api/useAddPosition.ts`: +```ts +import { usePositionMutations } from '@/entities/portfolio'; + +export function useAddPosition(portfolioId: number) { + const { add } = usePositionMutations(portfolioId); + return add; +} +``` + +- [ ] **Step 2: Создать useAddPositionForm (model-слой)** + +`apps/frontend/src/features/add-position/model/useAddPositionForm.ts`: +```ts +import { useState } from 'react'; + +export function useAddPositionForm() { + const [showAddForm, setShowAddForm] = useState(false); + const [newSecid, setNewSecid] = useState(''); + const [newQty, setNewQty] = useState('1'); + const [newPrice, setNewPrice] = useState(''); + const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]); + + function reset() { + setNewSecid(''); + setNewQty('1'); + setNewPrice(''); + setNewDate(new Date().toISOString().split('T')[0]); + } + + return { + showAddForm, + setShowAddForm, + newSecid, + setNewSecid, + newQty, + setNewQty, + newPrice, + setNewPrice, + newDate, + setNewDate, + reset, + }; +} +``` + +- [ ] **Step 3: Создать AddPositionForm (ui-слой)** + +`apps/frontend/src/features/add-position/ui/AddPositionForm.tsx`: +```tsx +import { useAddPosition } from '../api/useAddPosition'; +import { useAddPositionForm } from '../model/useAddPositionForm'; + +const inputStyle: React.CSSProperties = { + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, +}; + +export function AddPositionForm({ portfolioId }: { portfolioId: number }) { + const addPosition = useAddPosition(portfolioId); + const form = useAddPositionForm(); + + function handleAddPosition() { + if (!form.newSecid.trim() || !parseInt(form.newQty, 10)) return; + addPosition.mutate( + { + secid: form.newSecid.trim().toUpperCase(), + quantity: parseInt(form.newQty, 10), + buyPrice: form.newPrice ? parseFloat(form.newPrice) : undefined, + buyDate: form.newDate || undefined, + }, + { + onSuccess: () => { + form.setShowAddForm(false); + form.reset(); + }, + }, + ); + } + + return ( +
+
+ + form.setNewSecid(e.target.value)} + placeholder="SBER" + style={{ ...inputStyle, width: 120 }} + /> +
+
+ + form.setNewQty(e.target.value)} + style={{ ...inputStyle, width: 100 }} + /> +
+
+ + form.setNewPrice(e.target.value)} + placeholder="0.00" + style={{ ...inputStyle, width: 120 }} + /> +
+
+ + form.setNewDate(e.target.value)} + style={{ ...inputStyle, width: 150 }} + /> +
+ +
+ ); +} +``` + +- [ ] **Step 4: Создать barrel** + +`apps/frontend/src/features/add-position/index.ts`: +```ts +export { AddPositionForm } from './ui/AddPositionForm'; +``` + +- [ ] **Step 5: Обновить PortfolioDetailPage** + +`apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx`: +```tsx +import { useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { usePortfolio, usePortfolioMutations } from '@/entities/portfolio'; +import { PortfolioForm } from '@/widgets/portfolio-form'; +import { PortfolioSummary } from '@/widgets/portfolio-summary'; +import { AnalyticsSummary } from '@/widgets/portfolio-analytics'; +import { SharePositionTable } from '@/widgets/share-positions-table'; +import { BondPositionTable } from '@/widgets/bond-positions-table'; +import { AddPositionForm } from '@/features/add-position'; + +export function PortfolioDetailPage() { + const { id } = useParams<{ id: string }>(); + const portfolioId = parseInt(id!, 10); + + const { data: portfolio, isLoading, error } = usePortfolio(portfolioId); + const { update, remove } = usePortfolioMutations(); + const [editing, setEditing] = useState(false); + const [showAddForm, setShowAddForm] = useState(false); + + if (isLoading) { + return ( +
+ Загрузка... +
+ ); + } + + if (error || !portfolio) { + return ( +
+ Ошибка загрузки портфеля +
+ ); + } + + async function handleDelete() { + if (window.confirm('Удалить портфель и все позиции?')) { + remove.mutate(portfolioId); + } + } + + return ( +
+
+ + ← К списку + +

{portfolio.name}

+ + +
+ + {editing && ( +
+

+ Редактировать портфель +

+ update.mutate({ id: portfolioId, data: d })} + onCancel={() => setEditing(false)} + isLoading={update.isPending} + /> +
+ )} + + + {portfolio.analytics && } + +
+

Позиции

+ +
+ + {showAddForm && } + + p.type === 'share')} + onUpdatePosition={(positionId, data) => update.mutate({ positionId, data })} + onDeletePosition={(positionId) => { + if (window.confirm('Удалить позицию?')) remove.mutate(positionId); + }} + /> + p.type === 'bond')} + onUpdatePosition={(positionId, data) => update.mutate({ positionId, data })} + onDeletePosition={(positionId) => { + if (window.confirm('Удалить позицию?')) remove.mutate(positionId); + }} + /> +
+ ); +} +``` + +- [ ] **Step 6: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/frontend/src/features/add-position/ apps/frontend/src/pages/portfolios/ +git commit -m "refactor: extract AddPositionForm to features layer" +``` + +--- + +### Task 5: Вынести cursor-пагинацию в shared/lib/useCursorPagination + +**Текущая проблема:** 25 строк cursor-логики дублируются в `BrokerPositionsPage` и `BrokerOperationsPage`. + +**Решение:** Создать `shared/lib/useCursorPagination.ts` и использовать в обеих страницах. + +**Files:** +- Create: `apps/frontend/src/shared/lib/useCursorPagination.ts` +- Modify: `apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx` +- Modify: `apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx` + +- [ ] **Step 1: Создать хук** + +`apps/frontend/src/shared/lib/useCursorPagination.ts`: +```ts +import { useState, useCallback } from 'react'; + +export function useCursorPagination() { + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState>([]); + + const handleNext = useCallback((nextCursor: string | undefined) => { + if (!nextCursor) return; + setCursorStack((prev) => [...prev, cursor]); + setCursor(nextCursor); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cursor]); + + const handlePrevious = useCallback(() => { + setCursorStack((prev) => { + if (prev.length === 0) return prev; + setCursor(prev[prev.length - 1]); + return prev.slice(0, -1); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const reset = useCallback(() => { + setCursor(undefined); + setCursorStack([]); + }, []); + + return { + cursor, + pageNumber: cursorStack.length + 1, + handleNext, + handlePrevious, + reset, + }; +} +``` + +- [ ] **Step 2: Обновить BrokerPositionsPage** + +`apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx`: +```tsx +import { useBrokerPositions } from '@/entities/broker-position'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerPositionTable } from '@/widgets/broker-positions-table'; +import { useCursorPagination } from '@/shared/lib/useCursorPagination'; + +type BrokerPositionsPageProps = { + type: 'share' | 'bond'; + title: 'Акции' | 'Облигации'; +}; + +export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { + const { accountId } = useBrokerAccountContext(); + const pagination = useCursorPagination(); + const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor }); + + if (positions.error) { + return ( +
+

{title}

+

+ {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} +

+
+ ); + } + + return ( + pagination.handleNext(positions.data?.nextCursor)} + onPrevious={pagination.handlePrevious} + /> + ); +} +``` + +- [ ] **Step 3: Обновить BrokerOperationsPage** + +`apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx`: +```tsx +import { useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + BROKER_OPERATION_TYPE_OPTIONS, + isBrokerOperationType, + useBrokerOperations, +} from '@/entities/broker-operation'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; +import { useCursorPagination } from '@/shared/lib/useCursorPagination'; + +export function BrokerOperationsPage() { + const { accountId } = useBrokerAccountContext(); + const [searchParams, setSearchParams] = useSearchParams(); + const urlType = searchParams.get('type'); + const selectedType = isBrokerOperationType(urlType) ? urlType : ''; + const pagination = useCursorPagination(); + + const operations = useBrokerOperations(accountId, { + limit: 10, + cursor: pagination.cursor, + operationTypes: selectedType || undefined, + }); + + useEffect(() => { + pagination.reset(); + }, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps + + function handleTypeChange(event: React.ChangeEvent) { + const nextType = event.target.value; + setSearchParams(nextType ? { type: nextType } : {}, { replace: true }); + } + + const history = operations.error ? ( +

Не удалось загрузить историю операций

+ ) : ( + 1, + canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), + onPrevious: pagination.handlePrevious, + onNext: () => pagination.handleNext(operations.data?.nextCursor), + }} + /> + ); + + return ( +
+
+

+ Операции +

+ +
+ {history} +
+ ); +} +``` + +- [ ] **Step 4: Проверить сборку и тесты** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +npm run -w apps/frontend test run 2>&1 | tail -30 +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/shared/lib/useCursorPagination.ts apps/frontend/src/pages/broker-positions/ apps/frontend/src/pages/broker-operations/ +git commit -m "refactor: extract useCursorPagination to shared layer" +``` + +--- + +### Task 6: Добавить @conarti/eslint-plugin-feature-sliced + +**Текущая проблема:** ESLint проверяет только межслойные границы через `import/no-restricted-paths`. Нет проверок public API и сегментов. + +**Решение:** Установить плагин и включить recommended rules. + +**Files:** +- Modify: `apps/frontend/.eslintrc.cjs` +- Modify: `apps/frontend/package.json` (через npm install) + +- [ ] **Step 1: Установить плагин** + +```bash +npm install -w apps/frontend --save-dev @conarti/eslint-plugin-feature-sliced +``` + +- [ ] **Step 2: Обновить .eslintrc.cjs** + +```diff + plugins: [ + '@typescript-eslint/eslint-plugin', + 'react', + 'react-hooks', + 'import', ++ '@conarti/feature-sliced', + ], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', ++ 'plugin:@conarti/feature-sliced/rules/recommended', + ], +``` + +- [ ] **Step 3: Проверить lint** + +```bash +npm run lint -w apps/frontend 2>&1 +``` + +Expected: 0 errors (если появятся ложные срабатывания — см. Step 4). + +- [ ] **Step 4 (если нужно): Ослабить правила** + +Если `plugin:@conarti/feature-sliced/rules/recommended` даёт ложные срабатывания (например, на импорты типов из соседних модулей), ослабить конкретные правила: +```js +rules: { + // existing rules... + '@conarti/feature-sliced/public-api': 'warn', + '@conarti/feature-sliced/absolute-relative': 'warn', +} +``` + +- [ ] **Step 5: Проверить сборку** + +```bash +npm run build -w apps/frontend 2>&1 | tail -20 +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/frontend/.eslintrc.cjs apps/frontend/package.json apps/frontend/package-lock.json +git commit -m "chore: add @conarti/eslint-plugin-feature-sliced for FSD rule enforcement" +``` + +--- + +## Self-Review Checklist + +- [x] **Spec coverage:** Все 6 AC из spec.md имеют соответствующие задачи (Task 1→AC1, Task 2→AC2, Task 3→AC3, Task 4→AC4, Task 5→AC5, Task 6→AC6). AC7-AC10 проверяются в каждом task. +- [x] **Placeholder scan:** Нет TBD, TODO, или незаполненных шагов. Каждый шаг содержит полный код. +- [x] **Type consistency:** Все импорты и типы соответствуют существующему коду (formatBrokerMoney, BrokerPositionsPage, BrokerOperationsTable props, SessionContextValue). +- [x] **No circular deps:** Создаваемые файлы импортируют только из shared, entities или друг друга — без циклических зависимостей. diff --git a/docs/features/fsd-frontend-refactor/spec.md b/docs/features/fsd-frontend-refactor/spec.md new file mode 100644 index 0000000..252a01f --- /dev/null +++ b/docs/features/fsd-frontend-refactor/spec.md @@ -0,0 +1,96 @@ +# FSD Frontend Refactor + +## Цель + +Довести фронтенд-архитектуру до эталонного Feature-Sliced Design (FSD) — устранить существующие нарушения, вынести жирные страницы в виджеты/фичи, внедрить автоматический контроль FSD-правил. + +Текущее состояние: 4.2/5 по FSD-зрелости. Цель: 5/5. + +## Задачи + +### 1. Исправить нарушение слоёв в shared/lib/test + +- `shared/lib/test/test-utils.tsx` импортирует `SessionProvider` из `@/app/providers` +- Это единственное нарушение FSD (shared → app) +- Решение: вынести `TestSessionProvider` в `shared/lib/test/` и переключить `test-utils.tsx` на него +- Тесты не должны сломаться: `TestSessionProvider` предоставляет те же значения контекста (замоканые) + +### 2. Вынести BrokerPositionTable из страницы в виджет + +- `pages/broker-positions/ui/BrokerPositionsPage.tsx` (310 строк) содержит внутренние компоненты: + - `BrokerPositionTable` (~190 строк) — таблица с пагинацией, skeleton, inline-стилями + - `PositionTicker` (~20 строк) — ссылка на инструмент + - `formatQuantity` — хелпер +- Решение: создать `widgets/broker-positions-table/` с: + - `ui/BrokerPositionTable.tsx` + - `ui/PositionTicker.tsx` + - `index.ts` +- Страница сокращается до ~100 строк (только cursor-логика + композиция) + +### 3. Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в виджет + +- `pages/broker-account/ui/BrokerAccountOverviewPage.tsx` (160 строк) содержит: + - `BrokerSummary` — сводка портфеля (карточки стоимости и денег) + - `BrokerAssetCards` — карточки классов активов (акции/облигации) + - `BrokerOverviewSkeleton` — скелетон загрузки + - `allocationPercent`, `formatAllocationPercent` — хелперы +- Решение: создать `widgets/broker-overview/` с: + - `ui/BrokerSummary.tsx` + - `ui/BrokerAssetCards.tsx` + - `ui/BrokerOverviewSkeleton.tsx` + - `index.ts` +- Страница сокращается до ~40 строк (только loading/error guard + композиция) + +### 4. Вынести AddPositionForm в фичу + +- `pages/portfolios/ui/PortfolioDetailPage.tsx` (290 строк) содержит inline-форму добавления позиции: + - 4 state-переменные (newSecid, newQty, newPrice, newDate) + - 4 input-поля с inline-стилями + - Валидация и submit +- Это полноценная бизнес-фича, не место в странице +- Решение: создать `features/add-position/` с: + - `api/useAddPosition.ts` — мутация (обёртка над usePositionMutations) + - `model/useAddPositionForm.ts` — управление формой + - `ui/AddPositionForm.tsx` — UI формы + - `index.ts` +- Страница сокращается до ~140 строк (showAddForm toggle + ``) + +### 5. Вынести cursor-пагинацию в shared-хук + +- 25 строк cursor-логики дублируются в: + - `pages/broker-positions/ui/BrokerPositionsPage.tsx` + - `pages/broker-operations/ui/BrokerOperationsPage.tsx` +- Решение: создать `shared/lib/useCursorPagination.ts` + +```ts +function useCursorPagination() { + // cursor, pageNumber, handleNext(cursor), handlePrevious, reset +} +``` + +### 6. Добавить ESLint-плагин FSD + +- Текущий `import/no-restricted-paths` проверяет только межслойные границы +- Плагин `@conarti/eslint-plugin-feature-sliced` добавит: + - Проверки public API (запрет импорта из внутренних модулей в обход index.ts) + - Проверки сегментов (api/ui/model не импортируют друг друга напрямую) +- Решение: установить плагин и включить recommended rules + +## Критерии приёмки (Acceptance Criteria) + +- [ ] AC1: `test-utils.tsx` не импортирует из `@/app/*` +- [ ] AC2: `widgets/broker-positions-table` создан, страница использует его +- [ ] AC3: `widgets/broker-overview` создан, страница использует его +- [ ] AC4: `features/add-position` создана, страница использует её +- [ ] AC5: `shared/lib/useCursorPagination.ts` создан, обе страницы используют его +- [ ] AC6: `@conarti/eslint-plugin-feature-sliced` установлен и настроен +- [ ] AC7: `npm run lint` проходит без ошибок +- [ ] AC8: `npm run build` проходит без ошибок +- [ ] AC9: Все существующие тесты проходят (включая тесты рефакторимых компонентов) +- [ ] AC10: Визуально поведение страниц не изменилось + +## Ограничения + +- Не менять API-контракты компонентов (пропсы, типы). Только перемещение кода. +- Не добавлять новую функциональность — только рефакторинг. +- inline-стили переносятся вместе с компонентами; централизация UI-кита — отдельная задача. diff --git a/docs/features/fsd-frontend-refactor/tasks.md b/docs/features/fsd-frontend-refactor/tasks.md new file mode 100644 index 0000000..4dfde8d --- /dev/null +++ b/docs/features/fsd-frontend-refactor/tasks.md @@ -0,0 +1,13 @@ +# FSD Frontend Refactor — Tasks + +**Ветка:** `codex/fsd-frontend-refactor` + +**Порядок:** последовательно, каждый task — независимый коммит. + +- [ ] **Task 1:** Исправить нарушение shared → app — TestSessionProvider +- [ ] **Task 2:** Вынести BrokerPositionTable из страницы в widgets +- [ ] **Task 3:** Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в widgets +- [ ] **Task 4:** Вынести AddPositionForm в features/add-position +- [ ] **Task 5:** Вынести useCursorPagination в shared/lib/ +- [ ] **Task 6:** Добавить @conarti/eslint-plugin-feature-sliced +- [ ] **Финальная проверка:** `npm run build -w apps/frontend && npm run lint -w apps/frontend && npm run -w apps/frontend test run` diff --git a/package-lock.json b/package-lock.json index 55bb494..47e86fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,7 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@conarti/eslint-plugin-feature-sliced": "^1.0.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -3416,6 +3417,36 @@ "node": ">=0.1.90" } }, + "node_modules/@conarti/eslint-plugin-feature-sliced": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@conarti/eslint-plugin-feature-sliced/-/eslint-plugin-feature-sliced-1.0.5.tgz", + "integrity": "sha512-Gx90Zupi8nv6cCP3O6cYWE9GT4RIw0D9JBHWeUwXEv7qmjCd3dXSqDEmQchN7k41hhCPupZvMevH4JrSORcVjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "picomatch": "^2.3.1" + }, + "engines": { + "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + }, + "peerDependencies": { + "eslint": ">=7", + "eslint-plugin-import": ">=2.26" + } + }, + "node_modules/@conarti/eslint-plugin-feature-sliced/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@csstools/cascade-layer-name-parser": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz",