1311 lines
44 KiB
Markdown
1311 lines
44 KiB
Markdown
# 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 (
|
||
<SessionContext.Provider
|
||
value={{
|
||
user: null,
|
||
accessToken: null,
|
||
isAuthenticated: false,
|
||
isLoading: false,
|
||
login: noop,
|
||
register: noop,
|
||
logout: noop,
|
||
updateProfile: noop,
|
||
}}
|
||
>
|
||
{children}
|
||
</SessionContext.Provider>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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
|
||
- <SessionProvider>{children}</SessionProvider>
|
||
+ <TestSessionProvider>{children}</TestSessionProvider>
|
||
```
|
||
|
||
- [ ] **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 <strong>{label}</strong>;
|
||
}
|
||
|
||
return (
|
||
<Link to={path} style={{ fontWeight: 700 }}>
|
||
{label}
|
||
</Link>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
justifyContent: 'space-between',
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||
{title}
|
||
</h2>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<button
|
||
type="button"
|
||
aria-label="Предыдущая страница"
|
||
onClick={onPrevious}
|
||
disabled={!canGoBack || isFetching}
|
||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||
>
|
||
{isFetching ? (
|
||
<span
|
||
className="loading-spinner"
|
||
style={{ width: 14, height: 14, display: 'block' }}
|
||
/>
|
||
) : (
|
||
'←'
|
||
)}
|
||
</button>
|
||
<span
|
||
style={{
|
||
minWidth: 20,
|
||
textAlign: 'center',
|
||
color: 'var(--color-text-secondary)',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{pageNumber}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label="Следующая страница"
|
||
onClick={onNext}
|
||
disabled={!canGoForward || isFetching}
|
||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||
>
|
||
{isFetching ? (
|
||
<span
|
||
className="loading-spinner"
|
||
style={{ width: 14, height: 14, display: 'block' }}
|
||
/>
|
||
) : (
|
||
'→'
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table style={tableStyle}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={thStyle}>Тикер</th>
|
||
<th align="left" style={thStyle}>Название</th>
|
||
<th align="right" style={thStyle}>Количество</th>
|
||
<th align="right" style={thStyle}>Цена</th>
|
||
<th align="right" style={thStyle}>Стоимость</th>
|
||
</tr>
|
||
</thead>
|
||
<TableSkeleton
|
||
rows={4}
|
||
columns={[
|
||
{ width: '30%' },
|
||
{ width: '50%' },
|
||
{ width: '20%' },
|
||
{ width: '25%' },
|
||
{ width: '25%' },
|
||
]}
|
||
/>
|
||
</table>
|
||
</div>
|
||
) : positions.length === 0 && !isFetching ? (
|
||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||
) : (
|
||
<div className="table-container">
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={thStyle}>Тикер</th>
|
||
<th align="left" style={thStyle}>Название</th>
|
||
<th align="right" style={thStyle}>Количество</th>
|
||
<th align="right" style={thStyle}>Цена</th>
|
||
<th align="right" style={thStyle}>Стоимость</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{positions.map((position) => (
|
||
<tr
|
||
key={
|
||
position.positionUid ||
|
||
position.instrumentUid ||
|
||
position.ticker ||
|
||
position.figi
|
||
}
|
||
>
|
||
<td style={tdStyle}><PositionTicker position={position} /></td>
|
||
<td style={tdStyle}>
|
||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||
{position.name || '-'}
|
||
</span>
|
||
</td>
|
||
<td align="right" style={tdStyle}>{formatQuantity(position.quantity)}</td>
|
||
<td align="right" style={tdStyle}>{formatMoney(position.currentPrice)}</td>
|
||
<td align="right" style={tdStyle}>{formatMoney(position.currentValue)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{isFetching && (
|
||
<div className="table-loading-overlay">
|
||
<div className="loading-spinner" />
|
||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||
Загрузка страницы {pageNumber}…
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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<string | undefined>(undefined);
|
||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||
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 (
|
||
<section aria-labelledby={`broker-${type}-heading`}>
|
||
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>{title}</h2>
|
||
<p role="alert">
|
||
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<BrokerPositionTable
|
||
title={title}
|
||
page={positions.data}
|
||
isLoading={positions.isLoading}
|
||
isFetching={positions.isFetching}
|
||
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
||
pageNumber={cursorStack.length + 1}
|
||
onNext={handleNext}
|
||
onPrevious={handlePrevious}
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<section className="broker-overview__summary" aria-label="Сводка счёта">
|
||
<div className="broker-overview__card">
|
||
<span className="broker-overview__label">Стоимость портфеля</span>
|
||
<strong className="broker-overview__total">
|
||
{formatMoney(portfolio.totals.portfolio)}
|
||
</strong>
|
||
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
|
||
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
|
||
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
|
||
</div>
|
||
<div className="broker-overview__card">
|
||
<span className="broker-overview__label">Денежный остаток</span>
|
||
{portfolio.cash.length === 0 ? (
|
||
<span>Нет денежных остатков</span>
|
||
) : (
|
||
<ul className="broker-overview__cash">
|
||
{portfolio.cash.map((money, index) => (
|
||
<li key={`${money.currency}-${index}`}>
|
||
<span>{money.currency}</span>
|
||
<strong>{formatMoney(money)}</strong>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<section className="broker-overview__assets" aria-label="Основные классы активов">
|
||
{cards.map((card) => (
|
||
<Link
|
||
className="broker-overview__card broker-overview__asset-link"
|
||
key={card.label}
|
||
to={card.path}
|
||
>
|
||
<strong className="broker-overview__asset-title">{card.label}</strong>
|
||
<span>{card.count} {card.countLabel}</span>
|
||
<span>{formatMoney(card.value)}</span>
|
||
<span>{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}</span>
|
||
</Link>
|
||
))}
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Создать BrokerOverviewSkeleton**
|
||
|
||
`apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx`:
|
||
```tsx
|
||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
||
|
||
export function BrokerOverviewSkeleton() {
|
||
return (
|
||
<div className="broker-overview" aria-label="Загрузка сводки счёта">
|
||
<div className="broker-overview__summary">
|
||
{[1, 2].map((item) => (
|
||
<div className="broker-overview__card" key={item}>
|
||
<SkeletonBlock height={16} width="45%" />
|
||
<SkeletonBlock height={28} width="70%" />
|
||
<SkeletonBlock height={16} width="55%" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="broker-allocation">
|
||
<SkeletonBlock height={160} width={160} borderRadius={80} />
|
||
<SkeletonBlock height={80} width="60%" />
|
||
</div>
|
||
<div className="broker-overview__assets">
|
||
{[1, 2].map((item) => (
|
||
<div className="broker-overview__card" key={item}>
|
||
<SkeletonBlock height={20} width="35%" />
|
||
<SkeletonBlock height={16} width="55%" />
|
||
<SkeletonBlock height={16} width="70%" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 <BrokerOverviewSkeleton />;
|
||
if (portfolio.error || !portfolio.data) {
|
||
return <p role="alert">Не удалось загрузить сводку счёта</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="broker-overview">
|
||
<BrokerSummary portfolio={portfolio.data} />
|
||
<BrokerAllocationChart portfolio={portfolio.data} />
|
||
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
||
{operations.error ? (
|
||
<p role="alert">Не удалось загрузить последние операции</p>
|
||
) : (
|
||
<BrokerOperationsTable
|
||
title="Последние операции"
|
||
headerAction={
|
||
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
|
||
}
|
||
emptyMessage="Операций с начала текущего года нет"
|
||
isLoading={operations.isLoading}
|
||
isFetching={operations.isFetching}
|
||
page={operations.data}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<div
|
||
style={{
|
||
marginTop: 12,
|
||
padding: 16,
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: 'var(--border-radius)',
|
||
display: 'flex',
|
||
gap: 12,
|
||
alignItems: 'flex-end',
|
||
}}
|
||
>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||
Тикер
|
||
</label>
|
||
<input
|
||
value={form.newSecid}
|
||
onChange={(e) => form.setNewSecid(e.target.value)}
|
||
placeholder="SBER"
|
||
style={{ ...inputStyle, width: 120 }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||
Количество
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={form.newQty}
|
||
onChange={(e) => form.setNewQty(e.target.value)}
|
||
style={{ ...inputStyle, width: 100 }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||
Цена покупки
|
||
</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={form.newPrice}
|
||
onChange={(e) => form.setNewPrice(e.target.value)}
|
||
placeholder="0.00"
|
||
style={{ ...inputStyle, width: 120 }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||
Дата покупки
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={form.newDate}
|
||
onChange={(e) => form.setNewDate(e.target.value)}
|
||
style={{ ...inputStyle, width: 150 }}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={handleAddPosition}
|
||
disabled={addPosition.isPending}
|
||
style={{
|
||
padding: '8px 16px',
|
||
background: 'var(--color-primary)',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 'var(--border-radius)',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||
Загрузка...
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !portfolio) {
|
||
return (
|
||
<div style={{ padding: 40, textAlign: 'center', color: '#e53935' }}>
|
||
Ошибка загрузки портфеля
|
||
</div>
|
||
);
|
||
}
|
||
|
||
async function handleDelete() {
|
||
if (window.confirm('Удалить портфель и все позиции?')) {
|
||
remove.mutate(portfolioId);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 24,
|
||
}}
|
||
>
|
||
<Link
|
||
to="/portfolios"
|
||
style={{ color: 'var(--color-text-secondary)', textDecoration: 'none', fontSize: 14 }}
|
||
>
|
||
← К списку
|
||
</Link>
|
||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700 }}>{portfolio.name}</h1>
|
||
<button
|
||
onClick={() => setEditing(!editing)}
|
||
style={{
|
||
marginLeft: 'auto',
|
||
padding: '6px 16px',
|
||
background: 'transparent',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: 'var(--border-radius)',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{editing ? 'Закрыть' : 'Редактировать'}
|
||
</button>
|
||
<button
|
||
onClick={handleDelete}
|
||
style={{
|
||
padding: '6px 16px',
|
||
background: 'transparent',
|
||
border: '1px solid #e53935',
|
||
color: '#e53935',
|
||
borderRadius: 'var(--border-radius)',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
|
||
{editing && (
|
||
<div
|
||
style={{
|
||
marginBottom: 24,
|
||
padding: 20,
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: 'var(--border-radius)',
|
||
}}
|
||
>
|
||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>
|
||
Редактировать портфель
|
||
</h3>
|
||
<PortfolioForm
|
||
initial={portfolio}
|
||
onSave={(d) => update.mutate({ id: portfolioId, data: d })}
|
||
onCancel={() => setEditing(false)}
|
||
isLoading={update.isPending}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<PortfolioSummary portfolio={portfolio} />
|
||
{portfolio.analytics && <AnalyticsSummary summary={portfolio.analytics} />}
|
||
|
||
<div
|
||
style={{
|
||
marginTop: 24,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Позиции</h2>
|
||
<button
|
||
onClick={() => setShowAddForm(!showAddForm)}
|
||
style={{
|
||
padding: '6px 16px',
|
||
background: 'var(--color-primary)',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 'var(--border-radius)',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
+ Добавить
|
||
</button>
|
||
</div>
|
||
|
||
{showAddForm && <AddPositionForm portfolioId={portfolioId} />}
|
||
|
||
<SharePositionTable
|
||
positions={portfolio.positions.filter((p) => p.type === 'share')}
|
||
onUpdatePosition={(positionId, data) => update.mutate({ positionId, data })}
|
||
onDeletePosition={(positionId) => {
|
||
if (window.confirm('Удалить позицию?')) remove.mutate(positionId);
|
||
}}
|
||
/>
|
||
<BondPositionTable
|
||
positions={portfolio.positions.filter((p) => p.type === 'bond')}
|
||
onUpdatePosition={(positionId, data) => update.mutate({ positionId, data })}
|
||
onDeletePosition={(positionId) => {
|
||
if (window.confirm('Удалить позицию?')) remove.mutate(positionId);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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<string | undefined>(undefined);
|
||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||
|
||
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 (
|
||
<section aria-labelledby={`broker-${type}-heading`}>
|
||
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>{title}</h2>
|
||
<p role="alert">
|
||
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<BrokerPositionTable
|
||
title={title}
|
||
page={positions.data}
|
||
isLoading={positions.isLoading}
|
||
isFetching={positions.isFetching}
|
||
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
||
pageNumber={pagination.pageNumber}
|
||
onNext={() => 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<HTMLSelectElement>) {
|
||
const nextType = event.target.value;
|
||
setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
|
||
}
|
||
|
||
const history = operations.error ? (
|
||
<p role="alert">Не удалось загрузить историю операций</p>
|
||
) : (
|
||
<BrokerOperationsTable
|
||
title="История операций"
|
||
emptyMessage={
|
||
selectedType ? 'Операций выбранного типа нет' : 'Операций с начала текущего года нет'
|
||
}
|
||
isLoading={operations.isLoading}
|
||
isFetching={operations.isFetching}
|
||
page={operations.data}
|
||
pagination={{
|
||
pageNumber: pagination.pageNumber,
|
||
canGoBack: pagination.pageNumber > 1,
|
||
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
|
||
onPrevious: pagination.handlePrevious,
|
||
onNext: () => pagination.handleNext(operations.data?.nextCursor),
|
||
}}
|
||
/>
|
||
);
|
||
|
||
return (
|
||
<section aria-labelledby="broker-operations-heading">
|
||
<div className="broker-operations__toolbar">
|
||
<h2 id="broker-operations-heading" style={{ fontSize: 20, margin: 0 }}>
|
||
Операции
|
||
</h2>
|
||
<label>
|
||
<span>Тип операции</span>
|
||
<select value={selectedType} onChange={handleTypeChange}>
|
||
<option value="">Все операции</option>
|
||
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{history}
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 или друг друга — без циклических зависимостей.
|