codex/fsd-frontend-refactor #31

Merged
ksv741 merged 6 commits from codex/fsd-frontend-refactor into main 2026-06-20 23:29:09 +03:00
27 changed files with 2160 additions and 575 deletions

View File

@ -7,7 +7,7 @@ module.exports = {
sourceType: 'module', sourceType: 'module',
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
}, },
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import'], plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import', '@conarti/feature-sliced'],
extends: [ extends: [
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react/recommended', 'plugin:react/recommended',
@ -33,7 +33,18 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
// FSD layer boundaries // FSD layer boundaries (from @conarti/eslint-plugin-feature-sliced)
// layers-slices: catches cross-layer violations (e.g., shared→entities)
'@conarti/feature-sliced/layers-slices': ['error', {
// allow test files and test utilities to import from any layer for mocking
ignoreInFilesPatterns: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'],
}],
// absolute-relative: false positives with @/ alias convention — disabled
'@conarti/feature-sliced/absolute-relative': 'off',
// public-api: too strict for app/ and test internals — disabled
'@conarti/feature-sliced/public-api': 'off',
// FSD layer boundaries (from import/no-restricted-paths)
// NOTE: `from` = what's being imported, `target` = the file doing the import // NOTE: `from` = what's being imported, `target` = the file doing the import
'import/no-restricted-paths': [ 'import/no-restricted-paths': [
'error', 'error',

View File

@ -21,6 +21,7 @@
"react-router-dom": "^6.20.0" "react-router-dom": "^6.20.0"
}, },
"devDependencies": { "devDependencies": {
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",

View File

@ -1,15 +1 @@
import { createContext } from 'react'; export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
import type { UserResponse } from '@/shared/api/responses';
export interface SessionContextValue {
user: UserResponse | null;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (data: { name?: string }) => Promise<void>;
}
export const SessionContext = createContext<SessionContextValue | null>(null);

View File

@ -1,13 +1,26 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { SessionContext, type SessionState } from './sessionContext'; import { SessionContext } from './sessionContext';
import { useSession } from './useSession'; import { useSession } from './useSession';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
type SessionState = {
user: { id: number; email: string; name: string; role: string } | null;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: () => Promise<void>;
logout: () => Promise<void>;
register: () => Promise<void>;
updateProfile: () => Promise<void>;
refreshSession: () => Promise<void>;
};
const mockSession: SessionState = { const mockSession: SessionState = {
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' }, user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
accessToken: 'mock-access-token', accessToken: 'mock-access-token',
isAuthenticated: true, isAuthenticated: true,
isLoading: false,
login: vi.fn().mockResolvedValue(undefined), login: vi.fn().mockResolvedValue(undefined),
logout: vi.fn(), logout: vi.fn(),
register: vi.fn().mockResolvedValue(undefined), register: vi.fn().mockResolvedValue(undefined),

View File

@ -0,0 +1,6 @@
import { usePositionMutations } from '@/entities/portfolio';
export function useAddPosition(portfolioId: number) {
const { add } = usePositionMutations(portfolioId);
return add;
}

View File

@ -0,0 +1 @@
export { AddPositionForm } from './ui/AddPositionForm';

View File

@ -0,0 +1,30 @@
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,
};
}

View File

@ -0,0 +1,111 @@
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>
);
}

View File

@ -1,131 +1,9 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
import { useBrokerOperations } from '@/entities/broker-operation'; import { useBrokerOperations } from '@/entities/broker-operation';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'; import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
import { import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview';
formatBrokerMoney as formatMoney,
formatBrokerPercent as formatPercent,
pluralize,
} from '@/shared/lib/formatters';
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>
);
}
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)}%`;
}
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>
);
}
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>
);
}
export function BrokerAccountOverviewPage() { export function BrokerAccountOverviewPage() {
const { accountId, portfolio } = useBrokerAccountContext(); const { accountId, portfolio } = useBrokerAccountContext();

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
BROKER_OPERATION_TYPE_OPTIONS, BROKER_OPERATION_TYPE_OPTIONS,
@ -7,43 +7,30 @@ import {
} from '@/entities/broker-operation'; } from '@/entities/broker-operation';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
export function BrokerOperationsPage() { export function BrokerOperationsPage() {
const { accountId } = useBrokerAccountContext(); const { accountId } = useBrokerAccountContext();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const urlType = searchParams.get('type'); const urlType = searchParams.get('type');
const selectedType = isBrokerOperationType(urlType) ? urlType : ''; const selectedType = isBrokerOperationType(urlType) ? urlType : '';
const [cursor, setCursor] = useState<string | undefined>(undefined); const pagination = useCursorPagination();
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const operations = useBrokerOperations(accountId, { const operations = useBrokerOperations(accountId, {
limit: 10, limit: 10,
cursor, cursor: pagination.cursor,
operationTypes: selectedType || undefined, operationTypes: selectedType || undefined,
}); });
useEffect(() => { useEffect(() => {
setCursor(undefined); pagination.reset();
setCursorStack([]); }, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedType]);
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) { function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
const nextType = event.target.value; const nextType = event.target.value;
setSearchParams(nextType ? { type: nextType } : {}, { replace: true }); setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
} }
function handleNext() {
const nextCursor = operations.data?.nextCursor;
if (!nextCursor || !operations.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));
}
const history = operations.error ? ( const history = operations.error ? (
<p role="alert">Не удалось загрузить историю операций</p> <p role="alert">Не удалось загрузить историю операций</p>
) : ( ) : (
@ -56,11 +43,11 @@ export function BrokerOperationsPage() {
isFetching={operations.isFetching} isFetching={operations.isFetching}
page={operations.data} page={operations.data}
pagination={{ pagination={{
pageNumber: cursorStack.length + 1, pageNumber: pagination.pageNumber,
canGoBack: cursorStack.length > 0, canGoBack: pagination.pageNumber > 1,
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
onPrevious: handlePrevious, onPrevious: pagination.handlePrevious,
onNext: handleNext, onNext: () => pagination.handleNext(operations.data?.nextCursor),
}} }}
/> />
); );

View File

@ -1,262 +1,7 @@
import { useState } from 'react'; import { useBrokerPositions } from '@/entities/broker-position';
import { Link } from 'react-router-dom';
import type {
BrokerPosition,
BrokerPositionsPage as BrokerPositionsPageData,
} from '@/shared/api/responses';
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters'; import { BrokerPositionTable } from '@/widgets/broker-positions-table';
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
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;
function formatQuantity(value: number | null | undefined) {
return value == null ? '-' : value.toLocaleString('ru-RU');
}
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>
);
}
function BrokerPositionTable({
title,
page,
isLoading,
isFetching,
emptyMessage,
pageNumber,
onNext,
onPrevious,
}: {
title: string;
page: BrokerPositionsPageData | undefined;
isLoading: boolean;
isFetching: boolean;
emptyMessage: string;
pageNumber: number;
onNext: () => void;
onPrevious: () => void;
}) {
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>
);
}
type BrokerPositionsPageProps = { type BrokerPositionsPageProps = {
type: 'share' | 'bond'; type: 'share' | 'bond';
@ -265,22 +10,8 @@ type BrokerPositionsPageProps = {
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
const { accountId } = useBrokerAccountContext(); const { accountId } = useBrokerAccountContext();
const [cursor, setCursor] = useState<string | undefined>(undefined); const pagination = useCursorPagination();
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]); const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor });
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) { if (positions.error) {
return ( return (
@ -302,9 +33,9 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
isLoading={positions.isLoading} isLoading={positions.isLoading}
isFetching={positions.isFetching} isFetching={positions.isFetching}
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'} emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
pageNumber={cursorStack.length + 1} pageNumber={pagination.pageNumber}
onNext={handleNext} onNext={() => pagination.handleNext(positions.data?.nextCursor)}
onPrevious={handlePrevious} onPrevious={pagination.handlePrevious}
/> />
); );
} }

View File

@ -6,6 +6,7 @@ import { PortfolioSummary } from '@/widgets/portfolio-summary';
import { AnalyticsSummary } from '@/widgets/portfolio-analytics'; import { AnalyticsSummary } from '@/widgets/portfolio-analytics';
import { SharePositionTable } from '@/widgets/share-positions-table'; import { SharePositionTable } from '@/widgets/share-positions-table';
import { BondPositionTable } from '@/widgets/bond-positions-table'; import { BondPositionTable } from '@/widgets/bond-positions-table';
import { AddPositionForm } from '@/features/add-position';
export function PortfolioDetailPage() { export function PortfolioDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -13,18 +14,10 @@ export function PortfolioDetailPage() {
const { data: portfolio, isLoading, error } = usePortfolio(portfolioId); const { data: portfolio, isLoading, error } = usePortfolio(portfolioId);
const { update, remove } = usePortfolioMutations(); const { update, remove } = usePortfolioMutations();
const { const { update: updatePosition, remove: removePosition } = usePositionMutations(portfolioId);
update: updatePosition,
remove: removePosition,
add: addPosition,
} = usePositionMutations(portfolioId);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [showAddForm, setShowAddForm] = useState(false); 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]);
if (isLoading) { if (isLoading) {
return ( return (
@ -48,26 +41,6 @@ export function PortfolioDetailPage() {
} }
} }
function handleAddPosition() {
if (!newSecid.trim() || !parseInt(newQty, 10)) return;
addPosition.mutate(
{
secid: newSecid.trim().toUpperCase(),
quantity: parseInt(newQty, 10),
buyPrice: newPrice ? parseFloat(newPrice) : undefined,
buyDate: newDate || undefined,
},
{
onSuccess: () => {
setShowAddForm(false);
setNewSecid('');
setNewQty('1');
setNewPrice('');
},
},
);
}
return ( return (
<div> <div>
<div <div
@ -167,108 +140,7 @@ export function PortfolioDetailPage() {
</button> </button>
</div> </div>
{showAddForm && ( {showAddForm && <AddPositionForm portfolioId={portfolioId} />}
<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={newSecid}
onChange={(e) => setNewSecid(e.target.value)}
placeholder="SBER"
style={{
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
width: 120,
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
Количество
</label>
<input
type="number"
min={1}
value={newQty}
onChange={(e) => setNewQty(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
width: 100,
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
Цена покупки
</label>
<input
type="number"
step="0.01"
value={newPrice}
onChange={(e) => setNewPrice(e.target.value)}
placeholder="0.00"
style={{
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
width: 120,
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
Дата покупки
</label>
<input
type="date"
value={newDate}
onChange={(e) => setNewDate(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
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>
)}
<SharePositionTable <SharePositionTable
positions={portfolio.positions.filter((p) => p.type === 'share')} positions={portfolio.positions.filter((p) => p.type === 'share')}

View File

@ -0,0 +1,15 @@
import { createContext } from 'react';
import type { UserResponse } from '@/shared/api/responses';
export interface SessionContextValue {
user: UserResponse | null;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (data: { name?: string }) => Promise<void>;
}
export const SessionContext = createContext<SessionContextValue | null>(null);

View File

@ -0,0 +1,68 @@
import { type ReactNode, useEffect, useState, useCallback } from 'react';
import { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
import * as sessionApi from '@/entities/session/api/sessionApi';
export function TestSessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionContextValue['user']>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
sessionApi
.refresh()
.then((result) => {
if (!cancelled) {
setUser(result.user);
setAccessToken(result.accessToken);
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (email: string, password: string) => {
const result = await sessionApi.login(email, password);
setUser(result.user);
setAccessToken(result.accessToken);
}, []);
const register = useCallback(async (email: string, password: string, name?: string) => {
const result = await sessionApi.register(email, password, name);
setUser(result.user);
setAccessToken(result.accessToken);
}, []);
const logout = useCallback(async () => {
await sessionApi.logout();
setUser(null);
setAccessToken(null);
}, []);
const updateProfile = useCallback(async (data: { name?: string }) => {
const updated = await sessionApi.updateProfile(data);
setUser(updated);
}, []);
if (isLoading) {
return <div>Загрузка...</div>;
}
const value: SessionContextValue = {
user,
accessToken,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
updateProfile,
};
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}

View File

@ -2,7 +2,7 @@ import { type ReactElement } from 'react';
import { render, type RenderOptions } from '@testing-library/react'; import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { SessionProvider } from '@/app/providers'; import { TestSessionProvider } from './TestSessionProvider';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> { interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient; queryClient?: QueryClient;
@ -33,7 +33,7 @@ export function renderWithProviders(
initialEntries={[route]} initialEntries={[route]}
future={{ v7_startTransition: true, v7_relativeSplatPath: true }} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
> >
<SessionProvider>{children}</SessionProvider> <TestSessionProvider>{children}</TestSessionProvider>
</MemoryRouter> </MemoryRouter>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@ -0,0 +1,40 @@
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 | null | 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;
const lastCursor = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
setCursor(lastCursor);
return remaining;
});
return undefined;
}, []);
const reset = useCallback(() => {
setCursor(undefined);
setCursorStack([]);
}, []);
return {
cursor,
pageNumber: cursorStack.length + 1,
handleNext,
handlePrevious,
reset,
};
}

View File

@ -0,0 +1,3 @@
export { BrokerSummary } from './ui/BrokerSummary';
export { BrokerAssetCards } from './ui/BrokerAssetCards';
export { BrokerOverviewSkeleton } from './ui/BrokerOverviewSkeleton';

View File

@ -0,0 +1,59 @@
import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
import { formatBrokerMoney, 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 ? '\u2014' : `${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>{formatBrokerMoney(card.value)}</span>
<span>
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
</span>
</Link>
))}
</section>
);
}

View File

@ -0,0 +1,30 @@
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>
);
}

View File

@ -0,0 +1,36 @@
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>
);
}

View File

@ -0,0 +1 @@
export { BrokerPositionTable } from './ui/BrokerPositionTable';

View File

@ -0,0 +1,234 @@
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
import { PositionTicker } from './PositionTicker';
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;
function formatQuantity(value: number | null | undefined) {
return value == null ? '-' : value.toLocaleString('ru-RU');
}
export function BrokerPositionTable({
title,
page,
isLoading,
isFetching,
emptyMessage,
pageNumber,
onNext,
onPrevious,
}: {
title: string;
page: BrokerPositionsPageData | undefined;
isLoading: boolean;
isFetching: boolean;
emptyMessage: string;
pageNumber: number;
onNext: () => void;
onPrevious: () => void;
}) {
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>
);
}

View File

@ -0,0 +1,22 @@
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,96 @@
# FSD Frontend Refactor
## Цель
Довести фронтенд-архитектуру до эталонного Feature-Sliced Design (FSD) — устранить существующие нарушения, вынести жирные страницы в виджеты/фичи, внедрить автоматический контроль FSD-правил.
Текущее состояние: 4.2/5 по FSD-зрелости. Цель: 5/5.
## Задачи
### 1. Исправить нарушение слоёв в shared/lib/test
- `shared/lib/test/test-utils.tsx` импортирует `SessionProvider` из `@/app/providers`
- Это единственное нарушение FSD (shared → app)
- Решение: вынести `TestSessionProvider` в `shared/lib/test/` и переключить `test-utils.tsx` на него
- Тесты не должны сломаться: `TestSessionProvider` предоставляет те же значения контекста (замоканые)
### 2. Вынести BrokerPositionTable из страницы в виджет
- `pages/broker-positions/ui/BrokerPositionsPage.tsx` (310 строк) содержит внутренние компоненты:
- `BrokerPositionTable` (~190 строк) — таблица с пагинацией, skeleton, inline-стилями
- `PositionTicker` (~20 строк) — ссылка на инструмент
- `formatQuantity` — хелпер
- Решение: создать `widgets/broker-positions-table/` с:
- `ui/BrokerPositionTable.tsx`
- `ui/PositionTicker.tsx`
- `index.ts`
- Страница сокращается до ~100 строк (только cursor-логика + композиция)
### 3. Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в виджет
- `pages/broker-account/ui/BrokerAccountOverviewPage.tsx` (160 строк) содержит:
- `BrokerSummary` — сводка портфеля (карточки стоимости и денег)
- `BrokerAssetCards` — карточки классов активов (акции/облигации)
- `BrokerOverviewSkeleton` — скелетон загрузки
- `allocationPercent`, `formatAllocationPercent` — хелперы
- Решение: создать `widgets/broker-overview/` с:
- `ui/BrokerSummary.tsx`
- `ui/BrokerAssetCards.tsx`
- `ui/BrokerOverviewSkeleton.tsx`
- `index.ts`
- Страница сокращается до ~40 строк (только loading/error guard + композиция)
### 4. Вынести AddPositionForm в фичу
- `pages/portfolios/ui/PortfolioDetailPage.tsx` (290 строк) содержит inline-форму добавления позиции:
- 4 state-переменные (newSecid, newQty, newPrice, newDate)
- 4 input-поля с inline-стилями
- Валидация и submit
- Это полноценная бизнес-фича, не место в странице
- Решение: создать `features/add-position/` с:
- `api/useAddPosition.ts` — мутация (обёртка над usePositionMutations)
- `model/useAddPositionForm.ts` — управление формой
- `ui/AddPositionForm.tsx` — UI формы
- `index.ts`
- Страница сокращается до ~140 строк (showAddForm toggle + `<AddPositionForm portfolioId={id} />`)
### 5. Вынести cursor-пагинацию в shared-хук
- 25 строк cursor-логики дублируются в:
- `pages/broker-positions/ui/BrokerPositionsPage.tsx`
- `pages/broker-operations/ui/BrokerOperationsPage.tsx`
- Решение: создать `shared/lib/useCursorPagination.ts`
```ts
function useCursorPagination() {
// cursor, pageNumber, handleNext(cursor), handlePrevious, reset
}
```
### 6. Добавить ESLint-плагин FSD
- Текущий `import/no-restricted-paths` проверяет только межслойные границы
- Плагин `@conarti/eslint-plugin-feature-sliced` добавит:
- Проверки public API (запрет импорта из внутренних модулей в обход index.ts)
- Проверки сегментов (api/ui/model не импортируют друг друга напрямую)
- Решение: установить плагин и включить recommended rules
## Критерии приёмки (Acceptance Criteria)
- [ ] AC1: `test-utils.tsx` не импортирует из `@/app/*`
- [ ] AC2: `widgets/broker-positions-table` создан, страница использует его
- [ ] AC3: `widgets/broker-overview` создан, страница использует его
- [ ] AC4: `features/add-position` создана, страница использует её
- [ ] AC5: `shared/lib/useCursorPagination.ts` создан, обе страницы используют его
- [ ] AC6: `@conarti/eslint-plugin-feature-sliced` установлен и настроен
- [ ] AC7: `npm run lint` проходит без ошибок
- [ ] AC8: `npm run build` проходит без ошибок
- [ ] AC9: Все существующие тесты проходят (включая тесты рефакторимых компонентов)
- [ ] AC10: Визуально поведение страниц не изменилось
## Ограничения
- Не менять API-контракты компонентов (пропсы, типы). Только перемещение кода.
- Не добавлять новую функциональность — только рефакторинг.
- inline-стили переносятся вместе с компонентами; централизация UI-кита — отдельная задача.

View File

@ -0,0 +1,13 @@
# FSD Frontend Refactor — Tasks
**Ветка:** `codex/fsd-frontend-refactor`
**Порядок:** последовательно, каждый task — независимый коммит.
- [ ] **Task 1:** Исправить нарушение shared → app — TestSessionProvider
- [ ] **Task 2:** Вынести BrokerPositionTable из страницы в widgets
- [ ] **Task 3:** Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в widgets
- [ ] **Task 4:** Вынести AddPositionForm в features/add-position
- [ ] **Task 5:** Вынести useCursorPagination в shared/lib/
- [ ] **Task 6:** Добавить @conarti/eslint-plugin-feature-sliced
- [ ] **Финальная проверка:** `npm run build -w apps/frontend && npm run lint -w apps/frontend && npm run -w apps/frontend test run`

31
package-lock.json generated
View File

@ -93,6 +93,7 @@
"react-router-dom": "^6.20.0" "react-router-dom": "^6.20.0"
}, },
"devDependencies": { "devDependencies": {
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@ -3416,6 +3417,36 @@
"node": ">=0.1.90" "node": ">=0.1.90"
} }
}, },
"node_modules/@conarti/eslint-plugin-feature-sliced": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@conarti/eslint-plugin-feature-sliced/-/eslint-plugin-feature-sliced-1.0.5.tgz",
"integrity": "sha512-Gx90Zupi8nv6cCP3O6cYWE9GT4RIw0D9JBHWeUwXEv7qmjCd3dXSqDEmQchN7k41hhCPupZvMevH4JrSORcVjQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"picomatch": "^2.3.1"
},
"engines": {
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
},
"peerDependencies": {
"eslint": ">=7",
"eslint-plugin-import": ">=2.26"
}
},
"node_modules/@conarti/eslint-plugin-feature-sliced/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@csstools/cascade-layer-name-parser": { "node_modules/@csstools/cascade-layer-name-parser": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz",