44 KiB
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:
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:
- import { SessionProvider } from '@/app/providers';
+ import { TestSessionProvider } from './TestSessionProvider';
- <SessionProvider>{children}</SessionProvider>
+ <TestSessionProvider>{children}</TestSessionProvider>
- Step 3: Проверить сборку и тесты
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
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:
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:
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:
export { BrokerPositionTable } from './ui/BrokerPositionTable';
- Step 4: Обновить страницу BrokerPositionsPage
apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.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: Проверить сборку и тесты
npm run build -w apps/frontend 2>&1 | tail -20
npm run -w apps/frontend test run 2>&1 | tail -30
- Step 6: Commit
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:
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:
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:
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:
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:
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: Проверить сборку и тесты
npm run build -w apps/frontend 2>&1 | tail -20
npm run -w apps/frontend test run 2>&1 | tail -30
- Step 7: Commit
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:
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:
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:
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:
export { AddPositionForm } from './ui/AddPositionForm';
- Step 5: Обновить PortfolioDetailPage
apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.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: Проверить сборку и тесты
npm run build -w apps/frontend 2>&1 | tail -20
npm run -w apps/frontend test run 2>&1 | tail -30
- Step 7: Commit
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:
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:
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:
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: Проверить сборку и тесты
npm run build -w apps/frontend 2>&1 | tail -20
npm run -w apps/frontend test run 2>&1 | tail -30
- Step 5: Commit
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: Установить плагин
npm install -w apps/frontend --save-dev @conarti/eslint-plugin-feature-sliced
- Step 2: Обновить .eslintrc.cjs
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
npm run lint -w apps/frontend 2>&1
Expected: 0 errors (если появятся ложные срабатывания — см. Step 4).
- Step 4 (если нужно): Ослабить правила
Если plugin:@conarti/feature-sliced/rules/recommended даёт ложные срабатывания (например, на импорты типов из соседних модулей), ослабить конкретные правила:
rules: {
// existing rules...
'@conarti/feature-sliced/public-api': 'warn',
'@conarti/feature-sliced/absolute-relative': 'warn',
}
- Step 5: Проверить сборку
npm run build -w apps/frontend 2>&1 | tail -20
- Step 6: Commit
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
- 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.
- Placeholder scan: Нет TBD, TODO, или незаполненных шагов. Каждый шаг содержит полный код.
- Type consistency: Все импорты и типы соответствуют существующему коду (formatBrokerMoney, BrokerPositionsPage, BrokerOperationsTable props, SessionContextValue).
- No circular deps: Создаваемые файлы импортируют только из shared, entities или друг друга — без циклических зависимостей.