# 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 или друг друга — без циклических зависимостей.