44 KiB
Raw Permalink Blame History

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