1311 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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