# 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)}
);
}
```
- [ ] **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}
/>
)}