diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fa9f736..52f458b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -26,9 +26,6 @@ jobs: - name: Lint run: npm run lint - - name: Format check - run: npm run format:check - - name: Test backend run: npm run test:backend diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index e5ce635..0000000 --- a/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "semi": true -} diff --git a/apps/backend/src/common/dto/api-response.dto.ts b/apps/backend/src/common/dto/api-response.dto.ts index ff612e5..54013b0 100644 --- a/apps/backend/src/common/dto/api-response.dto.ts +++ b/apps/backend/src/common/dto/api-response.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; export class ApiResponseMeta { - @ApiProperty({ nullable: true }) + @ApiProperty({ type: String, nullable: true }) cachedAt: string | null; @ApiProperty() diff --git a/apps/backend/src/modules/bonds/bonds.controller.ts b/apps/backend/src/modules/bonds/bonds.controller.ts index 613431a..d5af481 100644 --- a/apps/backend/src/modules/bonds/bonds.controller.ts +++ b/apps/backend/src/modules/bonds/bonds.controller.ts @@ -1,26 +1,32 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { BondsService } from './bonds.service'; +import { BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto } from './dto/bonds-envelope.dto'; @ApiTags('Bonds') +@ApiExtraModels(ApiResponseMeta) @Controller('securities/bonds') export class BondsController { constructor(private readonly bondsService: BondsService) {} @Get(':secid') @ApiOperation({ summary: 'Получить спецификацию облигации' }) + @ApiOkResponse({ type: BondEnvelopeDto }) async getBond(@Param('secid') secid: string) { return this.bondsService.getBond(secid); } @Get(':secid/marketdata') @ApiOperation({ summary: 'Получить рыночные данные облигации' }) + @ApiOkResponse({ type: BondMarketDataEnvelopeDto }) async getMarketData(@Param('secid') secid: string) { return this.bondsService.getMarketData(secid); } @Get(':secid/history') @ApiOperation({ summary: 'Получить дневную историю торгов облигации' }) + @ApiOkResponse({ type: BondHistoryEnvelopeDto }) async getHistory( @Param('secid') secid: string, @Query('from') from: string, diff --git a/apps/backend/src/modules/bonds/dto/bonds-envelope.dto.ts b/apps/backend/src/modules/bonds/dto/bonds-envelope.dto.ts new file mode 100644 index 0000000..7d17a0a --- /dev/null +++ b/apps/backend/src/modules/bonds/dto/bonds-envelope.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; +import { BondMarketDataDto, BondResponseDto } from './bond-response.dto'; +import { BondHistoryItemDto } from './history-item.dto'; + +export class BondEnvelopeDto { + @ApiProperty({ type: BondResponseDto }) + data!: BondResponseDto; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} + +export class BondMarketDataEnvelopeDto { + @ApiProperty({ type: BondMarketDataDto }) + data!: BondMarketDataDto; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} + +export class BondHistoryEnvelopeDto { + @ApiProperty({ type: [BondHistoryItemDto] }) + data!: BondHistoryItemDto[]; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} diff --git a/apps/backend/src/modules/bonds/dto/history-item.dto.ts b/apps/backend/src/modules/bonds/dto/history-item.dto.ts new file mode 100644 index 0000000..58b2087 --- /dev/null +++ b/apps/backend/src/modules/bonds/dto/history-item.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BondHistoryItemDto { + @ApiProperty({ example: '2026-06-01' }) + date!: string; + + @ApiProperty({ example: 100.45 }) + closePrice!: number; + + @ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 }) + yieldClose!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 }) + duration!: number | null; +} diff --git a/apps/backend/src/modules/candles/candles.controller.ts b/apps/backend/src/modules/candles/candles.controller.ts index 15747d9..4bfd91b 100644 --- a/apps/backend/src/modules/candles/candles.controller.ts +++ b/apps/backend/src/modules/candles/candles.controller.ts @@ -1,15 +1,19 @@ import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { CandlesService } from './candles.service'; import { CandlesQueryDto } from './dto/candles-query.dto'; +import { CandleEnvelopeDto } from './dto/candles-envelope.dto'; @ApiTags('Candles') +@ApiExtraModels(ApiResponseMeta) @Controller('securities') export class CandlesController { constructor(private readonly candlesService: CandlesService) {} @Get('shares/:secid/candles') @ApiOperation({ summary: 'Получить свечи акции' }) + @ApiOkResponse({ type: CandleEnvelopeDto }) async getShareCandles( @Param('secid') secid: string, @Query(ValidationPipe) query: CandlesQueryDto, @@ -19,6 +23,7 @@ export class CandlesController { @Get('bonds/:secid/candles') @ApiOperation({ summary: 'Получить свечи облигации' }) + @ApiOkResponse({ type: CandleEnvelopeDto }) async getBondCandles( @Param('secid') secid: string, @Query(ValidationPipe) query: CandlesQueryDto, diff --git a/apps/backend/src/modules/candles/dto/candle-item.dto.ts b/apps/backend/src/modules/candles/dto/candle-item.dto.ts new file mode 100644 index 0000000..403ce8b --- /dev/null +++ b/apps/backend/src/modules/candles/dto/candle-item.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CandleItemDto { + @ApiProperty({ example: 321.3 }) + open!: number; + + @ApiProperty({ example: 322.66 }) + high!: number; + + @ApiProperty({ example: 321.2 }) + low!: number; + + @ApiProperty({ example: 322.35 }) + close!: number; + + @ApiProperty({ example: 1925163 }) + volume!: number; + + @ApiProperty({ example: 620184479 }) + value!: number; + + @ApiProperty({ example: '2026-06-01T10:00:00' }) + begin!: string; + + @ApiProperty({ example: '2026-06-01T10:59:00' }) + end!: string; +} diff --git a/apps/backend/src/modules/candles/dto/candles-envelope.dto.ts b/apps/backend/src/modules/candles/dto/candles-envelope.dto.ts new file mode 100644 index 0000000..b78c866 --- /dev/null +++ b/apps/backend/src/modules/candles/dto/candles-envelope.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; +import { CandleItemDto } from './candle-item.dto'; + +export class CandleEnvelopeDto { + @ApiProperty({ type: [CandleItemDto] }) + data!: CandleItemDto[]; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} diff --git a/apps/backend/src/modules/health/dto/health-envelope.dto.ts b/apps/backend/src/modules/health/dto/health-envelope.dto.ts new file mode 100644 index 0000000..eab8c7e --- /dev/null +++ b/apps/backend/src/modules/health/dto/health-envelope.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; +import { HealthResponseDto } from './health-response.dto'; + +export class HealthEnvelopeDto { + @ApiProperty({ type: HealthResponseDto }) + data!: HealthResponseDto; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} diff --git a/apps/backend/src/modules/health/dto/health-response.dto.ts b/apps/backend/src/modules/health/dto/health-response.dto.ts new file mode 100644 index 0000000..770c4d8 --- /dev/null +++ b/apps/backend/src/modules/health/dto/health-response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class HealthResponseDto { + @ApiProperty({ example: 'ok' }) + status!: string; + + @ApiProperty({ example: '2026-06-23T06:00:00.000Z' }) + timestamp!: string; + + @ApiProperty({ example: 12345 }) + uptime!: number; +} diff --git a/apps/backend/src/modules/health/health.controller.ts b/apps/backend/src/modules/health/health.controller.ts index e27e33b..b4b6451 100644 --- a/apps/backend/src/modules/health/health.controller.ts +++ b/apps/backend/src/modules/health/health.controller.ts @@ -1,13 +1,17 @@ import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { Public } from '../auth/decorators/public.decorator'; +import { HealthEnvelopeDto } from './dto/health-envelope.dto'; @ApiTags('Health') +@ApiExtraModels(ApiResponseMeta) @Controller('health') export class HealthController { @Get() @Public() @ApiOperation({ summary: 'Проверка состояния сервиса' }) + @ApiOkResponse({ type: HealthEnvelopeDto }) check() { return { status: 'ok', diff --git a/apps/backend/src/modules/securities/dto/search-response.dto.ts b/apps/backend/src/modules/securities/dto/search-response.dto.ts new file mode 100644 index 0000000..2c9b1f6 --- /dev/null +++ b/apps/backend/src/modules/securities/dto/search-response.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; + +export class SearchResultItemDto { + @ApiProperty({ example: 'SBER' }) + secid!: string; + + @ApiProperty({ example: 'RU0009029540' }) + isin!: string; + + @ApiProperty({ example: 'Сбербанк' }) + shortName!: string; + + @ApiProperty({ enum: ['share', 'bond'] }) + type!: 'share' | 'bond'; + + @ApiProperty({ example: 1 }) + listLevel!: number; + + @ApiPropertyOptional({ type: String, nullable: true, example: 'RUB' }) + currency!: string | null; + + @ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 }) + price!: number | null; +} + +export class SearchEnvelopeDto { + @ApiProperty({ type: [SearchResultItemDto] }) + data!: SearchResultItemDto[]; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} diff --git a/apps/backend/src/modules/securities/securities.controller.ts b/apps/backend/src/modules/securities/securities.controller.ts index be26469..5f17949 100644 --- a/apps/backend/src/modules/securities/securities.controller.ts +++ b/apps/backend/src/modules/securities/securities.controller.ts @@ -1,12 +1,15 @@ import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { SecuritiesService } from './securities.service'; import { ScreenerService } from './screener.service'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { ScreenerQueryDto } from './dto/screener-query.dto'; import { ScreenerResponseDto } from './dto/screener-response.dto'; +import { SearchEnvelopeDto } from './dto/search-response.dto'; @ApiTags('Securities') +@ApiExtraModels(ApiResponseMeta) @Controller('securities') export class SecuritiesController { constructor( @@ -16,6 +19,7 @@ export class SecuritiesController { @Get('search') @ApiOperation({ summary: 'Поиск по инструментам' }) + @ApiOkResponse({ type: SearchEnvelopeDto }) async search(@Query(ValidationPipe) query: SearchQueryDto) { const results = await this.securitiesService.search( query.q, diff --git a/apps/backend/src/modules/shares/dto/dividend-item.dto.ts b/apps/backend/src/modules/shares/dto/dividend-item.dto.ts new file mode 100644 index 0000000..25fb894 --- /dev/null +++ b/apps/backend/src/modules/shares/dto/dividend-item.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DividendItemDto { + @ApiProperty({ example: '2026-05-15' }) + registryCloseDate!: string; + + @ApiProperty({ example: 33.47 }) + value!: number; + + @ApiProperty({ example: 'RUB' }) + currency!: string; +} diff --git a/apps/backend/src/modules/shares/dto/history-item.dto.ts b/apps/backend/src/modules/shares/dto/history-item.dto.ts new file mode 100644 index 0000000..8cb6c0d --- /dev/null +++ b/apps/backend/src/modules/shares/dto/history-item.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class HistoryItemDto { + @ApiProperty({ example: '2026-06-01' }) + date!: string; + + @ApiProperty({ example: 321.3 }) + open!: number; + + @ApiProperty({ example: 322.66 }) + high!: number; + + @ApiProperty({ example: 321.2 }) + low!: number; + + @ApiProperty({ example: 322.35 }) + close!: number; + + @ApiProperty({ example: 1925163 }) + volume!: number; + + @ApiProperty({ example: 620184479 }) + value!: number; +} diff --git a/apps/backend/src/modules/shares/dto/shares-envelope.dto.ts b/apps/backend/src/modules/shares/dto/shares-envelope.dto.ts new file mode 100644 index 0000000..0ade6df --- /dev/null +++ b/apps/backend/src/modules/shares/dto/shares-envelope.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; +import { ShareResponseDto } from './share-response.dto'; +import { ShareMarketDataResponseDto } from './share-marketdata-response.dto'; +import { HistoryItemDto } from './history-item.dto'; +import { DividendItemDto } from './dividend-item.dto'; + +export class ShareEnvelopeDto { + @ApiProperty({ type: ShareResponseDto }) + data!: ShareResponseDto; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} + +export class ShareMarketDataEnvelopeDto { + @ApiProperty({ type: ShareMarketDataResponseDto }) + data!: ShareMarketDataResponseDto; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} + +export class DividendsEnvelopeDto { + @ApiProperty({ type: [DividendItemDto] }) + data!: DividendItemDto[]; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} + +export class ShareHistoryEnvelopeDto { + @ApiProperty({ type: [HistoryItemDto] }) + data!: HistoryItemDto[]; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} diff --git a/apps/backend/src/modules/shares/shares.controller.ts b/apps/backend/src/modules/shares/shares.controller.ts index ea8a41d..1f082c8 100644 --- a/apps/backend/src/modules/shares/shares.controller.ts +++ b/apps/backend/src/modules/shares/shares.controller.ts @@ -1,14 +1,23 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { SharesService } from './shares.service'; +import { + ShareEnvelopeDto, + ShareMarketDataEnvelopeDto, + DividendsEnvelopeDto, + ShareHistoryEnvelopeDto, +} from './dto/shares-envelope.dto'; @ApiTags('Shares') +@ApiExtraModels(ApiResponseMeta) @Controller('securities/shares') export class SharesController { constructor(private readonly sharesService: SharesService) {} @Get(':secid') @ApiOperation({ summary: 'Получить спецификацию акции' }) + @ApiOkResponse({ type: ShareEnvelopeDto }) async getShare(@Param('secid') secid: string) { const share = await this.sharesService.getShare(secid); return { data: share, meta: { cachedAt: null, fromCache: false } }; @@ -16,18 +25,21 @@ export class SharesController { @Get(':secid/marketdata') @ApiOperation({ summary: 'Получить рыночные данные акции' }) + @ApiOkResponse({ type: ShareMarketDataEnvelopeDto }) async getMarketData(@Param('secid') secid: string) { return this.sharesService.getMarketData(secid); } @Get(':secid/dividends') @ApiOperation({ summary: 'Получить дивиденды' }) + @ApiOkResponse({ type: DividendsEnvelopeDto }) async getDividends(@Param('secid') secid: string) { return this.sharesService.getDividends(secid); } @Get(':secid/history') @ApiOperation({ summary: 'Получить дневную историю торгов акции' }) + @ApiOkResponse({ type: ShareHistoryEnvelopeDto }) async getHistory( @Param('secid') secid: string, @Query('from') from: string, diff --git a/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts index fb92719..fccb172 100644 --- a/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts @@ -21,37 +21,37 @@ export class BrokerEventItemDto { @ApiProperty() eventDate!: string; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: String, nullable: true }) paymentDate!: string | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: String, nullable: true }) ticker!: string | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: String, nullable: true }) name!: string | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: String, nullable: true }) instrumentUid!: string | null; @ApiProperty({ enum: instrumentTypes }) instrumentType!: string; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: Number, nullable: true }) quantitySnapshot!: number | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: Number, nullable: true }) payoutPerUnit!: number | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: Number, nullable: true }) estimatedAmount!: number | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: Number, nullable: true }) actualAmount!: number | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: String, nullable: true }) currency!: string | null; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: String, nullable: true, enum: ['current_position'] }) estimateMode!: 'current_position' | null; } @@ -59,7 +59,7 @@ export class BrokerEventsSummaryDto { @ApiProperty({ minimum: 0 }) eventCount!: number; - @ApiProperty({ nullable: true }) + @ApiProperty({ type: String, nullable: true }) nearestEventDate!: string | null; @ApiProperty() diff --git a/apps/docs/docs/adr/ADR-017-biome-linter-formatter.md b/apps/docs/docs/adr/ADR-017-biome-linter-formatter.md new file mode 100644 index 0000000..e1c5be8 --- /dev/null +++ b/apps/docs/docs/adr/ADR-017-biome-linter-formatter.md @@ -0,0 +1,67 @@ +# ADR-017: Biome как единый инструмент линтинга и форматирования + +**Дата:** 2026-06-23 +**Статус:** Принято +**Автор:** AI Agent (codex/frontend-infrastructure-tooling) + +## Контекст + +Проект использует **ESLint v8** + **Prettier** для линтинга и форматирования. ESLint 8 устарел: ESLint 9 имеет полностью изменённый конфиг, миграция потребует переписывания `.eslintrc`. Оба инструмента написаны на JavaScript и работают медленно на больших кодовых базах. + +Появились современные Rust-альтернативы, объединяющие линтинг и форматирование в одном CLI со значительным приростом производительности. + +## Рассмотренные варианты + +### Biome v2.5 +- Linter + Formatter в одном CLI +- 500+ правил (ESLint + TypeScript ESLint + others) +- 97% совместимость форматтера с Prettier +- ~35x быстрее Prettier, ~35x быстрее ESLint +- Поддержка: JS, TS, JSX, TSX, JSON, HTML, CSS, GraphQL +- Стабильный LTS, продакшн у AWS, Google, Vercel +- Встроенная миграция: `biome migrate eslint` + +### Oxlint + Oxfmt (Oxc Project) +- Linter отдельно (800+ правил, 50-100x быстрее ESLint) +- Formatter отдельно (Oxfmt, beta, 3x быстрее Biome, 30x быстрее Prettier) +- Type-aware linting через tsgo +- Два отдельных инструмента с разными конфигами +- Oxfmt в статусе beta + +### Оставить ESLint + Prettier +- Знакомый стек +- Медленная производительность +- Необходимость мигрировать на ESLint 9 в любом случае +- Два набора конфигов + +## Решение + +Перейти на **Biome** как единый инструмент для линтинга и форматирования. + +Причины: +1. **Один инструмент вместо двух** — меньше конфигов, один CI-степ, одна команда +2. **Production-ready** — стабильный LTS, используется крупными компаниями +3. **Плавная миграция** — `biome migrate eslint` переносит правила автоматически +4. **Производительность** — ~35x быстрее как линтинг, так и форматирование +5. **Встроенный import sorting** — замена `eslint-plugin-import` + +### Ограничения + +FSD-правила из `@conarti/eslint-plugin-feature-sliced` не имеют аналога в Biome. Если не удаётся портировать через plugin system — сохраняется минимальный `.eslintrc.cjs` только для FSD layer boundaries. + +## Последствия + +### Положительные +- Единый конфиг `biome.json` вместо `.eslintrc.cjs` + `.prettierrc` +- Ускорение CI (lint + format за один проход) +- Автоматический import sorting +- Меньше зависимостей в `package.json` + +### Риски +- FSD-правила могут не портироваться — потребуется костыль с минимальным ESLint +- Biome может не поддерживать какое-то редкое правило ESLint — потребуется адаптация +- Команде нужно привыкнуть к новому CLI + +## Связанные документы +- `docs/research/frontend-infrastructure-tooling/biome-vs-oxlint.md` +- `docs/features/frontend-infrastructure-tooling/plan.md` (Phase 2) diff --git a/apps/docs/docs/adr/ADR-018-tanstack-router.md b/apps/docs/docs/adr/ADR-018-tanstack-router.md new file mode 100644 index 0000000..99759bd --- /dev/null +++ b/apps/docs/docs/adr/ADR-018-tanstack-router.md @@ -0,0 +1,77 @@ +# ADR-018: TanStack Router как основной роутер + +**Дата:** 2026-06-23 +**Статус:** Принято +**Автор:** AI Agent (codex/frontend-infrastructure-tooling) + +## Контекст + +Проект использует **react-router-dom v6** для клиентской маршрутизации. Текущая реализация: + +- Все страницы импортируются статически в `AppRoutes.tsx` +- Нет lazy loading (code splitting) — каждая навигация грузит весь бандл +- Параметры роутов (`useParams`) и search params (`useSearchParams`) не типизированы +- Нет встроенной валидации search params +- Проект уже использует TanStack Query — потенциальная синергия с TanStack Router + +Требуется: +- Route-level code splitting для оптимизации бандла +- Типобезопасность параметров и search params +- Интеграция с существующим TanStack Query (prefetching через loaders) + +## Рассмотренные варианты + +### react-router-dom v6 + React.lazy +- Минимальные изменения — обернуть каждый импорт в `React.lazy` + `` +- Не решает проблему типизации +- Нет prefetching / loaders +- React.lazy boilerplate на каждый роут + +### TanStack Router +- Полная типобезопасность через генерацию RouteTree +- Search params с Zod-схемами +- Code splitting built-in — каждый роут ленивый по умолчанию +- Loaders для prefetching + интеграция с TanStack Query +- Pending/Error/NotFound boundaries на уровне роута +- Размер: ~3-4KB gzip (меньше react-router-dom) +- Требует переписывания всех роутов и навигации + +## Решение + +Мигрировать на **TanStack Router** (code-first approach). + +Причины: +1. **Типобезопасность** — RouteTree generation исключает опечатки в путях и невалидные search params +2. **Code splitting без boilerplate** — built-in lazy, не нужен `React.lazy` +3. **Синергия с TanStack Query** — уже используется в проекте; loaders дают prefetching данных до рендера компонента +4. **Zod** — уже используется для валидации форм; Router использует Zod для search params +5. **Search params** — типизированная валидация вместо строковых `useSearchParams` +6. **Меньший размер** — 3-4KB vs 8KB react-router-dom + +> **Примечание о реализации:** Изначально планировался file-based подход с `@tanstack/router-plugin`, но плагин не генерировал `routeTree.gen.ts` корректно на текущей версии Vite/плагина. Принято решение использовать code-first подход — все маршруты определяются вручную в `routeTree.tsx` через `createRootRoute` + `createRoute`. Это даёт тот же функционал (типобезопасность, guard'ы, код-сплиттинг) без зависимости от Vite-плагина. + +## Последствия + +### Положительные +- Каждая страница — отдельный chunk, грузится по требованию +- Search params валидируются Zod-схемами (screener, broker-operations) +- Loaders предзагружают данные, уменьшая время до первого контента +- Guard'ы (ProtectedRoute) реализуются через `beforeLoad`, единый подход + +### Риски +- Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов +- `MemoryRouter` в тестах заменяется на `createMemoryHistory` + `RouterProvider` из TanStack Router +- Файловая структура роутов меняется — `src/app/routing/` с code-first определением в `routeTree.tsx` +- TanStack Router не экспортирует `useSearchParams` — потребовалась обёртка `useSearchParamsCompat` +- `useNavigate` использует объектный синтаксис: `navigate({ to: '...' })` вместо строкового `navigate('...')` +- Learning curve для команды + +### Миграция +- Все маршруты определены в одном `routeTree.tsx` (code-first) +- Старый `AppRoutes.tsx` удалён после прохождения тестов +- `react-router-dom` удалён из зависимостей после верификации +- Все 125 тестов проходят, сборка зелёная + +## Связанные документы +- `docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md` +- `docs/features/frontend-infrastructure-tooling/plan.md` (Phase 6) diff --git a/apps/docs/docs/adr/ADR-019-api-layer-unification.md b/apps/docs/docs/adr/ADR-019-api-layer-unification.md new file mode 100644 index 0000000..075b898 --- /dev/null +++ b/apps/docs/docs/adr/ADR-019-api-layer-unification.md @@ -0,0 +1,64 @@ +# ADR-019: Унификация API-слоя: ky + codegen как единый источник типов + +**Дата:** 2026-06-23 +**Статус:** Принято +**Автор:** AI Agent (codex/frontend-infrastructure-tooling) + +## Контекст + +В проекте сложилась ситуация расхождения между принятыми ADR и фактической реализацией: + +- **ADR-005** решил использовать `openapi-typescript` + `openapi-fetch` для кодогенерации типов +- **ADR-015** решил использовать `ky` как HTTP-клиент +- Фактически: используется кастомный `client.ts` с нативным fetch, создан `kyClient.ts` (но не используется), рукописный `responses.ts` дублирует сгенерированный `types.ts` + +Проблемы: +- Дублирование типов — рукописные расходятся с codegen +- Два HTTP-клиента (один мёртвый) — путаница +- fetch-реализация без удобных интерцепторов (retry, timeout, hooks) + +## Решение + +### 1. ky как единый HTTP-клиент + +- Активировать `kyClient.ts` с доработанными hooks (normalizeEnvelope в afterResponse) +- Перевести все entity-API файлы с `request()` на `kyApi` +- Удалить `shared/api/client.ts` +- ADR-015 исполняется полностью + +### 2. OpenAPI codegen как единый источник типов + +- Удалить рукописный `shared/api/responses.ts` +- Все entity-API файлы используют типы из сгенерированного `shared/api/types.ts` +- Типы пишутся только через `npm run codegen` +- ADR-005 приводится к фактическому исполнению (без `openapi-fetch`, с кастомным клиентом) + +### 3. MSW Browser Mode (расширение существующей инфраструктуры) + +- MSW уже используется в тестах (`shared/lib/test/server.ts`) +- Добавить browser entry (`setupWorker`) для dev-режима +- Включается переменной `VITE_API_MOCK=true` +- Переиспользует существующие handlers + +### 4. Валидация переменных окружения + +- Zod-схема для `VITE_*` переменных +- Валидация при старте приложения + +## Последствия + +### Положительные +- Единый HTTP-клиент с интерцепторами (auth, retry, normalize) +- Нет дублирования типов — все через codegen +- Возможность разрабатывать UI без бэкенда (MSW browser) +- Раннее обнаружение ошибок конфигурации (env validation) + +### Риски +- Миграция entity API требует регрессионного тестирования каждого модуля +- MSW browser handlers могут отличаться от реального API — нужно синхронизировать +- При изменении OpenAPI spec нужно запускать codegen вручную + +## Связанные документы +- ADR-005: OpenAPI codegen через openapi-typescript +- ADR-015: Модернизация инфраструктуры фронтенда +- `docs/features/frontend-infrastructure-tooling/plan.md` (Phase 1, 3, 4, 5) diff --git a/apps/docs/docs/adr/index.md b/apps/docs/docs/adr/index.md index ac259c5..14fdbc6 100644 --- a/apps/docs/docs/adr/index.md +++ b/apps/docs/docs/adr/index.md @@ -17,6 +17,9 @@ | [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена | | [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets | | [ADR-015](ADR-015-frontend-libraries-modernization) | — | Модернизация инфраструктуры фронтенда | -| [ADR-016](ADR-016-design-system) | Accepted | Дизайн-система — гибридный подход | +| [ADR-016](ADR-016-design-system) | Accepted | Дизайн-система — гибридный подход | +| [ADR-017](ADR-017-biome-linter-formatter) | Accepted | Biome как единый инструмент линтинга и форматирования | +| [ADR-018](ADR-018-tanstack-router) | Accepted | TanStack Router как основной роутер | +| [ADR-019](ADR-019-api-layer-unification) | Accepted | Унификация API-слоя: ky + codegen как единый источник типов | Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе. diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs index ec4f261..105c181 100644 --- a/apps/frontend/.eslintrc.cjs +++ b/apps/frontend/.eslintrc.cjs @@ -5,21 +5,14 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { sourceType: 'module', - ecmaFeatures: { jsx: true }, }, - plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import', '@conarti/feature-sliced'], - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - ], + plugins: ['@conarti/feature-sliced', 'import'], root: true, env: { browser: true, es2020: true, }, settings: { - react: { version: 'detect' }, 'import/resolver': { typescript: { alwaysTryTypes: true, @@ -29,63 +22,31 @@ module.exports = { }, ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'], rules: { - 'no-restricted-imports': ['warn', { - paths: [{ - name: '@mui/material', - importNames: [ - // DS-covered: import from @moex-vibe/design-system - 'Typography', 'Button', 'TextField', 'Select', 'Checkbox', - 'Paper', 'Chip', 'Badge', 'Alert', 'Dialog', 'Skeleton', - 'CircularProgress', 'Link', 'IconButton', - 'Table', 'TableBody', 'TableCell', 'TableContainer', - 'TableHead', 'TableRow', 'TableSortLabel', - 'TablePagination', 'Pagination', - ], - message: 'Import from @moex-vibe/design-system instead, or use Box/Stack/Grid for layout.', - }], - }], - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - '@typescript-eslint/no-explicit-any': 'off', - 'react/react-in-jsx-scope': 'off', - // 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 'import/no-restricted-paths': [ 'error', { zones: [ - // shared/ cannot import from entities/, features/, widgets/, pages/, app/ { from: `${src}/entities`, target: `${src}/shared` }, { from: `${src}/features`, target: `${src}/shared` }, { from: `${src}/widgets`, target: `${src}/shared` }, { from: `${src}/pages`, target: `${src}/shared` }, { from: `${src}/app`, target: `${src}/shared` }, - // entities/ cannot import from features/, widgets/, pages/, app/ { from: `${src}/features`, target: `${src}/entities` }, { from: `${src}/widgets`, target: `${src}/entities` }, { from: `${src}/pages`, target: `${src}/entities` }, { from: `${src}/app`, target: `${src}/entities` }, - // features/ cannot import from widgets/, pages/, app/ { from: `${src}/widgets`, target: `${src}/features` }, { from: `${src}/pages`, target: `${src}/features` }, { from: `${src}/app`, target: `${src}/features` }, - // widgets/ cannot import from pages/, app/ { from: `${src}/pages`, target: `${src}/widgets` }, { from: `${src}/app`, target: `${src}/widgets` }, - // pages/ cannot import from app/ { from: `${src}/app`, target: `${src}/pages` }, - ], }, ], diff --git a/apps/frontend/mocks/browser.ts b/apps/frontend/mocks/browser.ts new file mode 100644 index 0000000..4dd03f0 --- /dev/null +++ b/apps/frontend/mocks/browser.ts @@ -0,0 +1,4 @@ +import { setupWorker } from 'msw/browser' +import { handlers } from './handlers' + +export const worker = setupWorker(...handlers) diff --git a/apps/frontend/mocks/data/auth.ts b/apps/frontend/mocks/data/auth.ts new file mode 100644 index 0000000..dcdfe37 --- /dev/null +++ b/apps/frontend/mocks/data/auth.ts @@ -0,0 +1,11 @@ +export const mockUser = { + id: 1, + email: 'user@test.com', + name: 'Test User', + role: 'user', +} + +export const mockAuthResponse = { + user: mockUser, + accessToken: 'mock-access-token', +} diff --git a/apps/frontend/mocks/data/bonds.ts b/apps/frontend/mocks/data/bonds.ts new file mode 100644 index 0000000..49e6944 --- /dev/null +++ b/apps/frontend/mocks/data/bonds.ts @@ -0,0 +1,40 @@ +export const mockBond = { + secid: 'SU26238RMFS5', + isin: 'RU000A101XU7', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2027-05-15', + couponValue: 36.9, + couponPercent: 7.5, + couponPeriod: 182, + nextCoupon: '2024-07-15', + accruedInt: 8.45, + bondType: 'ОФЗ', + bondSubType: 'ОФЗ-ПД', + offerDate: null, + buybackDate: null, + marketData: { + price: 98.5, + yieldToMaturity: 8.2, + duration: 3.5, + accruedInt: 8.45, + couponValue: 36.9, + couponPercent: 7.5, + nextCouponDate: '2024-07-15', + open: 98.0, + high: 99.0, + low: 97.5, + volume: 1000000, + updatedAt: '2024-01-15T10:00:00Z', + }, +} + +export const mockBondHistory = [ + { date: '2024-01-15', closePrice: 98.5, yieldClose: 8.2, duration: 3.5 }, + { date: '2024-01-14', closePrice: 98.2, yieldClose: 8.3, duration: 3.5 }, +] diff --git a/apps/frontend/mocks/data/broker.ts b/apps/frontend/mocks/data/broker.ts new file mode 100644 index 0000000..b23f8c3 --- /dev/null +++ b/apps/frontend/mocks/data/broker.ts @@ -0,0 +1,189 @@ +export const mockBrokerAccounts = [ + { + id: '2084014113', + type: 'brokerage', + name: 'Т-Инвестиции', + status: 'open', + openedAt: null, + accessLevel: null, + }, +] + +export const mockBrokerPortfolio = { + account: mockBrokerAccounts[0], + positionCounts: { shares: 3, bonds: 2, etf: 0, other: 0 }, + totals: { + shares: { currency: 'RUB', units: '240000', nano: 500000000, value: 240000.5 }, + bonds: { currency: 'RUB', units: '45000', nano: 0, value: 45000 }, + etf: null, + currencies: null, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '285000', nano: 500000000, value: 285000.5 }, + }, + yields: { + expectedPercent: null, + daily: { currency: 'RUB', units: '1200', nano: 0, value: 1200 }, + dailyPercent: null, + }, + cash: [{ currency: 'RUB', units: '15000', nano: 0, value: 15000 }], + blockedCash: [], + asOf: '2024-06-01T10:00:00Z', +} + +export const mockBrokerPositions = [ + { + figi: null, + instrumentUid: null, + positionUid: null, + ticker: 'SBER', + classCode: null, + instrumentType: 'share', + name: 'Сбер Банк', + quantity: { currency: '', units: '100', nano: 0, value: 100 }, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '289', nano: 500000000, value: 289.5 }, + currentValue: { currency: 'RUB', units: '28950', nano: 0, value: 28950 }, + averagePositionPrice: null, + expectedYieldPercent: null, + dailyYield: null, + }, + { + figi: null, + instrumentUid: null, + positionUid: null, + ticker: 'VTBR', + classCode: null, + instrumentType: 'share', + name: 'ВТБ', + quantity: { currency: '', units: '5000', nano: 0, value: 5000 }, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '0', nano: 23400000, value: 0.0234 }, + currentValue: { currency: 'RUB', units: '117', nano: 0, value: 117 }, + averagePositionPrice: null, + expectedYieldPercent: null, + dailyYield: null, + }, + { + figi: null, + instrumentUid: null, + positionUid: null, + ticker: 'SU26238RMFS5', + classCode: null, + instrumentType: 'bond', + name: 'ОФЗ 26238', + quantity: { currency: '', units: '10', nano: 0, value: 10 }, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '985', nano: 0, value: 985 }, + currentValue: { currency: 'RUB', units: '9850', nano: 0, value: 9850 }, + averagePositionPrice: null, + expectedYieldPercent: null, + dailyYield: null, + }, +] + +export const mockBrokerOperations = [ + { + cursor: null, + accountId: '2084014113', + id: 'op-1', + parentOperationId: null, + date: '2024-06-01T10:00:00Z', + type: 'buy', + category: 'trade', + description: null, + name: 'Покупка SBER', + state: 'executed', + instrumentUid: null, + figi: null, + ticker: 'SBER', + classCode: null, + instrumentType: 'share', + payment: { currency: 'RUB', units: '-27500', nano: 0, value: -27500 }, + price: { currency: 'RUB', units: '275', nano: 0, value: 275 }, + commission: { currency: 'RUB', units: '-55', nano: 0, value: -55 }, + yield: null, + accruedInt: null, + quantity: { currency: '', units: '100', nano: 0, value: 100 }, + quantityDone: { currency: '', units: '100', nano: 0, value: 100 }, + }, + { + cursor: null, + accountId: '2084014113', + id: 'op-2', + parentOperationId: null, + date: '2024-05-20T14:30:00Z', + type: 'dividend', + category: 'income', + description: null, + name: 'Дивиденды Сбер', + state: 'executed', + instrumentUid: null, + figi: null, + ticker: 'SBER', + classCode: null, + instrumentType: 'share', + payment: { currency: 'RUB', units: '3500', nano: 0, value: 3500 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + }, +] + +export const mockBrokerEvents = [ + { + id: 'ev-1', + type: 'dividend', + source: 'forecast', + category: 'cashflow', + eventDate: '2024-07-10', + paymentDate: null, + ticker: 'SBER', + name: 'Сбер Банк', + instrumentUid: 'uid-sber', + instrumentType: 'share', + quantitySnapshot: 100, + payoutPerUnit: 35, + estimatedAmount: 3500, + actualAmount: null, + currency: 'RUB', + estimateMode: 'current_position', + }, + { + id: 'ev-2', + type: 'coupon', + source: 'forecast', + category: 'cashflow', + eventDate: '2024-07-15', + paymentDate: null, + ticker: 'SU26238RMFS5', + name: 'ОФЗ 26238', + instrumentUid: 'uid-bond', + instrumentType: 'bond', + quantitySnapshot: 10, + payoutPerUnit: 36.9, + estimatedAmount: 369, + actualAmount: null, + currency: 'RUB', + estimateMode: 'current_position', + }, +] + +export const mockEventsSummary = { + eventCount: 2, + nearestEventDate: '2024-07-10', + totalEstimatedCashflow: 3869, + actualCashflow: 0, + forecastEstimatedCashflow: 3869, + dividendsTotal: 3500, + couponsTotal: 369, + principalRepaymentTotal: 0, + actualDividendsTotal: 0, + actualCouponsTotal: 0, + actualPrincipalRepaymentTotal: 0, +} diff --git a/apps/frontend/mocks/data/candles.ts b/apps/frontend/mocks/data/candles.ts new file mode 100644 index 0000000..124a48f --- /dev/null +++ b/apps/frontend/mocks/data/candles.ts @@ -0,0 +1,22 @@ +export const mockCandles = [ + { + open: 280, + high: 290, + low: 278, + close: 289.5, + volume: 1000000, + value: 280000000, + begin: '2024-01-15T10:00:00Z', + end: '2024-01-15T18:00:00Z', + }, + { + open: 289, + high: 292, + low: 285, + close: 288, + volume: 800000, + value: 231200000, + begin: '2024-01-16T10:00:00Z', + end: '2024-01-16T18:00:00Z', + }, +] diff --git a/apps/frontend/mocks/data/index.ts b/apps/frontend/mocks/data/index.ts new file mode 100644 index 0000000..3964188 --- /dev/null +++ b/apps/frontend/mocks/data/index.ts @@ -0,0 +1,15 @@ +export { mockAuthResponse, mockUser } from './auth' +export { mockBond, mockBondHistory } from './bonds' +export { + mockBrokerAccounts, + mockBrokerEvents, + mockBrokerOperations, + mockBrokerPortfolio, + mockBrokerPositions, + mockEventsSummary, +} from './broker' +export { mockCandles } from './candles' +export { mockAnalytics, mockPortfolioDetail, mockPortfolios, mockPositions } from './portfolios' +export { mockScreenerItems } from './screener' +export { mockSearchResults } from './search' +export { mockDividends, mockShare, mockShareHistory } from './shares' diff --git a/apps/frontend/mocks/data/portfolios.ts b/apps/frontend/mocks/data/portfolios.ts new file mode 100644 index 0000000..3f450d7 --- /dev/null +++ b/apps/frontend/mocks/data/portfolios.ts @@ -0,0 +1,138 @@ +export const mockPortfolios = [ + { + id: 1, + name: 'Основной портфель', + currency: 'RUB', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-06-01T00:00:00Z', + totalValue: 285000, + positionCount: 5, + shareCount: 3, + bondCount: 2, + description: null, + }, + { + id: 2, + name: 'ИИС', + currency: 'RUB', + createdAt: '2024-03-15T00:00:00Z', + updatedAt: '2024-06-10T00:00:00Z', + totalValue: 150000, + positionCount: 3, + shareCount: 2, + bondCount: 1, + description: 'Индивидуальный инвестиционный счёт', + }, +] + +export const mockPositions = [ + { + id: 1, + secid: 'SBER', + shortName: 'Сбер', + type: 'share', + quantity: 100, + buyPrice: 275, + currentPrice: 289.5, + totalCost: 27500, + currentValue: 28950, + weightPercent: 10.2, + pnl: 1450, + pnlPercent: 5.27, + dividendIncome: 3500, + totalReturn: 4950, + totalReturnPercent: 18, + change: 14.5, + changePercent: 5.27, + notes: null, + tags: ['DIVIDEND', 'GROWTH'], + }, + { + id: 2, + secid: 'VTBR', + shortName: 'ВТБ', + type: 'share', + quantity: 5000, + buyPrice: 0.021, + currentPrice: 0.0234, + totalCost: 105, + currentValue: 117, + weightPercent: 0.04, + pnl: 12, + pnlPercent: 11.43, + dividendIncome: 0, + totalReturn: 12, + totalReturnPercent: 11.43, + change: 0.0024, + changePercent: 11.43, + notes: null, + tags: ['SPECULATIVE'], + }, + { + id: 3, + secid: 'SU26238RMFS5', + shortName: 'ОФЗ 26238', + type: 'bond', + quantity: 10, + buyPrice: 97, + currentPrice: 98.5, + totalCost: 9700, + currentValue: 9850, + weightPercent: 3.5, + pnl: 150, + pnlPercent: 1.55, + dividendIncome: 0, + totalReturn: 150, + totalReturnPercent: 1.55, + change: 1.5, + changePercent: 1.55, + yieldToMaturity: 8.2, + duration: 3.5, + couponValue: 36.9, + couponPercent: 7.5, + nextCouponDate: '2024-07-15', + matDate: '2027-05-15', + accruedInt: 8.45, + bid: 98.3, + offer: 98.6, + couponPeriod: 182, + bondType: 'ОФЗ', + }, +] + +export const mockPortfolioDetail = { + id: 1, + name: 'Основной портфель', + currency: 'RUB', + description: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-06-01T00:00:00Z', + positions: mockPositions, + totalValue: 285000, + analytics: { + totalInvested: 250000, + totalValue: 285000, + totalPnl: 35000, + totalPnlPercent: 14, + totalDividends: 5000, + totalReturn: 40000, + totalReturnPercent: 16, + positionCount: 5, + weightedYield: null, + }, +} + +export const mockAnalytics = { + positions: mockPositions, + summary: { + totalInvested: 250000, + totalValue: 285000, + totalPnl: 35000, + totalPnlPercent: 14, + totalDividends: 5000, + totalReturn: 40000, + totalReturnPercent: 16, + positionCount: 5, + weightedYield: 7.5, + }, +} diff --git a/apps/frontend/mocks/data/screener.ts b/apps/frontend/mocks/data/screener.ts new file mode 100644 index 0000000..27dabe6 --- /dev/null +++ b/apps/frontend/mocks/data/screener.ts @@ -0,0 +1,38 @@ +export const mockScreenerItems = [ + { + secid: 'SBER', + shortName: 'Сбер', + isin: 'RU0009029540', + type: 'share', + price: 289.5, + change: 2.5, + changePercent: 0.87, + volume: 15000000, + listLevel: 1, + capitalization: 6250000000000, + }, + { + secid: 'VTBR', + shortName: 'ВТБ', + isin: 'RU000A0JP5V6', + type: 'share', + price: 0.0234, + change: 0.0002, + changePercent: 0.86, + volume: 50000000, + listLevel: 1, + capitalization: 30000000000, + }, + { + secid: 'GAZP', + shortName: 'Газпром', + isin: 'RU0007661625', + type: 'share', + price: 198.5, + change: -1.5, + changePercent: -0.75, + volume: 8000000, + listLevel: 1, + capitalization: 4700000000000, + }, +] diff --git a/apps/frontend/mocks/data/search.ts b/apps/frontend/mocks/data/search.ts new file mode 100644 index 0000000..0308f7b --- /dev/null +++ b/apps/frontend/mocks/data/search.ts @@ -0,0 +1,20 @@ +export const mockSearchResults = [ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбер', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: 289.5, + }, + { + secid: 'VTBR', + isin: 'RU000A0JP5V6', + shortName: 'ВТБ', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: 0.0234, + }, +] diff --git a/apps/frontend/mocks/data/shares.ts b/apps/frontend/mocks/data/shares.ts new file mode 100644 index 0000000..cd6fb87 --- /dev/null +++ b/apps/frontend/mocks/data/shares.ts @@ -0,0 +1,50 @@ +export const mockShare = { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбер Банк', + shortName: 'Сбер', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586900000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: { + price: 289.5, + change: 2.5, + changePercent: 0.87, + open: 287, + high: 291, + low: 286.5, + volume: 15000000, + value: 4350000000, + issueCapitalization: 6250000000000, + updatedAt: '2024-01-15T10:00:00Z', + }, +} + +export const mockDividends = [ + { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, + { registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' }, +] + +export const mockShareHistory = [ + { + date: '2024-01-15', + open: 287, + high: 291, + low: 286.5, + close: 289.5, + volume: 15000000, + value: 4350000000, + }, + { + date: '2024-01-14', + open: 285, + high: 288, + low: 284, + close: 287, + volume: 12000000, + value: 3440000000, + }, +] diff --git a/apps/frontend/mocks/handlers.ts b/apps/frontend/mocks/handlers.ts new file mode 100644 index 0000000..5e795bf --- /dev/null +++ b/apps/frontend/mocks/handlers.ts @@ -0,0 +1,151 @@ +import { HttpResponse, http } from 'msw' +import { + mockAnalytics, + mockAuthResponse, + mockBond, + mockBondHistory, + mockBrokerAccounts, + mockBrokerEvents, + mockBrokerOperations, + mockBrokerPortfolio, + mockBrokerPositions, + mockCandles, + mockDividends, + mockEventsSummary, + mockPortfolioDetail, + mockPortfolios, + mockPositions, + mockScreenerItems, + mockSearchResults, + mockShare, + mockShareHistory, + mockUser, +} from './data' +import { envelope } from './utils' + +const API = '/api/v1' + +export const handlers = [ + http.get(`${API}/health`, () => + HttpResponse.json( + envelope({ status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }), + ), + ), + + http.get(`${API}/auth/me`, () => HttpResponse.json(envelope(mockUser))), + http.patch(`${API}/auth/me`, () => HttpResponse.json(envelope({ ...mockUser, name: 'Updated' }))), + http.post(`${API}/auth/login`, () => HttpResponse.json(envelope(mockAuthResponse))), + http.post(`${API}/auth/register`, () => HttpResponse.json(envelope(mockAuthResponse))), + http.post(`${API}/auth/refresh`, () => HttpResponse.json(envelope(mockAuthResponse))), + http.post(`${API}/auth/logout`, () => HttpResponse.json(envelope({ message: 'Logged out' }))), + + http.get(`${API}/securities/search`, ({ request }) => { + const url = new URL(request.url) + const q = url.searchParams.get('q') || '' + if (q.length < 2) return HttpResponse.json(envelope([])) + const filtered = mockSearchResults.filter( + (r) => + r.secid.toLowerCase().includes(q.toLowerCase()) || + r.shortName.toLowerCase().includes(q.toLowerCase()), + ) + return HttpResponse.json(envelope(filtered)) + }), + + http.get(`${API}/securities/shares/:secid`, ({ params }) => { + const { secid } = params + if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }) + return HttpResponse.json(envelope({ ...mockShare, secid })) + }), + http.get(`${API}/securities/shares/:secid/marketdata`, () => + HttpResponse.json(envelope(mockShare.marketData)), + ), + http.get(`${API}/securities/shares/:secid/dividends`, () => + HttpResponse.json(envelope(mockDividends)), + ), + http.get(`${API}/securities/shares/:secid/history`, () => + HttpResponse.json(envelope(mockShareHistory)), + ), + http.get(`${API}/securities/shares/:secid/candles`, () => + HttpResponse.json(envelope(mockCandles)), + ), + + http.get(`${API}/securities/bonds/:secid`, ({ params }) => { + const { secid } = params + if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }) + return HttpResponse.json(envelope({ ...mockBond, secid })) + }), + http.get(`${API}/securities/bonds/:secid/marketdata`, () => + HttpResponse.json(envelope(mockBond.marketData)), + ), + http.get(`${API}/securities/bonds/:secid/history`, () => + HttpResponse.json(envelope(mockBondHistory)), + ), + http.get(`${API}/securities/bonds/:secid/candles`, () => + HttpResponse.json(envelope(mockCandles)), + ), + + http.get(`${API}/securities/screener`, () => + HttpResponse.json( + envelope({ + items: mockScreenerItems, + total: mockScreenerItems.length, + page: 1, + pageSize: 20, + totalPages: 1, + }), + ), + ), + + http.get(`${API}/portfolios`, () => HttpResponse.json(envelope(mockPortfolios))), + http.post(`${API}/portfolios`, () => HttpResponse.json(envelope(mockPortfolios[0]))), + http.get(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(mockPortfolioDetail))), + http.patch(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(mockPortfolios[0]))), + http.delete(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(null))), + http.post(`${API}/portfolios/:id/positions`, () => HttpResponse.json(envelope(mockPositions[0]))), + http.patch(`${API}/portfolios/:id/positions/:positionId`, () => + HttpResponse.json(envelope(mockPositions[0])), + ), + http.delete(`${API}/portfolios/:id/positions/:positionId`, () => + HttpResponse.json(envelope(null)), + ), + http.get(`${API}/portfolios/:id/analytics`, () => HttpResponse.json(envelope(mockAnalytics))), + + http.get(`${API}/broker/accounts`, () => HttpResponse.json(envelope(mockBrokerAccounts))), + http.get(`${API}/broker/accounts/:accountId/portfolio`, () => + HttpResponse.json(envelope(mockBrokerPortfolio)), + ), + http.get(`${API}/broker/accounts/:accountId/positions`, () => + HttpResponse.json( + envelope({ + accountId: '2084014113', + items: mockBrokerPositions, + nextCursor: null, + hasNext: false, + asOf: '2024-06-01T10:00:00Z', + }), + ), + ), + http.get(`${API}/broker/accounts/:accountId/operations`, () => + HttpResponse.json( + envelope({ + accountId: '2084014113', + items: mockBrokerOperations, + nextCursor: null, + hasNext: false, + asOf: '2024-06-01T10:00:00Z', + }), + ), + ), + http.get(`${API}/broker/accounts/:accountId/events`, () => + HttpResponse.json( + envelope({ + items: mockBrokerEvents, + summary: mockEventsSummary, + asOf: '2024-06-01T10:00:00Z', + }), + ), + ), + http.post(`${API}/broker/accounts/:accountId/operations/sync`, () => + HttpResponse.json(envelope({ upserted: 5 })), + ), +] diff --git a/apps/frontend/mocks/server.ts b/apps/frontend/mocks/server.ts new file mode 100644 index 0000000..86f7d61 --- /dev/null +++ b/apps/frontend/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node' +import { handlers } from './handlers' + +export const server = setupServer(...handlers) diff --git a/apps/frontend/mocks/utils.ts b/apps/frontend/mocks/utils.ts new file mode 100644 index 0000000..f9dff52 --- /dev/null +++ b/apps/frontend/mocks/utils.ts @@ -0,0 +1,5 @@ +export function envelope(data: unknown) { + return { + data: { data, meta: { fromCache: false, cachedAt: null } }, + } +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index cef656f..2f85446 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -5,10 +5,14 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "vite build", + "typecheck": "tsc -b", "preview": "vite preview", - "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts", - "lint": "eslint \"src/**/*.{ts,tsx}\"", + "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/shared/api/types.ts", + "lint": "biome check src/", + "lint:fix": "biome check --write src/", + "format": "biome format --write src/", + "format:check": "biome format src/", "test": "vitest run", "test:watch": "vitest" }, @@ -16,11 +20,12 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@fontsource/inter": "^5.2.8", - "@moex-vibe/design-system": "*", "@hookform/resolvers": "^3.10.0", + "@moex-vibe/design-system": "*", "@mui/icons-material": "^6.5.0", "@mui/material": "^6.5.0", "@tanstack/react-query": "^5.20.0", + "@tanstack/react-router": "^1.170.16", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", "dayjs": "^1.11.21", @@ -31,27 +36,25 @@ "react-dom": "^18.3.0", "react-hook-form": "^7.80.0", "react-is": "^18.3.1", - "react-router-dom": "^6.20.0", "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { + "@biomejs/biome": "^2.5.0", "@conarti/eslint-plugin-feature-sliced": "^1.0.5", + "@tanstack/router-devtools": "^1.167.0", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.9.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "@vitejs/plugin-react": "^4.2.0", "eslint": "^8.0.0", - "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-react": "^7.34.0", - "eslint-plugin-react-hooks": "^4.6.0", "jsdom": "^29.1.1", "msw": "^2.14.6", "openapi-typescript": "^7.0.0", diff --git a/apps/frontend/public/mockServiceWorker.js b/apps/frontend/public/mockServiceWorker.js new file mode 100644 index 0000000..33dde9e --- /dev/null +++ b/apps/frontend/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx index 4c86746..f7b5103 100644 --- a/apps/frontend/src/app/App.tsx +++ b/apps/frontend/src/app/App.tsx @@ -1,10 +1,6 @@ -import { BrowserRouter } from 'react-router-dom'; -import { AppRoutes } from './routing/AppRoutes'; +import { RouterProvider } from '@tanstack/react-router' +import { router } from './routing/router' export default function App() { - return ( - - - - ); + return } diff --git a/apps/frontend/src/app/index.ts b/apps/frontend/src/app/index.ts index c866729..f52c042 100644 --- a/apps/frontend/src/app/index.ts +++ b/apps/frontend/src/app/index.ts @@ -1 +1 @@ -export { default as App } from './App'; +export { default as App } from './App' diff --git a/apps/frontend/src/app/layouts/AppLayout.tsx b/apps/frontend/src/app/layouts/AppLayout.tsx index fcdd1ff..aefdb96 100644 --- a/apps/frontend/src/app/layouts/AppLayout.tsx +++ b/apps/frontend/src/app/layouts/AppLayout.tsx @@ -1,14 +1,14 @@ -import { Outlet, Link, useNavigate } from 'react-router-dom'; -import { SearchBar } from '@/widgets/search-bar'; -import { useSession } from '@/entities/session'; +import { Link, Outlet, useNavigate } from '@tanstack/react-router' +import { useSession } from '@/entities/session' +import { SearchBar } from '@/widgets/search-bar' export function AppLayout() { - const { isAuthenticated, user, logout } = useSession(); - const navigate = useNavigate(); + const { isAuthenticated, user, logout } = useSession() + const navigate = useNavigate() async function handleLogout() { - await logout(); - navigate('/'); + await logout() + navigate('/') } return ( @@ -138,5 +138,5 @@ export function AppLayout() { - ); + ) } diff --git a/apps/frontend/src/app/layouts/index.ts b/apps/frontend/src/app/layouts/index.ts index 763c036..8bde017 100644 --- a/apps/frontend/src/app/layouts/index.ts +++ b/apps/frontend/src/app/layouts/index.ts @@ -1 +1 @@ -export { AppLayout } from './AppLayout'; +export { AppLayout } from './AppLayout' diff --git a/apps/frontend/src/app/providers/AppProviders.tsx b/apps/frontend/src/app/providers/AppProviders.tsx index 2c24c08..e303a29 100644 --- a/apps/frontend/src/app/providers/AppProviders.tsx +++ b/apps/frontend/src/app/providers/AppProviders.tsx @@ -1,11 +1,11 @@ -import { type ReactNode } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import '@fontsource/inter/400.css'; -import '@fontsource/inter/500.css'; -import '@fontsource/inter/600.css'; -import '@fontsource/inter/700.css'; -import { MoexVibeThemeProvider } from '@moex-vibe/design-system/theme'; -import { SessionProvider } from './SessionProvider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import '@fontsource/inter/400.css' +import '@fontsource/inter/500.css' +import '@fontsource/inter/600.css' +import '@fontsource/inter/700.css' +import { MoexVibeThemeProvider } from '@moex-vibe/design-system/theme' +import { SessionProvider } from './SessionProvider' const queryClient = new QueryClient({ defaultOptions: { @@ -15,7 +15,7 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, }, }, -}); +}) export function AppProviders({ children }: { children: ReactNode }) { return ( @@ -24,5 +24,5 @@ export function AppProviders({ children }: { children: ReactNode }) { {children} - ); + ) } diff --git a/apps/frontend/src/app/providers/SessionProvider.test.tsx b/apps/frontend/src/app/providers/SessionProvider.test.tsx index a1294f2..c29813e 100644 --- a/apps/frontend/src/app/providers/SessionProvider.test.tsx +++ b/apps/frontend/src/app/providers/SessionProvider.test.tsx @@ -1,27 +1,27 @@ -import { describe, it, expect } from 'vitest'; -import { useContext } from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { SessionContext } from '@/entities/session'; -import { SessionProvider } from './SessionProvider'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { server } from '@mocks/server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, http } from 'msw' +import { useContext } from 'react' +import { describe, expect, it } from 'vitest' +import { SessionContext } from '@/entities/session' +import { SessionProvider } from './SessionProvider' -const API = '/api/v1'; +const API = '/api/v1' function renderWithProviders(ui: React.ReactElement) { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render( {ui} , - ); + ) } function TestConsumer() { - const ctx = useContext(SessionContext); - if (!ctx) return
no context
; + const ctx = useContext(SessionContext) + if (!ctx) return
no context
return (
{ctx.isAuthenticated ? 'authenticated' : 'anonymous'} @@ -31,44 +31,44 @@ function TestConsumer() {
- ); + ) } describe('SessionProvider', () => { it('starts unauthenticated when refresh fails', async () => { - server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); - renderWithProviders(); + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) + renderWithProviders() await waitFor(() => { - expect(screen.getByTestId('session')).toHaveTextContent('anonymous'); - }); - }); + expect(screen.getByTestId('session')).toHaveTextContent('anonymous') + }) + }) it('restores session on mount when refresh succeeds', async () => { - renderWithProviders(); + renderWithProviders() await waitFor(() => { - expect(screen.getByTestId('session')).toHaveTextContent('authenticated'); - expect(screen.getByTestId('email')).toHaveTextContent('user@test.com'); - }); - }); + expect(screen.getByTestId('session')).toHaveTextContent('authenticated') + expect(screen.getByTestId('email')).toHaveTextContent('user@test.com') + }) + }) it('updates state after login', async () => { - server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); - const user = userEvent.setup(); - renderWithProviders(); - await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('anonymous')); - await user.click(screen.getByRole('button', { name: 'login' })); + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) + const user = userEvent.setup() + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('anonymous')) + await user.click(screen.getByRole('button', { name: 'login' })) await waitFor(() => { - expect(screen.getByTestId('session')).toHaveTextContent('authenticated'); - }); - }); + expect(screen.getByTestId('session')).toHaveTextContent('authenticated') + }) + }) it('updates state after logout', async () => { - const user = userEvent.setup(); - renderWithProviders(); - await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated')); - await user.click(screen.getByRole('button', { name: 'logout' })); + const user = userEvent.setup() + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated')) + await user.click(screen.getByRole('button', { name: 'logout' })) await waitFor(() => { - expect(screen.getByTestId('session')).toHaveTextContent('anonymous'); - }); - }); -}); + expect(screen.getByTestId('session')).toHaveTextContent('anonymous') + }) + }) +}) diff --git a/apps/frontend/src/app/providers/SessionProvider.tsx b/apps/frontend/src/app/providers/SessionProvider.tsx index 9c8c4ca..725c894 100644 --- a/apps/frontend/src/app/providers/SessionProvider.tsx +++ b/apps/frontend/src/app/providers/SessionProvider.tsx @@ -1,97 +1,99 @@ -import { useState, useEffect, useCallback, type ReactNode } from 'react'; -import * as sessionApi from '@/entities/session'; -import { SessionContext, type SessionContextValue } from '@/entities/session'; -import { configureAuth } from '@/shared/api/client'; +import { type ReactNode, useCallback, useEffect, useState } from 'react' +import * as sessionApi from '@/entities/session' +import { SessionContext, type SessionContextValue, useSessionStore } from '@/entities/session' import { - setOnUnauthorized, getAccessToken, handleUnauthorized, -} from '@/entities/session/api/tokenManager'; -import type { UserResponse } from '@/shared/api/responses'; + setOnUnauthorized, +} from '@/entities/session/api/tokenManager' +import type { UserResponse } from '@/shared/api' +import { configureKyAuth } from '@/shared/api/kyClient' export function SessionProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [accessToken, setAccessTokenState] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [initialized, setInitialized] = useState(false); + const [user, setUser] = useState(null) + const [accessToken, setAccessTokenState] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [initialized, setInitialized] = useState(false) const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => { - setUser(authData.user); - setAccessTokenState(authData.accessToken); - }, []); + setUser(authData.user) + setAccessTokenState(authData.accessToken) + useSessionStore.getState().setSession(authData) + }, []) const clearSession = useCallback(() => { - setUser(null); - setAccessTokenState(null); - }, []); + setUser(null) + setAccessTokenState(null) + useSessionStore.getState().clearSession() + }, []) const login = useCallback( async (email: string, password: string) => { - const result = await sessionApi.login(email, password); - updateSession(result); + const result = await sessionApi.login(email, password) + updateSession(result) }, [updateSession], - ); + ) const register = useCallback( async (email: string, password: string, name?: string) => { - const result = await sessionApi.register(email, password, name); - updateSession(result); + const result = await sessionApi.register(email, password, name) + updateSession(result) }, [updateSession], - ); + ) const logout = useCallback(async () => { try { - await sessionApi.logout(); + await sessionApi.logout() } catch { // ignore network errors on logout } - clearSession(); - }, [clearSession]); + clearSession() + }, [clearSession]) const updateProfileFn = useCallback(async (data: { name?: string }) => { - const result = await sessionApi.updateProfile(data); - setUser(result); - }, []); + const result = await sessionApi.updateProfile(data) + setUser(result) + }, []) // Try to restore session on mount useEffect(() => { - let mounted = true; + let mounted = true async function init() { try { - const result = await sessionApi.refresh(); + const result = await sessionApi.refresh() if (mounted) { - updateSession(result); + updateSession(result) } } catch { // No valid session } finally { if (mounted) { - setIsLoading(false); - setInitialized(true); + setIsLoading(false) + setInitialized(true) } } } - init(); + init() return () => { - mounted = false; - }; - }, [updateSession]); + mounted = false + } + }, [updateSession]) // Wire up auth config and auto-logout on unauthorized useEffect(() => { - configureAuth({ + configureKyAuth({ getAccessToken, handleUnauthorized, - }); + }) setOnUnauthorized(() => { - clearSession(); - }); - }, [clearSession]); + clearSession() + }) + }, [clearSession]) if (!initialized && isLoading) { return ( @@ -106,7 +108,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { > Загрузка... - ); + ) } const value: SessionContextValue = { @@ -118,7 +120,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { register, logout, updateProfile: updateProfileFn, - }; + } - return {children}; + return {children} } diff --git a/apps/frontend/src/app/providers/index.ts b/apps/frontend/src/app/providers/index.ts index daab25a..9d819a2 100644 --- a/apps/frontend/src/app/providers/index.ts +++ b/apps/frontend/src/app/providers/index.ts @@ -1,2 +1,2 @@ -export { SessionProvider } from './SessionProvider'; -export { AppProviders } from './AppProviders'; +export { AppProviders } from './AppProviders' +export { SessionProvider } from './SessionProvider' diff --git a/apps/frontend/src/app/routing/AppRoutes.tsx b/apps/frontend/src/app/routing/AppRoutes.tsx deleted file mode 100644 index ffe84c2..0000000 --- a/apps/frontend/src/app/routing/AppRoutes.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Routes, Route } from 'react-router-dom'; -import { AppLayout } from '../layouts/AppLayout'; -import { ProtectedRoute } from './ProtectedRoute'; -import { HomePage } from '@/pages/home'; -import { StockPage } from '@/pages/stock'; -import { BondPage } from '@/pages/bond'; -import { LoginPage } from '@/pages/login'; -import { RegisterPage } from '@/pages/register'; -import { ProfilePage } from '@/pages/profile'; -import { PortfoliosListPage, PortfolioDetailPage } from '@/pages/portfolios'; -import { ScreenerPage } from '@/pages/screener'; -import { BrokerAccountsPage } from '@/pages/broker-accounts'; -import { BrokerAccountLayout } from '@/widgets/broker-account-layout'; -import { BrokerAccountOverviewPage } from '@/pages/broker-account'; -import { BrokerEventsPage } from '@/pages/broker-events'; -import { BrokerPositionsPage } from '@/pages/broker-positions'; -import { BrokerOperationsPage } from '@/pages/broker-operations'; - -export function AppRoutes() { - return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - - - - ); -} diff --git a/apps/frontend/src/app/routing/ProtectedRoute.tsx b/apps/frontend/src/app/routing/ProtectedRoute.tsx deleted file mode 100644 index 5232231..0000000 --- a/apps/frontend/src/app/routing/ProtectedRoute.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Navigate, useLocation } from 'react-router-dom'; -import { useSession } from '@/entities/session'; -import type { ReactNode } from 'react'; - -export function ProtectedRoute({ children }: { children: ReactNode }) { - const { isAuthenticated, isLoading } = useSession(); - const location = useLocation(); - - if (isLoading) { - return ( -
- Загрузка... -
- ); - } - - if (!isAuthenticated) { - return ; - } - - return <>{children}; -} diff --git a/apps/frontend/src/app/routing/index.ts b/apps/frontend/src/app/routing/index.ts index 6220355..285e37b 100644 --- a/apps/frontend/src/app/routing/index.ts +++ b/apps/frontend/src/app/routing/index.ts @@ -1,2 +1 @@ -export { AppRoutes } from './AppRoutes'; -export { ProtectedRoute } from './ProtectedRoute'; +export { router } from './routeTree' diff --git a/apps/frontend/src/app/routing/routeTree.tsx b/apps/frontend/src/app/routing/routeTree.tsx new file mode 100644 index 0000000..80f0e9d --- /dev/null +++ b/apps/frontend/src/app/routing/routeTree.tsx @@ -0,0 +1,169 @@ +import { + createRootRoute, + createRoute, + createRouter, + Outlet, + redirect, +} from '@tanstack/react-router' +import { useSessionStore } from '@/entities/session' +import { BondPage } from '@/pages/bond' +import { BrokerAccountOverviewPage } from '@/pages/broker-account' +import { BrokerAccountsPage } from '@/pages/broker-accounts' +import { BrokerEventsPage } from '@/pages/broker-events' +import { BrokerOperationsPage } from '@/pages/broker-operations' +import { BrokerPositionsPage } from '@/pages/broker-positions' +import { HomePage } from '@/pages/home' +import { LoginPage } from '@/pages/login' +import { PortfolioDetailPage, PortfoliosListPage } from '@/pages/portfolios' +import { ProfilePage } from '@/pages/profile' +import { RegisterPage } from '@/pages/register' +import { ScreenerPage } from '@/pages/screener' +import { StockPage } from '@/pages/stock' +import { BrokerAccountLayout } from '@/widgets/broker-account-layout' +import { AppLayout } from '../layouts/AppLayout' + +function requireAuth() { + if (!useSessionStore.getState().isAuthenticated) { + throw redirect({ to: '/login' }) + } +} + +const rootRoute = createRootRoute({ + component: () => , +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: HomePage, +}) + +const stockRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/stocks/$secid', + component: StockPage, +}) + +const bondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/bonds/$secid', + component: BondPage, +}) + +const screenerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/screener', + component: ScreenerPage, +}) + +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: LoginPage, +}) + +const registerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/register', + component: RegisterPage, +}) + +const profileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/profile', + beforeLoad: requireAuth, + component: ProfilePage, +}) + +const portfoliosRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/portfolios', + beforeLoad: requireAuth, + component: PortfoliosListPage, +}) + +const portfolioDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/portfolios/$id', + beforeLoad: requireAuth, + component: PortfolioDetailPage, +}) + +const brokerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/broker', + beforeLoad: requireAuth, + component: BrokerAccountsPage, +}) + +const brokerAccountRoot = createRoute({ + getParentRoute: () => rootRoute, + path: '/broker/$accountId', + beforeLoad: requireAuth, + component: () => ( + + + + ), +}) + +const brokerAccountIndexRoute = createRoute({ + getParentRoute: () => brokerAccountRoot, + path: '/', + component: BrokerAccountOverviewPage, +}) + +const brokerSharesRoute = createRoute({ + getParentRoute: () => brokerAccountRoot, + path: '/shares', + component: () => , +}) + +const brokerBondsRoute = createRoute({ + getParentRoute: () => brokerAccountRoot, + path: '/bonds', + component: () => , +}) + +const brokerOperationsRoute = createRoute({ + getParentRoute: () => brokerAccountRoot, + path: '/operations', + component: BrokerOperationsPage, +}) + +const brokerEventsRoute = createRoute({ + getParentRoute: () => brokerAccountRoot, + path: '/events', + component: BrokerEventsPage, +}) + +const routeTree = rootRoute.addChildren([ + indexRoute, + stockRoute, + bondRoute, + screenerRoute, + loginRoute, + registerRoute, + profileRoute, + portfoliosRoute, + portfolioDetailRoute, + brokerRoute, + brokerAccountRoot.addChildren([ + brokerAccountIndexRoute, + brokerSharesRoute, + brokerBondsRoute, + brokerOperationsRoute, + brokerEventsRoute, + ]), +]) + +export const router = createRouter({ + routeTree, + defaultPreload: 'intent', +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} diff --git a/apps/frontend/src/app/routing/router.ts b/apps/frontend/src/app/routing/router.ts new file mode 100644 index 0000000..bcae20e --- /dev/null +++ b/apps/frontend/src/app/routing/router.ts @@ -0,0 +1 @@ +export { router } from './routeTree.tsx' diff --git a/apps/frontend/src/entities/bond/api/bondApi.ts b/apps/frontend/src/entities/bond/api/bondApi.ts index 97aefdc..4d505ce 100644 --- a/apps/frontend/src/entities/bond/api/bondApi.ts +++ b/apps/frontend/src/entities/bond/api/bondApi.ts @@ -1,22 +1,20 @@ -import { request } from '@/shared/api/client'; import type { ApiResponseMeta, - BondResponse, - BondMarketData, BondHistoryItem, + BondMarketData, + BondResponse, CandleItem, -} from '@/shared/api/responses'; +} from '@/shared/api' +import { request } from '@/shared/api/kyClient' export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> { - return request(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`); + return request(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`) } export function getBondMarketData( secid: string, ): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> { - return request( - `/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`, - ); + return request(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`) } export function getBondHistory( @@ -27,7 +25,7 @@ export function getBondHistory( return request( `/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`, { from, till }, - ); + ) } export function getBondCandles( @@ -40,5 +38,5 @@ export function getBondCandles( interval, from, till, - }); + }) } diff --git a/apps/frontend/src/entities/bond/index.ts b/apps/frontend/src/entities/bond/index.ts index 92b9f7b..4ff6049 100644 --- a/apps/frontend/src/entities/bond/index.ts +++ b/apps/frontend/src/entities/bond/index.ts @@ -1,3 +1,3 @@ -export { useBond } from './model/useBond'; -export { useBondCandles } from './model/useBondCandles'; -export { getBond, getBondMarketData, getBondHistory, getBondCandles } from './api/bondApi'; +export { getBond, getBondCandles, getBondHistory, getBondMarketData } from './api/bondApi' +export { useBond } from './model/useBond' +export { useBondCandles } from './model/useBondCandles' diff --git a/apps/frontend/src/entities/bond/model/useBond.test.tsx b/apps/frontend/src/entities/bond/model/useBond.test.tsx index 4777e98..6698c4f 100644 --- a/apps/frontend/src/entities/bond/model/useBond.test.tsx +++ b/apps/frontend/src/entities/bond/model/useBond.test.tsx @@ -1,26 +1,26 @@ -import { describe, it, expect } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useBond } from './useBond'; -import { type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import { useBond } from './useBond' function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } describe('useBond', () => { it('returns bond data', async () => { - const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.shortName).toBe('ОФЗ 26238'); - expect(result.current.data?.marketData.price).toBe(98.5); - }); + const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.shortName).toBe('ОФЗ 26238') + expect(result.current.data?.marketData.price).toBe(98.5) + }) it('returns error on 404', async () => { - const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() }); - await waitFor(() => expect(result.current.isError).toBe(true)); - }); -}); + const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) diff --git a/apps/frontend/src/entities/bond/model/useBond.ts b/apps/frontend/src/entities/bond/model/useBond.ts index 302a07f..60ead24 100644 --- a/apps/frontend/src/entities/bond/model/useBond.ts +++ b/apps/frontend/src/entities/bond/model/useBond.ts @@ -1,14 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; -import { getBond } from '../api/bondApi'; -import type { BondResponse } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { BondResponse } from '@/shared/api' +import { getBond } from '../api/bondApi' export function useBond(secid: string) { return useQuery({ queryKey: ['bond', secid], queryFn: async () => { - const res = await getBond(secid); - return res.data; + const res = await getBond(secid) + return res.data }, staleTime: 900_000, - }); + }) } diff --git a/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx b/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx index ecade27..b0aa23f 100644 --- a/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx +++ b/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx @@ -1,18 +1,18 @@ -import { describe, it, expect } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { useBondCandles } from './useBondCandles'; -import { type ReactNode } from 'react'; +import { server } from '@mocks/server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { HttpResponse, http } from 'msw' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import { useBondCandles } from './useBondCandles' -const API = '/api/v1'; +const API = '/api/v1' function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } describe('useBondCandles', () => { @@ -20,24 +20,24 @@ describe('useBondCandles', () => { const { result } = renderHook( () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), { wrapper: createWrapper() }, - ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toHaveLength(2); - }); + ) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toHaveLength(2) + }) it('returns empty array when no candles', async () => { server.use( http.get(`${API}/securities/bonds/:secid/candles`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } }, - }); + }) }), - ); + ) const { result } = renderHook( () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), { wrapper: createWrapper() }, - ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([]); - }); -}); + ) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual([]) + }) +}) diff --git a/apps/frontend/src/entities/bond/model/useBondCandles.ts b/apps/frontend/src/entities/bond/model/useBondCandles.ts index fb25730..931b636 100644 --- a/apps/frontend/src/entities/bond/model/useBondCandles.ts +++ b/apps/frontend/src/entities/bond/model/useBondCandles.ts @@ -1,14 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; -import { getBondCandles } from '../api/bondApi'; -import type { CandleItem } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { CandleItem } from '@/shared/api' +import { getBondCandles } from '../api/bondApi' export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { return useQuery({ queryKey: ['bondCandles', secid, interval, from, till], queryFn: async () => { - const res = await getBondCandles(secid, interval, from, till); - return res.data; + const res = await getBondCandles(secid, interval, from, till) + return res.data }, staleTime: 3600_000, - }); + }) } diff --git a/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts b/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts index 05bf96f..67b9f4b 100644 --- a/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts +++ b/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts @@ -1,28 +1,28 @@ -import { request } from '@/shared/api/client'; -import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'; +import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api' +import { request } from '@/shared/api/kyClient' export type BrokerOperationQuery = { - from?: string; - to?: string; - cursor?: string; - limit?: number; - instrumentId?: string; - operationTypes?: string; - state?: string; -}; + from?: string + to?: string + cursor?: string + limit?: number + instrumentId?: string + operationTypes?: string + state?: string +} export function getBrokerAccounts(): Promise<{ - data: BrokerAccount[]; - meta: ApiResponseMeta; + data: BrokerAccount[] + meta: ApiResponseMeta }> { - return request('/api/v1/broker/accounts'); + return request('/api/v1/broker/accounts') } export function getBrokerPortfolio(accountId: string): Promise<{ - data: BrokerPortfolio; - meta: ApiResponseMeta; + data: BrokerPortfolio + meta: ApiResponseMeta }> { return request( `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`, - ); + ) } diff --git a/apps/frontend/src/entities/broker-account/index.ts b/apps/frontend/src/entities/broker-account/index.ts index e6e7c28..7b639f1 100644 --- a/apps/frontend/src/entities/broker-account/index.ts +++ b/apps/frontend/src/entities/broker-account/index.ts @@ -1,12 +1,12 @@ -export { useBrokerAccounts } from './model/useBrokerAccounts'; -export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'; -export { useBrokerPortfolio } from './model/useBrokerPortfolio'; +export { + type BrokerOperationQuery, + getBrokerAccounts, + getBrokerPortfolio, +} from './api/brokerAccountApi' export { aggregateBrokerAccounts, type BrokerAccountsAggregate, -} from './model/brokerAccountsOverview'; -export { - getBrokerAccounts, - getBrokerPortfolio, - type BrokerOperationQuery, -} from './api/brokerAccountApi'; +} from './model/brokerAccountsOverview' +export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios' +export { useBrokerAccounts } from './model/useBrokerAccounts' +export { useBrokerPortfolio } from './model/useBrokerPortfolio' diff --git a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.test.ts b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.test.ts index a2e5c02..44c658e 100644 --- a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.test.ts +++ b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import type { BrokerPortfolio } from '@/shared/api/responses'; -import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview'; +import { describe, expect, it } from 'vitest' +import type { BrokerPortfolio } from '@/shared/api' +import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview' function portfolio( id: string, @@ -38,7 +38,7 @@ function portfolio( cash: [{ currency, units: '0', nano: 0, value: cash }], blockedCash: [], asOf: '2026-06-19T10:00:00.000Z', - }; + } } describe('aggregateBrokerAccounts', () => { @@ -46,7 +46,7 @@ describe('aggregateBrokerAccounts', () => { const result = aggregateBrokerAccounts([ portfolio('a', 'RUB', 1_100, 100, 200), portfolio('b', 'RUB', 2_200, 200, 300), - ]); + ]) expect(result.portfolios).toEqual([ expect.objectContaining({ @@ -56,44 +56,44 @@ describe('aggregateBrokerAccounts', () => { dailyPercent: 10, allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 }, }), - ]); - expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]); - }); + ]) + expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]) + }) it('keeps different currencies separate', () => { const result = aggregateBrokerAccounts([ portfolio('rub', 'RUB', 1_100, 100, 200), portfolio('usd', 'USD', 550, 50, 25), - ]); + ]) expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([ { currency: 'RUB', total: 1_100 }, { currency: 'USD', total: 550 }, - ]); - }); + ]) + }) it('does not expose a daily percent when one account lacks daily data', () => { const result = aggregateBrokerAccounts([ portfolio('a', 'RUB', 1_100, 100, 200), portfolio('b', 'RUB', 2_000, null, 300), - ]); + ]) - expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null }); - }); + expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null }) + }) it('does not expose a daily percent when start of day is non-positive', () => { - const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]); + const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]) - expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null }); - }); + expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null }) + }) it('clamps negative residual other allocation to zero', () => { - const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100); - overAllocated.totals.shares!.value = 700; - overAllocated.totals.bonds!.value = 400; - overAllocated.totals.currencies!.value = 100; + const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100) + overAllocated.totals.shares!.value = 700 + overAllocated.totals.bonds!.value = 400 + overAllocated.totals.currencies!.value = 100 - const result = aggregateBrokerAccounts([overAllocated]); + const result = aggregateBrokerAccounts([overAllocated]) expect(result.portfolios[0].allocation).toEqual({ shares: 700, @@ -101,27 +101,27 @@ describe('aggregateBrokerAccounts', () => { etf: 0, cash: 100, other: 0, - }); - }); + }) + }) it('returns empty summaries for empty or unsupported portfolios', () => { - const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100); - missingTotal.totals.portfolio = null; + const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100) + missingTotal.totals.portfolio = null - expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] }); + expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] }) expect(aggregateBrokerAccounts([missingTotal])).toEqual({ portfolios: [], cash: [{ currency: 'RUB', value: 100 }], - }); - }); + }) + }) it('groups cash separately by currency', () => { - const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100); - mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 }); + const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100) + mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 }) expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([ { currency: 'RUB', value: 100 }, { currency: 'USD', value: 25 }, - ]); - }); -}); + ]) + }) +}) diff --git a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts index b5808a7..209246b 100644 --- a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts +++ b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts @@ -1,74 +1,74 @@ -import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' export interface BrokerCurrencyAllocationSummary { - shares: number; - bonds: number; - etf: number; - cash: number; - other: number; + shares: number + bonds: number + etf: number + cash: number + other: number } export interface BrokerCurrencyPortfolioSummary { - currency: string; - total: number; - daily: number | null; - dailyPercent: number | null; - allocation: BrokerCurrencyAllocationSummary; + currency: string + total: number + daily: number | null + dailyPercent: number | null + allocation: BrokerCurrencyAllocationSummary } export interface BrokerCurrencyCashSummary { - currency: string; - value: number; + currency: string + value: number } export interface BrokerAccountsAggregate { - portfolios: BrokerCurrencyPortfolioSummary[]; - cash: BrokerCurrencyCashSummary[]; + portfolios: BrokerCurrencyPortfolioSummary[] + cash: BrokerCurrencyCashSummary[] } interface MutableCurrencySummary { - currency: string; - total: number; - daily: number | null; - dailyComparable: boolean; - allocation: BrokerCurrencyAllocationSummary; + currency: string + total: number + daily: number | null + dailyComparable: boolean + allocation: BrokerCurrencyAllocationSummary } function moneyValue(money: BrokerMoney | null | undefined): number { - return money?.value ?? 0; + return money?.value ?? 0 } export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { - return type === 'iis' ? 'ИИС' : 'Брокерский счёт'; + return type === 'iis' ? 'ИИС' : 'Брокерский счёт' } export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate { - const portfolioSummaries = new Map(); - const cashSummaries = new Map(); + const portfolioSummaries = new Map() + const cashSummaries = new Map() for (const portfolio of portfolios) { for (const cash of portfolio.cash) { if (!cash.currency) { - continue; + continue } - const existingCash = cashSummaries.get(cash.currency); + const existingCash = cashSummaries.get(cash.currency) if (existingCash) { - existingCash.value += cash.value; + existingCash.value += cash.value } else { - cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value }); + cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value }) } } - const totalMoney = portfolio.totals.portfolio; - const currency = totalMoney?.currency; + const totalMoney = portfolio.totals.portfolio + const currency = totalMoney?.currency if (!totalMoney || !currency) { - continue; + continue } - const existingSummary = portfolioSummaries.get(currency); + const existingSummary = portfolioSummaries.get(currency) const summary = existingSummary ?? ({ @@ -77,45 +77,43 @@ export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAc daily: 0, dailyComparable: true, allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 }, - } satisfies MutableCurrencySummary); + } satisfies MutableCurrencySummary) - const total = totalMoney.value; - const shares = moneyValue(portfolio.totals.shares); - const bonds = moneyValue(portfolio.totals.bonds); - const etf = moneyValue(portfolio.totals.etf); - const cash = moneyValue(portfolio.totals.currencies); - const other = Math.max(0, total - shares - bonds - etf - cash); + const total = totalMoney.value + const shares = moneyValue(portfolio.totals.shares) + const bonds = moneyValue(portfolio.totals.bonds) + const etf = moneyValue(portfolio.totals.etf) + const cash = moneyValue(portfolio.totals.currencies) + const other = Math.max(0, total - shares - bonds - etf - cash) - summary.total += total; - summary.allocation.shares += shares; - summary.allocation.bonds += bonds; - summary.allocation.etf += etf; - summary.allocation.cash += cash; - summary.allocation.other += other; + summary.total += total + summary.allocation.shares += shares + summary.allocation.bonds += bonds + summary.allocation.etf += etf + summary.allocation.cash += cash + summary.allocation.other += other - const dailyMoney = portfolio.yields.daily; - const comparableDaily = dailyMoney && dailyMoney.currency === currency; + const dailyMoney = portfolio.yields.daily + const comparableDaily = dailyMoney && dailyMoney.currency === currency if (!comparableDaily) { - summary.daily = null; - summary.dailyComparable = false; + summary.daily = null + summary.dailyComparable = false } else if (summary.dailyComparable) { - summary.daily = (summary.daily ?? 0) + dailyMoney.value; + summary.daily = (summary.daily ?? 0) + dailyMoney.value } if (!existingSummary) { - portfolioSummaries.set(currency, summary); + portfolioSummaries.set(currency, summary) } } return { portfolios: Array.from(portfolioSummaries.values()).map((summary) => { - const daily = summary.dailyComparable ? summary.daily : null; - const startOfDay = daily === null ? null : summary.total - daily; + const daily = summary.dailyComparable ? summary.daily : null + const startOfDay = daily === null ? null : summary.total - daily const dailyPercent = - daily === null || startOfDay === null || startOfDay <= 0 - ? null - : (daily / startOfDay) * 100; + daily === null || startOfDay === null || startOfDay <= 0 ? null : (daily / startOfDay) * 100 return { currency: summary.currency, @@ -123,8 +121,8 @@ export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAc daily, dailyPercent, allocation: summary.allocation, - }; + } }), cash: Array.from(cashSummaries.values()), - }; + } } diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts b/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts index 20eee06..bfca33a 100644 --- a/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts +++ b/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts @@ -1,6 +1,6 @@ -import { useQueries } from '@tanstack/react-query'; -import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'; -import { getBrokerPortfolio } from '../api/brokerAccountApi'; +import { useQueries } from '@tanstack/react-query' +import type { BrokerAccount, BrokerPortfolio } from '@/shared/api' +import { getBrokerPortfolio } from '../api/brokerAccountApi' export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { const queries = useQueries({ @@ -11,7 +11,7 @@ export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { retry: 2, refetchOnWindowFocus: false, })), - }); + }) - return accounts.map((account, index) => ({ account, query: queries[index] })); + return accounts.map((account, index) => ({ account, query: queries[index] })) } diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts b/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts index 00fc4be..ff4a3b6 100644 --- a/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts +++ b/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts @@ -1,6 +1,6 @@ -import { useQuery } from '@tanstack/react-query'; -import type { BrokerAccount } from '@/shared/api/responses'; -import { getBrokerAccounts } from '../api/brokerAccountApi'; +import { useQuery } from '@tanstack/react-query' +import type { BrokerAccount } from '@/shared/api' +import { getBrokerAccounts } from '../api/brokerAccountApi' export function useBrokerAccounts() { return useQuery({ @@ -9,5 +9,5 @@ export function useBrokerAccounts() { staleTime: 3_600_000, retry: 2, refetchOnWindowFocus: false, - }); + }) } diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts index d956e2a..f7a9a5b 100644 --- a/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts +++ b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts @@ -1,6 +1,6 @@ -import { useQuery } from '@tanstack/react-query'; -import type { BrokerPortfolio } from '@/shared/api/responses'; -import { getBrokerPortfolio } from '../api/brokerAccountApi'; +import { useQuery } from '@tanstack/react-query' +import type { BrokerPortfolio } from '@/shared/api' +import { getBrokerPortfolio } from '../api/brokerAccountApi' export function useBrokerPortfolio(accountId: string | undefined) { return useQuery({ @@ -10,5 +10,5 @@ export function useBrokerPortfolio(accountId: string | undefined) { staleTime: 60_000, retry: 2, refetchOnWindowFocus: false, - }); + }) } diff --git a/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts b/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts index 2d7fa0b..6180d85 100644 --- a/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts +++ b/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts @@ -1,11 +1,11 @@ -import { request } from '@/shared/api/client'; -import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses'; +import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api' +import { request } from '@/shared/api/kyClient' export type BrokerEventsQuery = { - from: string; - to: string; - types?: string; -}; + from: string + to: string + types?: string +} export function getBrokerEvents( accountId: string, @@ -18,5 +18,5 @@ export function getBrokerEvents( to: query.to, types: query.types, }, - ); + ) } diff --git a/apps/frontend/src/entities/broker-event/index.ts b/apps/frontend/src/entities/broker-event/index.ts index fc62283..3b6b66d 100644 --- a/apps/frontend/src/entities/broker-event/index.ts +++ b/apps/frontend/src/entities/broker-event/index.ts @@ -1,2 +1,2 @@ -export { getBrokerEvents, type BrokerEventsQuery } from './api/brokerEventApi'; -export { useBrokerEvents } from './model/useBrokerEvents'; +export { type BrokerEventsQuery, getBrokerEvents } from './api/brokerEventApi' +export { useBrokerEvents } from './model/useBrokerEvents' diff --git a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx index 41b7135..9b4ea14 100644 --- a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx +++ b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx @@ -1,20 +1,20 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { type ReactNode } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getBrokerEvents } from '../api/brokerEventApi'; -import { useBrokerEvents } from './useBrokerEvents'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getBrokerEvents } from '../api/brokerEventApi' +import { useBrokerEvents } from './useBrokerEvents' vi.mock('../api/brokerEventApi', () => ({ getBrokerEvents: vi.fn(), -})); +})) function createWrapper(queryClient?: QueryClient) { - const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } const mockEventsData = { @@ -53,55 +53,55 @@ const mockEventsData = { currency: 'RUB' as const, }, ], -}; +} -const query = { from: '2026-06-22', to: '2026-06-29' }; +const query = { from: '2026-06-22', to: '2026-06-29' } describe('useBrokerEvents', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) it('returns events data from API', async () => { vi.mocked(getBrokerEvents).mockResolvedValue({ data: mockEventsData, meta: { fromCache: false, cachedAt: null }, - }); + }) const { result } = renderHook(() => useBrokerEvents('acc-1', query), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.summary.eventCount).toBe(3); - expect(getBrokerEvents).toHaveBeenCalledWith('acc-1', query); - }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.summary.eventCount).toBe(3) + expect(getBrokerEvents).toHaveBeenCalledWith('acc-1', query) + }) it('reuses cache when query key matches', async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) queryClient.setQueryData( ['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29', 'dividend,coupon'], mockEventsData, - ); + ) const { result } = renderHook( () => useBrokerEvents('acc-1', { ...query, types: 'dividend,coupon' }), { wrapper: createWrapper(queryClient), }, - ); + ) - await waitFor(() => expect(result.current.data).toBe(mockEventsData)); - expect(getBrokerEvents).not.toHaveBeenCalled(); - }); + await waitFor(() => expect(result.current.data).toBe(mockEventsData)) + expect(getBrokerEvents).not.toHaveBeenCalled() + }) it('is not enabled when accountId is undefined', async () => { const { result } = renderHook(() => useBrokerEvents(undefined, query), { wrapper: createWrapper(), - }); + }) - expect(result.current.isPending).toBe(true); - expect(getBrokerEvents).not.toHaveBeenCalled(); - }); -}); + expect(result.current.isPending).toBe(true) + expect(getBrokerEvents).not.toHaveBeenCalled() + }) +}) diff --git a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts index 1ab29f5..5efa7fe 100644 --- a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts +++ b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts @@ -1,9 +1,9 @@ -import { useQuery } from '@tanstack/react-query'; -import type { BrokerEventsData } from '@/shared/api/responses'; -import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi'; +import { useQuery } from '@tanstack/react-query' +import type { BrokerEventsData } from '@/shared/api' +import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi' export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { - const { from, to, types } = query; + const { from, to, types } = query return useQuery({ queryKey: ['broker', 'events', accountId, from, to, types], enabled: Boolean(accountId), @@ -11,5 +11,5 @@ export function useBrokerEvents(accountId: string | undefined, query: BrokerEven staleTime: 300_000, retry: 2, refetchOnWindowFocus: false, - }); + }) } diff --git a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts index 7b5a93e..e5d5b2f 100644 --- a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts +++ b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts @@ -1,15 +1,15 @@ -import { request } from '@/shared/api/client'; -import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses'; +import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api' +import { request } from '@/shared/api/kyClient' export type BrokerOperationQuery = { - from?: string; - to?: string; - cursor?: string; - limit?: number; - instrumentId?: string; - operationTypes?: string; - state?: string; -}; + from?: string + to?: string + cursor?: string + limit?: number + instrumentId?: string + operationTypes?: string + state?: string +} export function getBrokerOperations( accountId: string, @@ -26,5 +26,5 @@ export function getBrokerOperations( operationTypes: query.operationTypes, state: query.state, }, - ); + ) } diff --git a/apps/frontend/src/entities/broker-operation/index.ts b/apps/frontend/src/entities/broker-operation/index.ts index bb45f5f..90c1f15 100644 --- a/apps/frontend/src/entities/broker-operation/index.ts +++ b/apps/frontend/src/entities/broker-operation/index.ts @@ -1,9 +1,9 @@ -export { getBrokerOperations, type BrokerOperationQuery } from './api/brokerOperationApi'; +export { type BrokerOperationQuery, getBrokerOperations } from './api/brokerOperationApi' export { BROKER_OPERATION_TYPE_OPTIONS, + type BrokerOperationImpact, getBrokerOperationImpact, getBrokerOperationTypeLabel, isBrokerOperationType, - type BrokerOperationImpact, -} from './model/operationFilters'; -export { useBrokerOperations } from './model/useBrokerOperations'; +} from './model/operationFilters' +export { useBrokerOperations } from './model/useBrokerOperations' diff --git a/apps/frontend/src/entities/broker-operation/model/operationFilters.test.ts b/apps/frontend/src/entities/broker-operation/model/operationFilters.test.ts index e5feb7e..d9be1d2 100644 --- a/apps/frontend/src/entities/broker-operation/model/operationFilters.test.ts +++ b/apps/frontend/src/entities/broker-operation/model/operationFilters.test.ts @@ -1,17 +1,17 @@ -import { describe, expect, it } from 'vitest'; -import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from '../model/operationFilters'; +import { describe, expect, it } from 'vitest' +import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from '../model/operationFilters' describe('operationFilters', () => { it('accepts only declared broker operation types', () => { - expect(isBrokerOperationType('OPERATION_TYPE_BUY')).toBe(true); - expect(isBrokerOperationType('unexpected')).toBe(false); - }); + expect(isBrokerOperationType('OPERATION_TYPE_BUY')).toBe(true) + expect(isBrokerOperationType('unexpected')).toBe(false) + }) it('keeps operation type option values unique and labels sorted for the filter', () => { - const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value); - const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label); + const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value) + const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label) - expect(new Set(values).size).toBe(values.length); - expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))); - }); -}); + expect(new Set(values).size).toBe(values.length) + expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))) + }) +}) diff --git a/apps/frontend/src/entities/broker-operation/model/operationFilters.ts b/apps/frontend/src/entities/broker-operation/model/operationFilters.ts index 757c910..7510ed2 100644 --- a/apps/frontend/src/entities/broker-operation/model/operationFilters.ts +++ b/apps/frontend/src/entities/broker-operation/model/operationFilters.ts @@ -1,6 +1,6 @@ -import type { BrokerOperation } from '@/shared/api/responses'; +import type { BrokerOperation } from '@/shared/api' -export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'; +export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown' const TRADE_TYPES = new Set([ 'OPERATION_TYPE_BUY', @@ -11,14 +11,14 @@ const TRADE_TYPES = new Set([ 'OPERATION_TYPE_SELL_MARGIN', 'OPERATION_TYPE_DELIVERY_BUY', 'OPERATION_TYPE_DELIVERY_SELL', -]); +]) const BOND_REPAYMENT_TYPES = new Set([ 'OPERATION_TYPE_BOND_REPAYMENT', 'OPERATION_TYPE_BOND_REPAYMENT_FULL', -]); +]) -const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']); +const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']) const TAX_TYPES = new Set([ 'OPERATION_TYPE_TAX', @@ -26,35 +26,35 @@ const TAX_TYPES = new Set([ 'OPERATION_TYPE_DIVIDEND_TAX', 'OPERATION_TYPE_TAX_CORRECTION', 'OPERATION_TYPE_TAX_CORRECTION_COUPON', -]); +]) const FEE_TYPES = new Set([ 'OPERATION_TYPE_BROKER_FEE', 'OPERATION_TYPE_SERVICE_FEE', 'OPERATION_TYPE_MARGIN_FEE', 'OPERATION_TYPE_SUCCESS_FEE', -]); +]) const TRANSFER_INPUT_TYPES = new Set([ 'OPERATION_TYPE_INPUT', 'OPERATION_TYPE_INPUT_SWIFT', 'OPERATION_TYPE_INPUT_ACQUIRING', 'OPERATION_TYPE_INP_MULTI', -]); +]) const TRANSFER_OUTPUT_TYPES = new Set([ 'OPERATION_TYPE_OUTPUT', 'OPERATION_TYPE_OUTPUT_SWIFT', 'OPERATION_TYPE_OUTPUT_ACQUIRING', 'OPERATION_TYPE_OUT_MULTI', -]); +]) const SECURITY_TRANSFER_TYPES = new Set([ 'OPERATION_TYPE_INPUT_SECURITIES', 'OPERATION_TYPE_OUTPUT_SECURITIES', 'OPERATION_TYPE_TRANS_IIS_BS', 'OPERATION_TYPE_TRANS_BS_BS', -]); +]) const OPERATION_TYPE_LABELS: Record = { OPERATION_TYPE_BUY: 'Покупка', @@ -82,7 +82,7 @@ const OPERATION_TYPE_LABELS: Record = { OPERATION_TYPE_OUTPUT: 'Вывод средств', OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг', OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг', -}; +} export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray< Readonly<{ value: string; label: string }> @@ -90,25 +90,25 @@ export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray< Object.entries(OPERATION_TYPE_LABELS) .map(([value, label]) => Object.freeze({ value, label })) .sort((left, right) => left.label.localeCompare(right.label, 'ru')), -); +) -const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value)); +const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value)) export function isBrokerOperationType(value: string | null): value is string { - return value !== null && BROKER_OPERATION_TYPES.has(value); + return value !== null && BROKER_OPERATION_TYPES.has(value) } export function getBrokerOperationTypeLabel( operation: Pick, ): string { - const knownLabel = OPERATION_TYPE_LABELS[operation.type]; - if (knownLabel) return knownLabel; - if (operation.description) return operation.description; + const knownLabel = OPERATION_TYPE_LABELS[operation.type] + if (knownLabel) return knownLabel + if (operation.description) return operation.description return operation.type .replace(/^OPERATION_TYPE_/, '') .replace(/_/g, ' ') - .toLowerCase(); + .toLowerCase() } export function getBrokerOperationImpact( @@ -119,15 +119,15 @@ export function getBrokerOperationImpact( BOND_REPAYMENT_TYPES.has(operation.type) || SECURITY_TRANSFER_TYPES.has(operation.type) ) { - return 'neutral'; + return 'neutral' } - if (INCOME_TYPES.has(operation.type)) return 'adds'; - if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces'; - if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds'; - if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces'; - if (operation.category === 'tax' || operation.category === 'fee') return 'reduces'; - if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds'; + if (INCOME_TYPES.has(operation.type)) return 'adds' + if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces' + if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds' + if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces' + if (operation.category === 'tax' || operation.category === 'fee') return 'reduces' + if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds' - return 'unknown'; + return 'unknown' } diff --git a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.test.tsx b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.test.tsx index 7de1636..cc038b8 100644 --- a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.test.tsx +++ b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.test.tsx @@ -1,26 +1,26 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { type ReactNode } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getBrokerOperations } from '../api/brokerOperationApi'; -import { useBrokerOperations } from '../model/useBrokerOperations'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getBrokerOperations } from '../api/brokerOperationApi' +import { useBrokerOperations } from '../model/useBrokerOperations' vi.mock('../api/brokerOperationApi', () => ({ getBrokerOperations: vi.fn(), -})); +})) function createWrapper(queryClient?: QueryClient) { - const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } describe('useBrokerOperations', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) it('returns operations page data from API', async () => { vi.mocked(getBrokerOperations).mockResolvedValue({ @@ -32,34 +32,34 @@ describe('useBrokerOperations', () => { asOf: '2026-06-19T00:00:00.000Z', }, meta: { fromCache: false, cachedAt: null }, - }); + }) const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.accountId).toBe('acc-1'); - expect(getBrokerOperations).toHaveBeenCalledWith('acc-1', { limit: 5 }); - }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.accountId).toBe('acc-1') + expect(getBrokerOperations).toHaveBeenCalledWith('acc-1', { limit: 5 }) + }) it('reuses the broker operations cache key across the account overview and full history pages', async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const cachedPage = { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', - }; + } - queryClient.setQueryData(['broker', 'operations', 'acc-1', { limit: 5 }], cachedPage); + queryClient.setQueryData(['broker', 'operations', 'acc-1', { limit: 5 }], cachedPage) const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), { wrapper: createWrapper(queryClient), - }); + }) - await waitFor(() => expect(result.current.data).toBe(cachedPage)); - expect(getBrokerOperations).not.toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.data).toBe(cachedPage)) + expect(getBrokerOperations).not.toHaveBeenCalled() + }) +}) diff --git a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts index 5c24188..20335c4 100644 --- a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts +++ b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts @@ -1,6 +1,6 @@ -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import type { BrokerOperationsPage } from '@/shared/api/responses'; -import { getBrokerOperations, type BrokerOperationQuery } from '../api/brokerOperationApi'; +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import type { BrokerOperationsPage } from '@/shared/api' +import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi' export function useBrokerOperations( accountId: string | undefined, @@ -14,5 +14,5 @@ export function useBrokerOperations( retry: 2, placeholderData: keepPreviousData, refetchOnWindowFocus: false, - }); + }) } diff --git a/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts b/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts index 485c3dc..0cd0fe8 100644 --- a/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts +++ b/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts @@ -1,5 +1,5 @@ -import { request } from '@/shared/api/client'; -import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses'; +import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api' +import { request } from '@/shared/api/kyClient' export function getBrokerPositions( accountId: string, @@ -12,5 +12,5 @@ export function getBrokerPositions( limit: query.limit ? String(query.limit) : undefined, type: query.type, }, - ); + ) } diff --git a/apps/frontend/src/entities/broker-position/index.ts b/apps/frontend/src/entities/broker-position/index.ts index 58387f2..8e2144f 100644 --- a/apps/frontend/src/entities/broker-position/index.ts +++ b/apps/frontend/src/entities/broker-position/index.ts @@ -1,12 +1,12 @@ -export { getBrokerPositions } from './api/brokerPositionApi'; +export { getBrokerPositions } from './api/brokerPositionApi' export { - buildBrokerAllocation, type BrokerAllocationItem, type BrokerAllocationKey, -} from './model/brokerAllocation'; + buildBrokerAllocation, +} from './model/brokerAllocation' export { + type BrokerPositionGroup, getBrokerInstrumentPath, getBrokerPositionGroup, - type BrokerPositionGroup, -} from './model/brokerDisplay'; -export { useBrokerPositions } from './model/useBrokerPositions'; +} from './model/brokerDisplay' +export { useBrokerPositions } from './model/useBrokerPositions' diff --git a/apps/frontend/src/entities/broker-position/model/brokerAllocation.test.ts b/apps/frontend/src/entities/broker-position/model/brokerAllocation.test.ts index 40f00fe..5ea8fd3 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerAllocation.test.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerAllocation.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; -import { buildBrokerAllocation } from './brokerAllocation'; +import { describe, expect, it } from 'vitest' +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' +import { buildBrokerAllocation } from './brokerAllocation' function money(value: number): BrokerMoney { return { @@ -8,16 +8,16 @@ function money(value: number): BrokerMoney { units: String(Math.trunc(value)), nano: 0, value, - }; + } } function portfolio( values: Partial>, ): BrokerPortfolio { const total = (key: keyof typeof values): BrokerMoney | null => { - const value = values[key]; - return value == null ? null : money(value); - }; + const value = values[key] + return value == null ? null : money(value) + } return { account: { @@ -53,7 +53,7 @@ function portfolio( cash: [], blockedCash: [], asOf: '2025-01-01T00:00:00.000Z', - }; + } } describe('buildBrokerAllocation', () => { @@ -72,73 +72,71 @@ describe('buildBrokerAllocation', () => { { key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' }, ], negative: [], - }); - }); + }) + }) it('omits zero-value sectors', () => { const result = buildBrokerAllocation( portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }), - ); + ) - expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']); - expect(result.negative).toEqual([]); - }); + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']) + expect(result.negative).toEqual([]) + }) it('reports a negative residual outside the sectors', () => { const result = buildBrokerAllocation( portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }), - ); + ) - expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']); + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']) expect(result.negative).toEqual([ { key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' }, - ]); - }); + ]) + }) it('ignores a tiny negative residual caused by decimal arithmetic', () => { - const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 })); + const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 })) - expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']); - expect(result.negative).toEqual([]); - }); + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']) + expect(result.negative).toEqual([]) + }) it('ignores a tiny positive residual caused by decimal arithmetic', () => { - const result = buildBrokerAllocation( - portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }), - ); + const result = buildBrokerAllocation(portfolio({ shares: 0.3, portfolio: 0.30000000000000004 })) - expect(result.sectors.map(({ key }) => key)).toEqual(['shares']); - expect(result.negative).toEqual([]); - }); + expect(result.sectors.map(({ key }) => key)).toEqual(['shares']) + expect(result.negative).toEqual([]) + }) it('returns no allocation for missing or nonpositive portfolio totals', () => { expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({ total: 0, sectors: [], negative: [], - }); + }) expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({ total: 0, sectors: [], negative: [], - }); + }) expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({ total: -10, sectors: [], negative: [], - }); - }); + }) + }) it('preserves named negative components when the portfolio total is nonpositive', () => { expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({ total: 0, sectors: [], negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }], - }); + }) expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({ total: -10, sectors: [], negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }], - }); - }); -}); + }) + }) +}) diff --git a/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts b/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts index 294610f..2e20a2a 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts @@ -1,16 +1,16 @@ -import type { BrokerPortfolio } from '@/shared/api/responses'; +import type { BrokerPortfolio } from '@/shared/api' -export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other'; +export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other' export interface BrokerAllocationItem { - key: BrokerAllocationKey; - label: string; - value: number; - percent: number; - color: string; + key: BrokerAllocationKey + label: string + value: number + percent: number + color: string } -type BrokerNegativeAllocationItem = Omit; +type BrokerNegativeAllocationItem = Omit const ALLOCATION_CONFIG: Array> = [ { key: 'shares', label: 'Акции', color: '#4969f5' }, @@ -18,40 +18,40 @@ const ALLOCATION_CONFIG: Array, number> = { shares, bonds, etf, cash, - }; + } if (total <= 0) { const negative = ALLOCATION_CONFIG.filter( ( item, ): item is (typeof ALLOCATION_CONFIG)[number] & { - key: Exclude; + key: Exclude } => item.key !== 'other', ) .filter((item) => namedValues[item.key] < 0) - .map((item) => ({ ...item, value: namedValues[item.key] })); - return { total, sectors: [], negative }; + .map((item) => ({ ...item, value: namedValues[item.key] })) + return { total, sectors: [], negative } } - const mappedTotal = shares + bonds + etf + cash; - const residual = total - mappedTotal; + const mappedTotal = shares + bonds + etf + cash + const residual = total - mappedTotal const residualTolerance = Number.EPSILON * Math.max( @@ -59,27 +59,27 @@ export function buildBrokerAllocation(portfolio: BrokerPortfolio): { Math.abs(total), Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash), ) * - 8; + 8 const values: Record = { shares, bonds, etf, cash, other: Math.abs(residual) <= residualTolerance ? 0 : residual, - }; + } - const sectors: BrokerAllocationItem[] = []; - const negative: BrokerNegativeAllocationItem[] = []; + const sectors: BrokerAllocationItem[] = [] + const negative: BrokerNegativeAllocationItem[] = [] for (const item of ALLOCATION_CONFIG) { - const value = values[item.key]; + const value = values[item.key] if (value > 0) { - sectors.push({ ...item, value, percent: (value / total) * 100 }); + sectors.push({ ...item, value, percent: (value / total) * 100 }) } else if (value < 0) { - negative.push({ ...item, value }); + negative.push({ ...item, value }) } } - return { total, sectors, negative }; + return { total, sectors, negative } } diff --git a/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts b/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts index ae123fa..59480f2 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts @@ -1,12 +1,12 @@ -import { describe, expect, it } from 'vitest'; -import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses'; -import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'; +import { describe, expect, it } from 'vitest' import { BROKER_OPERATION_TYPE_OPTIONS, getBrokerOperationImpact, getBrokerOperationTypeLabel, isBrokerOperationType, -} from '@/entities/broker-operation'; +} from '@/entities/broker-operation' +import type { BrokerOperation, BrokerPosition } from '@/shared/api' +import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay' function position(input: Partial): BrokerPosition { return { @@ -25,7 +25,7 @@ function position(input: Partial): BrokerPosition { expectedYieldPercent: null, dailyYield: null, ...input, - }; + } } function operation(input: Partial): BrokerOperation { @@ -53,70 +53,70 @@ function operation(input: Partial): BrokerOperation { quantity: null, quantityDone: null, ...input, - }; + } } describe('broker display helpers', () => { it('groups positions by instrument type', () => { - expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares'); - expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds'); - expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other'); - expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other'); - }); + expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares') + expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds') + expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other') + expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other') + }) it('builds stock and bond routes from instrument metadata', () => { expect( getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }), - ).toBe('/stocks/SBER'); + ).toBe('/stocks/SBER') expect( getBrokerInstrumentPath({ ticker: 'SU26238RMFS5', instrumentType: 'bond', classCode: 'TQOB', }), - ).toBe('/bonds/SU26238RMFS5'); + ).toBe('/bonds/SU26238RMFS5') expect( getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }), - ).toBeNull(); + ).toBeNull() expect( getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }), - ).toBeNull(); - }); + ).toBeNull() + }) it('uses class code fallback when instrument type is missing', () => { expect( getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }), - ).toBe('/stocks/SBER'); + ).toBe('/stocks/SBER') expect( getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }), - ).toBe('/bonds/RU000A0JX0J2'); - }); + ).toBe('/bonds/RU000A0JX0J2') + }) it('does not let class code override a known unsupported or conflicting instrument type', () => { expect( getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQBR' }), - ).toBeNull(); + ).toBeNull() expect( getBrokerInstrumentPath({ ticker: 'SU26238RMFS5', instrumentType: 'bond', classCode: 'TQBR', }), - ).toBe('/bonds/SU26238RMFS5'); - }); + ).toBe('/bonds/SU26238RMFS5') + }) it('maps operation enum values to Russian labels', () => { expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe( 'Выплата купона', - ); - expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог'); - expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка'); + ) + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог') + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка') expect( getBrokerOperationTypeLabel( operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }), ), - ).toBe('Custom'); - }); + ).toBe('Custom') + }) it('exposes independently selectable known operation types', () => { expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual( @@ -126,28 +126,28 @@ describe('broker display helpers', () => { { value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' }, { value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' }, ]), - ); - }); + ) + }) it('keeps operation type option values unique and labels in Russian order', () => { - const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value); - const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label); + const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value) + const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label) - expect(new Set(values).size).toBe(values.length); - expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))); - }); + expect(new Set(values).size).toBe(values.length) + expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))) + }) it('keeps operation type options immutable at runtime', () => { - expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true); - expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true); - }); + expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true) + expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true) + }) it('validates only exact known operation type values', () => { - expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true); - expect(isBrokerOperationType('operation_type_coupon')).toBe(false); - expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false); - expect(isBrokerOperationType(null)).toBe(false); - }); + expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true) + expect(isBrokerOperationType('operation_type_coupon')).toBe(false) + expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false) + expect(isBrokerOperationType(null)).toBe(false) + }) it('classifies operations by portfolio impact', () => { expect( @@ -158,7 +158,7 @@ describe('broker display helpers', () => { payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, }), ), - ).toBe('adds'); + ).toBe('adds') expect( getBrokerOperationImpact( operation({ @@ -167,7 +167,7 @@ describe('broker display helpers', () => { payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, }), ), - ).toBe('reduces'); + ).toBe('reduces') expect( getBrokerOperationImpact( operation({ @@ -176,11 +176,11 @@ describe('broker display helpers', () => { payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, }), ), - ).toBe('neutral'); + ).toBe('neutral') expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe( 'unknown', - ); - }); + ) + }) it('keeps unknown operation types unclear even when they have non-zero payments', () => { expect( @@ -191,7 +191,7 @@ describe('broker display helpers', () => { payment: { currency: 'RUB', units: '100', nano: 0, value: 100 }, }), ), - ).toBe('unknown'); + ).toBe('unknown') expect( getBrokerOperationImpact( operation({ @@ -200,8 +200,8 @@ describe('broker display helpers', () => { payment: { currency: 'RUB', units: '-100', nano: 0, value: -100 }, }), ), - ).toBe('unknown'); - }); + ).toBe('unknown') + }) it('classifies known income operation types as additions even with weak metadata', () => { expect( @@ -212,7 +212,7 @@ describe('broker display helpers', () => { payment: null, }), ), - ).toBe('adds'); + ).toBe('adds') expect( getBrokerOperationImpact( operation({ @@ -221,6 +221,6 @@ describe('broker display helpers', () => { payment: null, }), ), - ).toBe('adds'); - }); -}); + ).toBe('adds') + }) +}) diff --git a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts index 78ca888..174fad4 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts @@ -1,46 +1,46 @@ -import type { BrokerPosition } from '@/shared/api/responses'; +import type { BrokerPosition } from '@/shared/api' -export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; +export type BrokerPositionGroup = 'shares' | 'bonds' | 'other' type BrokerInstrumentLinkInput = { - ticker: string | null; - instrumentType: string | null; - classCode: string | null; -}; + ticker: string | null + instrumentType: string | null + classCode: string | null +} -const STOCK_CLASS_CODES = new Set(['TQBR']); -const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']); +const STOCK_CLASS_CODES = new Set(['TQBR']) +const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']) export function getBrokerPositionGroup( position: Pick, ): BrokerPositionGroup { - const instrumentType = position.instrumentType?.toLowerCase(); + const instrumentType = position.instrumentType?.toLowerCase() - if (instrumentType === 'share') return 'shares'; - if (instrumentType === 'bond') return 'bonds'; + if (instrumentType === 'share') return 'shares' + if (instrumentType === 'bond') return 'bonds' - return 'other'; + return 'other' } export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null { - const ticker = input.ticker?.trim().toUpperCase(); - if (!ticker) return null; + const ticker = input.ticker?.trim().toUpperCase() + if (!ticker) return null - const instrumentType = input.instrumentType?.toLowerCase(); - const classCode = input.classCode?.toUpperCase() ?? null; + const instrumentType = input.instrumentType?.toLowerCase() + const classCode = input.classCode?.toUpperCase() ?? null if (instrumentType === 'share') { - return `/stocks/${encodeURIComponent(ticker)}`; + return `/stocks/${encodeURIComponent(ticker)}` } if (instrumentType === 'bond') { - return `/bonds/${encodeURIComponent(ticker)}`; + return `/bonds/${encodeURIComponent(ticker)}` } - if (instrumentType) return null; + if (instrumentType) return null - if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`; - if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`; + if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}` + if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}` - return null; + return null } diff --git a/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts b/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts index 1925ecd..1911d63 100644 --- a/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts +++ b/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts @@ -1,6 +1,6 @@ -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import type { BrokerPositionsPage } from '@/shared/api/responses'; -import { getBrokerPositions } from '../api/brokerPositionApi'; +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import type { BrokerPositionsPage } from '@/shared/api' +import { getBrokerPositions } from '../api/brokerPositionApi' export function useBrokerPositions( accountId: string | undefined, @@ -14,5 +14,5 @@ export function useBrokerPositions( retry: 2, placeholderData: keepPreviousData, refetchOnWindowFocus: false, - }); + }) } diff --git a/apps/frontend/src/entities/portfolio/api/portfolioApi.ts b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts index d3ed6fa..26634c9 100644 --- a/apps/frontend/src/entities/portfolio/api/portfolioApi.ts +++ b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts @@ -1,33 +1,28 @@ -import { request } from '@/shared/api/client'; -import type { - AnalyticsResponse, - Portfolio, - PortfolioDetail, - Position, -} from '@/shared/api/responses'; +import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from '@/shared/api' +import { request } from '@/shared/api/kyClient' export function getPortfolios(): Promise<{ - data: Portfolio[]; - meta: { cachedAt: string | null; fromCache: boolean }; + data: Portfolio[] + meta: { cachedAt: string | null; fromCache: boolean } }> { - return request('/api/v1/portfolios'); + return request('/api/v1/portfolios') } export function getPortfolio( id: number, ): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${id}`); + return request(`/api/v1/portfolios/${id}`) } export function createPortfolio(data: { - name: string; - description?: string; - currency?: string; + name: string + description?: string + currency?: string }): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> { return request('/api/v1/portfolios', undefined, { method: 'POST', body: data, - }); + }) } export function updatePortfolio( @@ -37,7 +32,7 @@ export function updatePortfolio( return request(`/api/v1/portfolios/${id}`, undefined, { method: 'PATCH', body: data, - }); + }) } export function deletePortfolio( @@ -45,41 +40,41 @@ export function deletePortfolio( ): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> { return request(`/api/v1/portfolios/${id}`, undefined, { method: 'DELETE', - }); + }) } export function addPosition( portfolioId: number, data: { - secid: string; - quantity: number; - buyPrice?: number; - buyDate?: string; - notes?: string; - tags?: string[]; + secid: string + quantity: number + buyPrice?: number + buyDate?: string + notes?: string + tags?: string[] }, ): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { return request(`/api/v1/portfolios/${portfolioId}/positions`, undefined, { method: 'POST', body: data, - }); + }) } export function updatePosition( portfolioId: number, positionId: number, data: { - quantity?: number; - buyPrice?: number; - buyDate?: string; - notes?: string; - tags?: string[]; + quantity?: number + buyPrice?: number + buyDate?: string + notes?: string + tags?: string[] }, ): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { return request(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { method: 'PATCH', body: data, - }); + }) } export function removePosition( @@ -88,11 +83,11 @@ export function removePosition( ): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> { return request(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { method: 'DELETE', - }); + }) } export function getPortfolioAnalytics( portfolioId: number, ): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${portfolioId}/analytics`); + return request(`/api/v1/portfolios/${portfolioId}/analytics`) } diff --git a/apps/frontend/src/entities/portfolio/index.ts b/apps/frontend/src/entities/portfolio/index.ts index 48ccebb..7c4238c 100644 --- a/apps/frontend/src/entities/portfolio/index.ts +++ b/apps/frontend/src/entities/portfolio/index.ts @@ -1,16 +1,16 @@ -export { usePortfolio } from './model/usePortfolio'; -export { usePortfolios } from './model/usePortfolios'; -export { usePortfolioAnalytics } from './model/usePortfolioAnalytics'; -export { usePortfolioMutations } from './model/usePortfolioMutations'; -export { usePositionMutations } from './model/usePositionMutations'; export { - getPortfolios, - getPortfolio, - createPortfolio, - updatePortfolio, - deletePortfolio, addPosition, - updatePosition, - removePosition, + createPortfolio, + deletePortfolio, + getPortfolio, getPortfolioAnalytics, -} from './api/portfolioApi'; + getPortfolios, + removePosition, + updatePortfolio, + updatePosition, +} from './api/portfolioApi' +export { usePortfolio } from './model/usePortfolio' +export { usePortfolioAnalytics } from './model/usePortfolioAnalytics' +export { usePortfolioMutations } from './model/usePortfolioMutations' +export { usePortfolios } from './model/usePortfolios' +export { usePositionMutations } from './model/usePositionMutations' diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolio.ts b/apps/frontend/src/entities/portfolio/model/usePortfolio.ts index e2d6be8..547f4ac 100644 --- a/apps/frontend/src/entities/portfolio/model/usePortfolio.ts +++ b/apps/frontend/src/entities/portfolio/model/usePortfolio.ts @@ -1,17 +1,17 @@ -import { useQuery } from '@tanstack/react-query'; -import { getPortfolio } from '../api/portfolioApi'; -import type { PortfolioDetail } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { PortfolioDetail } from '@/shared/api' +import { getPortfolio } from '../api/portfolioApi' export function usePortfolio(id: number) { return useQuery({ queryKey: ['portfolio', id], queryFn: async () => { - const res = await getPortfolio(id); - return res.data; + const res = await getPortfolio(id) + return res.data }, staleTime: 900_000, retry: 2, refetchOnWindowFocus: false, enabled: !!id, - }); + }) } diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts b/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts index f74d169..3d0b8e1 100644 --- a/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts +++ b/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts @@ -1,17 +1,17 @@ -import { useQuery } from '@tanstack/react-query'; -import { getPortfolioAnalytics } from '../api/portfolioApi'; -import type { AnalyticsResponse } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { AnalyticsResponse } from '@/shared/api' +import { getPortfolioAnalytics } from '../api/portfolioApi' export function usePortfolioAnalytics(portfolioId: number) { return useQuery({ queryKey: ['portfolio', portfolioId, 'analytics'], queryFn: async () => { - const res = await getPortfolioAnalytics(portfolioId); - return res.data; + const res = await getPortfolioAnalytics(portfolioId) + return res.data }, staleTime: 900_000, retry: 2, refetchOnWindowFocus: false, enabled: !!portfolioId, - }); + }) } diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts b/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts index f5965ee..ae6559f 100644 --- a/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts +++ b/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts @@ -1,45 +1,45 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolioApi'; -import { useNavigate } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { createPortfolio, deletePortfolio, updatePortfolio } from '../api/portfolioApi' export function usePortfolioMutations() { - const queryClient = useQueryClient(); - const navigate = useNavigate(); + const queryClient = useQueryClient() + const navigate = useNavigate() const create = useMutation({ mutationFn: (data: { name: string; description?: string; currency?: string }) => createPortfolio(data), onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: ['portfolios'] }); - navigate(`/portfolios/${res.data.id}`); + queryClient.invalidateQueries({ queryKey: ['portfolios'] }) + navigate({ to: `/portfolios/${res.data.id}` }) }, - }); + }) const update = useMutation({ mutationFn: ({ id, data, }: { - id: number; + id: number data: { - name?: string; - description?: string; - currency?: string; - }; + name?: string + description?: string + currency?: string + } }) => updatePortfolio(id, data), onSuccess: (_, { id }) => { - queryClient.invalidateQueries({ queryKey: ['portfolios'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio', id] }); + queryClient.invalidateQueries({ queryKey: ['portfolios'] }) + queryClient.invalidateQueries({ queryKey: ['portfolio', id] }) }, - }); + }) const remove = useMutation({ mutationFn: (id: number) => deletePortfolio(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['portfolios'] }); - navigate('/portfolios'); + queryClient.invalidateQueries({ queryKey: ['portfolios'] }) + navigate({ to: '/portfolios' }) }, - }); + }) - return { create, update, remove }; + return { create, update, remove } } diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolios.ts b/apps/frontend/src/entities/portfolio/model/usePortfolios.ts index 781a63d..d5c7050 100644 --- a/apps/frontend/src/entities/portfolio/model/usePortfolios.ts +++ b/apps/frontend/src/entities/portfolio/model/usePortfolios.ts @@ -1,16 +1,16 @@ -import { useQuery } from '@tanstack/react-query'; -import { getPortfolios } from '../api/portfolioApi'; -import type { Portfolio } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { Portfolio } from '@/shared/api' +import { getPortfolios } from '../api/portfolioApi' export function usePortfolios() { return useQuery({ queryKey: ['portfolios'], queryFn: async () => { - const res = await getPortfolios(); - return res.data; + const res = await getPortfolios() + return res.data }, staleTime: 900_000, retry: 2, refetchOnWindowFocus: false, - }); + }) } diff --git a/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts b/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts index 30ed785..25bea73 100644 --- a/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts +++ b/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts @@ -1,46 +1,46 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { addPosition, updatePosition, removePosition } from '../api/portfolioApi'; -import type { PortfolioDetail } from '@/shared/api/responses'; +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { PortfolioDetail } from '@/shared/api' +import { addPosition, removePosition, updatePosition } from '../api/portfolioApi' export function usePositionMutations(portfolioId: number) { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() const add = useMutation({ mutationFn: (data: { - secid: string; - quantity: number; - buyPrice?: number; - buyDate?: string; - notes?: string; - tags?: string[]; + secid: string + quantity: number + buyPrice?: number + buyDate?: string + notes?: string + tags?: string[] }) => addPosition(portfolioId, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); + queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }) }, - }); + }) const update = useMutation({ mutationFn: ({ positionId, data, }: { - positionId: number; + positionId: number data: { - quantity?: number; - buyPrice?: number; - buyDate?: string; - notes?: string; - tags?: string[]; - }; + quantity?: number + buyPrice?: number + buyDate?: string + notes?: string + tags?: string[] + } }) => updatePosition(portfolioId, positionId, data), onMutate: async ({ positionId, data }) => { - await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] }); + await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] }) const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([ 'portfolio', portfolioId, - ]); + ]) queryClient.setQueryData(['portfolio', portfolioId], (old: any) => { - if (!old) return old; + if (!old) return old return { ...old, positions: old.positions.map((p: any) => @@ -53,26 +53,26 @@ export function usePositionMutations(portfolioId: number) { } : p, ), - }; - }); - return { previous }; + } + }) + return { previous } }, onError: (_err, _vars, context) => { if (context?.previous) { - queryClient.setQueryData(['portfolio', portfolioId], context.previous); + queryClient.setQueryData(['portfolio', portfolioId], context.previous) } }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); + queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }) }, - }); + }) const remove = useMutation({ mutationFn: (positionId: number) => removePosition(portfolioId, positionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); + queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }) }, - }); + }) - return { add, update, remove }; + return { add, update, remove } } diff --git a/apps/frontend/src/entities/search/api/searchApi.ts b/apps/frontend/src/entities/search/api/searchApi.ts index 5b7388e..327e891 100644 --- a/apps/frontend/src/entities/search/api/searchApi.ts +++ b/apps/frontend/src/entities/search/api/searchApi.ts @@ -1,10 +1,10 @@ -import { request } from '@/shared/api/client'; -import type { SearchResultItem } from '@/shared/api/responses'; +import type { SearchResultItem } from '@/shared/api' +import { request } from '@/shared/api/kyClient' export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) { return request('/api/v1/securities/search', { q, type, limit: String(limit), - }); + }) } diff --git a/apps/frontend/src/entities/search/index.ts b/apps/frontend/src/entities/search/index.ts index f152f29..9e0dcec 100644 --- a/apps/frontend/src/entities/search/index.ts +++ b/apps/frontend/src/entities/search/index.ts @@ -1,2 +1,2 @@ -export { useSearch } from './model/useSearch'; -export { searchSecurities } from './api/searchApi'; +export { searchSecurities } from './api/searchApi' +export { useSearch } from './model/useSearch' diff --git a/apps/frontend/src/entities/search/model/useSearch.test.tsx b/apps/frontend/src/entities/search/model/useSearch.test.tsx index 6a03269..371f89d 100644 --- a/apps/frontend/src/entities/search/model/useSearch.test.tsx +++ b/apps/frontend/src/entities/search/model/useSearch.test.tsx @@ -1,46 +1,46 @@ -import { describe, it, expect } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { http, HttpResponse } from 'msw'; -import { type ReactNode } from 'react'; -import { server } from '@/shared/lib/test/server'; -import { useSearch } from '@/entities/search'; +import { server } from '@mocks/server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { HttpResponse, http } from 'msw' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import { useSearch } from '@/entities/search' -const API = '/api/v1'; +const API = '/api/v1' function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } describe('useSearch', () => { it('does not fetch when query is empty', () => { - const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() }); + const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() }) - expect(result.current.isFetching).toBe(false); - expect(result.current.data).toBeUndefined(); - }); + expect(result.current.isFetching).toBe(false) + expect(result.current.data).toBeUndefined() + }) it('does not fetch when query is too short', () => { - const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() }); + const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() }) - expect(result.current.data).toBeUndefined(); - }); + expect(result.current.data).toBeUndefined() + }) it('returns search results for valid query', async () => { - const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() }); + const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() }) await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); + expect(result.current.isSuccess).toBe(true) + }) - expect(result.current.data).toBeDefined(); - expect(result.current.data?.length).toBeGreaterThan(0); - expect(result.current.data?.[0].secid).toBe('SBER'); - }); + expect(result.current.data).toBeDefined() + expect(result.current.data?.length).toBeGreaterThan(0) + expect(result.current.data?.[0].secid).toBe('SBER') + }) it('returns empty array when no results', async () => { server.use( @@ -49,24 +49,24 @@ describe('useSearch', () => { data: { data: [], meta: { fromCache: false, cachedAt: null } }, }), ), - ); + ) - const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() }); + const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() }) await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); + expect(result.current.isSuccess).toBe(true) + }) - expect(result.current.data).toEqual([]); - }); + expect(result.current.data).toEqual([]) + }) it('returns error state on network failure', async () => { - server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 }))); + server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 }))) - const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() }); + const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() }) await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - }); -}); + expect(result.current.isError).toBe(true) + }) + }) +}) diff --git a/apps/frontend/src/entities/search/model/useSearch.ts b/apps/frontend/src/entities/search/model/useSearch.ts index eafdc21..b6c57a9 100644 --- a/apps/frontend/src/entities/search/model/useSearch.ts +++ b/apps/frontend/src/entities/search/model/useSearch.ts @@ -1,16 +1,16 @@ -import { useQuery } from '@tanstack/react-query'; -import { searchSecurities } from '../api/searchApi'; -import type { SearchResultItem } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { SearchResultItem } from '@/shared/api' +import { searchSecurities } from '../api/searchApi' export function useSearch(query: string) { return useQuery({ queryKey: ['securities', 'search', query], queryFn: async () => { - const res = await searchSecurities(query); + const res = await searchSecurities(query) - return res.data; + return res.data }, enabled: query.length >= 2, staleTime: 60_000, - }); + }) } diff --git a/apps/frontend/src/entities/session/api/sessionApi.test.ts b/apps/frontend/src/entities/session/api/sessionApi.test.ts index ade1f5a..94d7f95 100644 --- a/apps/frontend/src/entities/session/api/sessionApi.test.ts +++ b/apps/frontend/src/entities/session/api/sessionApi.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { setAccessToken, getAccessToken } from './tokenManager'; -import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi'; +import { server } from '@mocks/server' +import { HttpResponse, http } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' +import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi' +import { getAccessToken, setAccessToken } from './tokenManager' -const API = '/api/v1'; +const API = '/api/v1' beforeEach(() => { - setAccessToken(null); -}); + setAccessToken(null) +}) describe('login', () => { it('returns auth data and sets access token', async () => { - const result = await login('user@test.com', 'password'); - expect(result.user.email).toBe('user@test.com'); - expect(result.accessToken).toBe('mock-access-token'); - expect(getAccessToken()).toBe('mock-access-token'); - }); + const result = await login('user@test.com', 'password') + expect(result.user.email).toBe('user@test.com') + expect(result.accessToken).toBe('mock-access-token') + expect(getAccessToken()).toBe('mock-access-token') + }) it('throws on invalid credentials', async () => { server.use( @@ -24,45 +24,45 @@ describe('login', () => { `${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), ), - ); - await expect(login('wrong@test.com', 'wrong')).rejects.toThrow(); - }); -}); + ) + await expect(login('wrong@test.com', 'wrong')).rejects.toThrow() + }) +}) describe('register', () => { it('returns auth data and sets access token', async () => { - const result = await register('new@test.com', 'password', 'New User'); - expect(result.user.email).toBe('user@test.com'); - expect(getAccessToken()).toBe('mock-access-token'); - }); -}); + const result = await register('new@test.com', 'password', 'New User') + expect(result.user.email).toBe('user@test.com') + expect(getAccessToken()).toBe('mock-access-token') + }) +}) describe('refresh', () => { it('returns auth data and sets access token', async () => { - const result = await refresh(); - expect(result.accessToken).toBe('mock-access-token'); - expect(getAccessToken()).toBe('mock-access-token'); - }); -}); + const result = await refresh() + expect(result.accessToken).toBe('mock-access-token') + expect(getAccessToken()).toBe('mock-access-token') + }) +}) describe('logout', () => { it('clears access token', async () => { - setAccessToken('test-token'); - await logout(); - expect(getAccessToken()).toBeNull(); - }); -}); + setAccessToken('test-token') + await logout() + expect(getAccessToken()).toBeNull() + }) +}) describe('getMe', () => { it('returns current user', async () => { - const result = await getMe(); - expect(result.email).toBe('user@test.com'); - }); -}); + const result = await getMe() + expect(result.email).toBe('user@test.com') + }) +}) describe('updateProfile', () => { it('updates and returns user', async () => { - const result = await updateProfile({ name: 'Updated' }); - expect(result.name).toBe('Updated'); - }); -}); + const result = await updateProfile({ name: 'Updated' }) + expect(result.name).toBe('Updated') + }) +}) diff --git a/apps/frontend/src/entities/session/api/sessionApi.ts b/apps/frontend/src/entities/session/api/sessionApi.ts index 8d6f1e2..07fdd97 100644 --- a/apps/frontend/src/entities/session/api/sessionApi.ts +++ b/apps/frontend/src/entities/session/api/sessionApi.ts @@ -1,15 +1,15 @@ -import { request } from '@/shared/api/client'; -import { setAccessToken } from './tokenManager'; -import type { AuthResponse, UserResponse } from '@/shared/api/responses'; +import type { AuthResponse, UserResponse } from '@/shared/api' +import { request } from '@/shared/api/kyClient' +import { setAccessToken } from './tokenManager' export async function login(email: string, password: string) { const result = await request('/api/v1/auth/login', undefined, { method: 'POST', body: { email, password }, skipAuth: true, - }); - setAccessToken(result.data.accessToken); - return result.data; + }) + setAccessToken(result.data.accessToken) + return result.data } export async function register(email: string, password: string, name?: string) { @@ -17,37 +17,37 @@ export async function register(email: string, password: string, name?: string) { method: 'POST', body: { email, password, name }, skipAuth: true, - }); - setAccessToken(result.data.accessToken); - return result.data; + }) + setAccessToken(result.data.accessToken) + return result.data } export async function refresh() { const result = await request('/api/v1/auth/refresh', undefined, { method: 'POST', skipAuth: true, - }); - setAccessToken(result.data.accessToken); - return result.data; + }) + setAccessToken(result.data.accessToken) + return result.data } export async function logout() { const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, { method: 'POST', - }); - setAccessToken(null); - return result.data; + }) + setAccessToken(null) + return result.data } export async function getMe() { - const result = await request('/api/v1/auth/me'); - return result.data; + const result = await request('/api/v1/auth/me') + return result.data } export async function updateProfile(data: { name?: string }) { const result = await request('/api/v1/auth/me', undefined, { method: 'PATCH', body: data, - }); - return result.data; + }) + return result.data } diff --git a/apps/frontend/src/entities/session/api/tokenManager.ts b/apps/frontend/src/entities/session/api/tokenManager.ts index 7b97b8c..a77df65 100644 --- a/apps/frontend/src/entities/session/api/tokenManager.ts +++ b/apps/frontend/src/entities/session/api/tokenManager.ts @@ -1,21 +1,21 @@ -import type { AuthResponse } from '@/shared/api/responses'; -import { normalizeEnvelope } from '@/shared/api/client'; +import type { AuthResponse } from '@/shared/api' +import { normalizeEnvelope } from '@/shared/api/kyClient' -let accessToken: string | null = null; -let onUnauthorized: (() => void) | null = null; -let isRefreshing = false; -let refreshPromise: Promise | null = null; +let accessToken: string | null = null +let onUnauthorized: (() => void) | null = null +let isRefreshing = false +let refreshPromise: Promise | null = null export function setAccessToken(token: string | null) { - accessToken = token; + accessToken = token } export function getAccessToken(): string | null { - return accessToken; + return accessToken } export function setOnUnauthorized(cb: () => void) { - onUnauthorized = cb; + onUnauthorized = cb } async function refreshTokens(): Promise { @@ -23,31 +23,31 @@ async function refreshTokens(): Promise { const res = await fetch('/api/v1/auth/refresh', { method: 'POST', credentials: 'include', - }); - if (!res.ok) return false; - const json = await res.json(); - accessToken = normalizeEnvelope(json).data.accessToken; - return true; + }) + if (!res.ok) return false + const json = await res.json() + accessToken = normalizeEnvelope(json).data.accessToken + return true } catch { - return false; + return false } } export async function handleUnauthorized(): Promise { if (isRefreshing && refreshPromise) { - return refreshPromise; + return refreshPromise } - isRefreshing = true; + isRefreshing = true refreshPromise = refreshTokens().then((success) => { - isRefreshing = false; - refreshPromise = null; + isRefreshing = false + refreshPromise = null if (!success) { - accessToken = null; - onUnauthorized?.(); + accessToken = null + onUnauthorized?.() } - return success; - }); + return success + }) - return refreshPromise; + return refreshPromise } diff --git a/apps/frontend/src/entities/session/index.ts b/apps/frontend/src/entities/session/index.ts index 13c5c49..bca9697 100644 --- a/apps/frontend/src/entities/session/index.ts +++ b/apps/frontend/src/entities/session/index.ts @@ -1,3 +1,4 @@ -export { login, register, refresh, logout, getMe, updateProfile } from './api/sessionApi'; -export { SessionContext, type SessionContextValue } from './model/sessionContext'; -export { useSession } from './model/useSession'; +export { getMe, login, logout, refresh, register, updateProfile } from './api/sessionApi' +export { SessionContext, type SessionContextValue } from './model/sessionContext' +export { useSession } from './model/useSession' +export { useSessionStore } from './model/useSessionStore' diff --git a/apps/frontend/src/entities/session/model/sessionContext.ts b/apps/frontend/src/entities/session/model/sessionContext.ts index eddf339..8638f3e 100644 --- a/apps/frontend/src/entities/session/model/sessionContext.ts +++ b/apps/frontend/src/entities/session/model/sessionContext.ts @@ -1 +1 @@ -export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context'; +export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context' diff --git a/apps/frontend/src/entities/session/model/useSession.test.tsx b/apps/frontend/src/entities/session/model/useSession.test.tsx index 204b815..f9477d8 100644 --- a/apps/frontend/src/entities/session/model/useSession.test.tsx +++ b/apps/frontend/src/entities/session/model/useSession.test.tsx @@ -1,20 +1,20 @@ -import { describe, it, expect } from 'vitest'; -import { renderHook } from '@testing-library/react'; -import { SessionContext } from './sessionContext'; -import { useSession } from './useSession'; -import type { ReactNode } from 'react'; +import { renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import { SessionContext } from './sessionContext' +import { useSession } from './useSession' type SessionState = { - user: { id: number; email: string; name: string; role: string } | null; - accessToken: string | null; - isAuthenticated: boolean; - isLoading: boolean; - login: () => Promise; - logout: () => Promise; - register: () => Promise; - updateProfile: () => Promise; - refreshSession: () => Promise; -}; + user: { id: number; email: string; name: string; role: string } | null + accessToken: string | null + isAuthenticated: boolean + isLoading: boolean + login: () => Promise + logout: () => Promise + register: () => Promise + updateProfile: () => Promise + refreshSession: () => Promise +} const mockSession: SessionState = { user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' }, @@ -26,34 +26,34 @@ const mockSession: SessionState = { register: vi.fn().mockResolvedValue(undefined), updateProfile: vi.fn().mockResolvedValue(undefined), refreshSession: vi.fn().mockResolvedValue(undefined), -}; +} function createWrapper(session: SessionState = mockSession) { return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } describe('useSession', () => { it('returns session context with user', () => { - const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }); - expect(result.current.isAuthenticated).toBe(true); - expect(result.current.user?.email).toBe('user@test.com'); - expect(result.current.accessToken).toBe('mock-access-token'); - }); + const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }) + expect(result.current.isAuthenticated).toBe(true) + expect(result.current.user?.email).toBe('user@test.com') + expect(result.current.accessToken).toBe('mock-access-token') + }) it('provides login function', () => { - const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }); - expect(typeof result.current.login).toBe('function'); - }); + const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }) + expect(typeof result.current.login).toBe('function') + }) it('provides logout function', () => { - const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }); - expect(typeof result.current.logout).toBe('function'); - }); + const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }) + expect(typeof result.current.logout).toBe('function') + }) it('provides register function', () => { - const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }); - expect(typeof result.current.register).toBe('function'); - }); -}); + const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }) + expect(typeof result.current.register).toBe('function') + }) +}) diff --git a/apps/frontend/src/entities/session/model/useSession.ts b/apps/frontend/src/entities/session/model/useSession.ts index 932eb6e..0a049f9 100644 --- a/apps/frontend/src/entities/session/model/useSession.ts +++ b/apps/frontend/src/entities/session/model/useSession.ts @@ -1,10 +1,10 @@ -import { useContext } from 'react'; -import { SessionContext, type SessionContextValue } from './sessionContext'; +import { useContext } from 'react' +import { SessionContext, type SessionContextValue } from './sessionContext' export function useSession(): SessionContextValue { - const ctx = useContext(SessionContext); + const ctx = useContext(SessionContext) if (!ctx) { - throw new Error('useSession must be used within a SessionProvider'); + throw new Error('useSession must be used within a SessionProvider') } - return ctx; + return ctx } diff --git a/apps/frontend/src/entities/session/model/useSessionStore.ts b/apps/frontend/src/entities/session/model/useSessionStore.ts index ab1b820..d2f6c5c 100644 --- a/apps/frontend/src/entities/session/model/useSessionStore.ts +++ b/apps/frontend/src/entities/session/model/useSessionStore.ts @@ -1,14 +1,14 @@ -import { create } from 'zustand'; -import type { UserResponse } from '@/shared/api/responses'; +import { create } from 'zustand' +import type { UserResponse } from '@/shared/api' interface SessionState { - user: UserResponse | null; - accessToken: string | null; - isLoading: boolean; - isAuthenticated: boolean; - setSession: (authData: { user: UserResponse; accessToken: string }) => void; - clearSession: () => void; - setLoading: (isLoading: boolean) => void; + user: UserResponse | null + accessToken: string | null + isLoading: boolean + isAuthenticated: boolean + setSession: (authData: { user: UserResponse; accessToken: string }) => void + clearSession: () => void + setLoading: (isLoading: boolean) => void } export const useSessionStore = create((set) => ({ @@ -22,7 +22,7 @@ export const useSessionStore = create((set) => ({ accessToken: authData.accessToken, isAuthenticated: true, isLoading: false, - }); + }) }, clearSession: () => { set({ @@ -30,9 +30,9 @@ export const useSessionStore = create((set) => ({ accessToken: null, isAuthenticated: false, isLoading: false, - }); + }) }, setLoading: (isLoading) => { - set({ isLoading }); + set({ isLoading }) }, -})); +})) diff --git a/apps/frontend/src/entities/stock/api/stockApi.ts b/apps/frontend/src/entities/stock/api/stockApi.ts index 8831851..8debd80 100644 --- a/apps/frontend/src/entities/stock/api/stockApi.ts +++ b/apps/frontend/src/entities/stock/api/stockApi.ts @@ -1,15 +1,15 @@ -import { request } from '@/shared/api/client'; import type { ApiResponseMeta, - ShareResponse, - StockMarketData, + CandleItem, DividendItem, ShareHistoryItem, - CandleItem, -} from '@/shared/api/responses'; + ShareResponse, + StockMarketData, +} from '@/shared/api' +import { request } from '@/shared/api/kyClient' export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> { - return request(`/api/v1/securities/shares/${encodeURIComponent(secid)}`); + return request(`/api/v1/securities/shares/${encodeURIComponent(secid)}`) } export function getShareMarketData( @@ -17,15 +17,13 @@ export function getShareMarketData( ): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> { return request( `/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`, - ); + ) } export function getShareDividends( secid: string, ): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> { - return request( - `/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`, - ); + return request(`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`) } export function getShareHistory( @@ -36,7 +34,7 @@ export function getShareHistory( return request( `/api/v1/securities/shares/${encodeURIComponent(secid)}/history`, { from, till }, - ); + ) } export function getShareCandles( @@ -49,5 +47,5 @@ export function getShareCandles( interval, from, till, - }); + }) } diff --git a/apps/frontend/src/entities/stock/index.ts b/apps/frontend/src/entities/stock/index.ts index fb2bead..59ddab7 100644 --- a/apps/frontend/src/entities/stock/index.ts +++ b/apps/frontend/src/entities/stock/index.ts @@ -1,3 +1,3 @@ -export { useStock } from './model/useStock'; -export { useStockCandles } from './model/useStockCandles'; -export { useStockDividends } from './model/useStockDividends'; +export { useStock } from './model/useStock' +export { useStockCandles } from './model/useStockCandles' +export { useStockDividends } from './model/useStockDividends' diff --git a/apps/frontend/src/entities/stock/model/useStock.test.tsx b/apps/frontend/src/entities/stock/model/useStock.test.tsx index 2a127fe..5b75bff 100644 --- a/apps/frontend/src/entities/stock/model/useStock.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStock.test.tsx @@ -1,32 +1,32 @@ -import { describe, it, expect } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useStock } from './useStock'; -import { type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import { useStock } from './useStock' function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } describe('useStock', () => { it('returns share data', async () => { - const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.secid).toBe('SBER'); - expect(result.current.data?.shortName).toBe('Сбер'); - expect(result.current.data?.marketData.price).toBe(289.5); - }); + const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.secid).toBe('SBER') + expect(result.current.data?.shortName).toBe('Сбер') + expect(result.current.data?.marketData.price).toBe(289.5) + }) it('returns error for not found', async () => { - const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() }); - await waitFor(() => expect(result.current.isError).toBe(true)); - }); + const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) it('starts in loading state', () => { - const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); - expect(result.current.isLoading).toBe(true); - }); -}); + const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }) + expect(result.current.isLoading).toBe(true) + }) +}) diff --git a/apps/frontend/src/entities/stock/model/useStock.ts b/apps/frontend/src/entities/stock/model/useStock.ts index c85b25c..7d88e61 100644 --- a/apps/frontend/src/entities/stock/model/useStock.ts +++ b/apps/frontend/src/entities/stock/model/useStock.ts @@ -1,14 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; -import { getShare } from '../api/stockApi'; -import type { ShareResponse } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { ShareResponse } from '@/shared/api' +import { getShare } from '../api/stockApi' export function useStock(secid: string) { return useQuery({ queryKey: ['stock', secid], queryFn: async () => { - const res = await getShare(secid); - return res.data; + const res = await getShare(secid) + return res.data }, staleTime: 900_000, - }); + }) } diff --git a/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx b/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx index 08d25ea..9bb2cd3 100644 --- a/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx @@ -1,18 +1,18 @@ -import { describe, it, expect } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { useStockCandles } from './useStockCandles'; -import { type ReactNode } from 'react'; +import { server } from '@mocks/server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { HttpResponse, http } from 'msw' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import { useStockCandles } from './useStockCandles' -const API = '/api/v1'; +const API = '/api/v1' function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } describe('useStockCandles', () => { @@ -20,25 +20,25 @@ describe('useStockCandles', () => { const { result } = renderHook( () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), { wrapper: createWrapper() }, - ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toHaveLength(2); - expect(result.current.data?.[0].open).toBe(280); - }); + ) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toHaveLength(2) + expect(result.current.data?.[0].open).toBe(280) + }) it('returns empty array when no candles', async () => { server.use( http.get(`${API}/securities/shares/:secid/candles`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } }, - }); + }) }), - ); + ) const { result } = renderHook( () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), { wrapper: createWrapper() }, - ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([]); - }); -}); + ) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual([]) + }) +}) diff --git a/apps/frontend/src/entities/stock/model/useStockCandles.ts b/apps/frontend/src/entities/stock/model/useStockCandles.ts index 297ba29..d2887b5 100644 --- a/apps/frontend/src/entities/stock/model/useStockCandles.ts +++ b/apps/frontend/src/entities/stock/model/useStockCandles.ts @@ -1,14 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; -import { getShareCandles } from '../api/stockApi'; -import type { CandleItem } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { CandleItem } from '@/shared/api' +import { getShareCandles } from '../api/stockApi' export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { return useQuery({ queryKey: ['stockCandles', secid, interval, from, till], queryFn: async () => { - const res = await getShareCandles(secid, interval, from, till); - return res.data; + const res = await getShareCandles(secid, interval, from, till) + return res.data }, staleTime: 3600_000, - }); + }) } diff --git a/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx b/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx index fc2ef23..7d8415c 100644 --- a/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx @@ -1,38 +1,38 @@ -import { describe, it, expect } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { useStockDividends } from './useStockDividends'; -import { type ReactNode } from 'react'; +import { server } from '@mocks/server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { HttpResponse, http } from 'msw' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import { useStockDividends } from './useStockDividends' -const API = '/api/v1'; +const API = '/api/v1' function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } describe('useStockDividends', () => { it('returns dividend data', async () => { - const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toHaveLength(2); - expect(result.current.data?.[0].value).toBe(35); - }); + const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toHaveLength(2) + expect(result.current.data?.[0].value).toBe(35) + }) it('returns empty array when no dividends', async () => { server.use( http.get(`${API}/securities/shares/:secid/dividends`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } }, - }); + }) }), - ); - const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([]); - }); -}); + ) + const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual([]) + }) +}) diff --git a/apps/frontend/src/entities/stock/model/useStockDividends.ts b/apps/frontend/src/entities/stock/model/useStockDividends.ts index 82c9a5c..2bb4f92 100644 --- a/apps/frontend/src/entities/stock/model/useStockDividends.ts +++ b/apps/frontend/src/entities/stock/model/useStockDividends.ts @@ -1,14 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; -import { getShareDividends } from '../api/stockApi'; -import type { DividendItem } from '@/shared/api/responses'; +import { useQuery } from '@tanstack/react-query' +import type { DividendItem } from '@/shared/api' +import { getShareDividends } from '../api/stockApi' export function useStockDividends(secid: string) { return useQuery({ queryKey: ['stockDividends', secid], queryFn: async () => { - const res = await getShareDividends(secid); - return res.data; + const res = await getShareDividends(secid) + return res.data }, staleTime: 86400_000, - }); + }) } diff --git a/apps/frontend/src/features/add-position/api/useAddPosition.ts b/apps/frontend/src/features/add-position/api/useAddPosition.ts index 1171003..d605974 100644 --- a/apps/frontend/src/features/add-position/api/useAddPosition.ts +++ b/apps/frontend/src/features/add-position/api/useAddPosition.ts @@ -1,6 +1,6 @@ -import { usePositionMutations } from '@/entities/portfolio'; +import { usePositionMutations } from '@/entities/portfolio' export function useAddPosition(portfolioId: number) { - const { add } = usePositionMutations(portfolioId); - return add; + const { add } = usePositionMutations(portfolioId) + return add } diff --git a/apps/frontend/src/features/add-position/index.ts b/apps/frontend/src/features/add-position/index.ts index 9c5f41a..1d90cd5 100644 --- a/apps/frontend/src/features/add-position/index.ts +++ b/apps/frontend/src/features/add-position/index.ts @@ -1 +1 @@ -export { AddPositionForm } from './ui/AddPositionForm'; +export { AddPositionForm } from './ui/AddPositionForm' diff --git a/apps/frontend/src/features/add-position/model/useAddPositionForm.ts b/apps/frontend/src/features/add-position/model/useAddPositionForm.ts index 7b3b650..97530da 100644 --- a/apps/frontend/src/features/add-position/model/useAddPositionForm.ts +++ b/apps/frontend/src/features/add-position/model/useAddPositionForm.ts @@ -1,17 +1,17 @@ -import { useState } from 'react'; +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]); + 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]); + setNewSecid('') + setNewQty('1') + setNewPrice('') + setNewDate(new Date().toISOString().split('T')[0]) } return { @@ -26,5 +26,5 @@ export function useAddPositionForm() { newDate, setNewDate, reset, - }; + } } diff --git a/apps/frontend/src/features/add-position/ui/AddPositionForm.tsx b/apps/frontend/src/features/add-position/ui/AddPositionForm.tsx index ed57feb..9b73273 100644 --- a/apps/frontend/src/features/add-position/ui/AddPositionForm.tsx +++ b/apps/frontend/src/features/add-position/ui/AddPositionForm.tsx @@ -1,19 +1,19 @@ -import { useAddPosition } from '../api/useAddPosition'; -import { useAddPositionForm } from '../model/useAddPositionForm'; +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(); + const addPosition = useAddPosition(portfolioId) + const form = useAddPositionForm() function handleAddPosition() { - if (!form.newSecid.trim() || !parseInt(form.newQty, 10)) return; + if (!form.newSecid.trim() || !parseInt(form.newQty, 10)) return addPosition.mutate( { secid: form.newSecid.trim().toUpperCase(), @@ -23,11 +23,11 @@ export function AddPositionForm({ portfolioId }: { portfolioId: number }) { }, { onSuccess: () => { - form.setShowAddForm(false); - form.reset(); + form.setShowAddForm(false) + form.reset() }, }, - ); + ) } return ( @@ -107,5 +107,5 @@ export function AddPositionForm({ portfolioId }: { portfolioId: number }) { Добавить - ); + ) } diff --git a/apps/frontend/src/features/screener/api/screenerApi.ts b/apps/frontend/src/features/screener/api/screenerApi.ts index c8a9c7c..e0dd3d0 100644 --- a/apps/frontend/src/features/screener/api/screenerApi.ts +++ b/apps/frontend/src/features/screener/api/screenerApi.ts @@ -1,40 +1,40 @@ -import { request } from '@/shared/api/client'; -import type { ScreenerResult } from '@/shared/api/responses'; +import type { ScreenerResult } from '@/shared/api' +import { request } from '@/shared/api/kyClient' export interface ScreenerQuery { - type: 'share' | 'bond'; - priceMin?: number; - priceMax?: number; - volumeMin?: number; - listLevel?: number; - changePercentMin?: number; - changePercentMax?: number; - capitalizationMin?: number; - yieldMin?: number; - yieldMax?: number; - durationMin?: number; - durationMax?: number; - couponMin?: number; - couponMax?: number; - couponPercentMin?: number; - couponPercentMax?: number; - maturityBefore?: string; - maturityAfter?: string; - bondType?: string; - sortBy?: string; - sortOrder?: 'asc' | 'desc'; - page?: number; - pageSize?: number; + type: 'share' | 'bond' + priceMin?: number + priceMax?: number + volumeMin?: number + listLevel?: number + changePercentMin?: number + changePercentMax?: number + capitalizationMin?: number + yieldMin?: number + yieldMax?: number + durationMin?: number + durationMax?: number + couponMin?: number + couponMax?: number + couponPercentMin?: number + couponPercentMax?: number + maturityBefore?: string + maturityAfter?: string + bondType?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' + page?: number + pageSize?: number } export function getScreenerResults( params: ScreenerQuery, ): Promise<{ data: ScreenerResult; meta: { cachedAt: string | null; fromCache: boolean } }> { - const query: Record = {}; + const query: Record = {} Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { - query[key] = String(value); + query[key] = String(value) } - }); - return request('/api/v1/securities/screener', query); + }) + return request('/api/v1/securities/screener', query) } diff --git a/apps/frontend/src/features/screener/index.ts b/apps/frontend/src/features/screener/index.ts index 174c184..d8a893d 100644 --- a/apps/frontend/src/features/screener/index.ts +++ b/apps/frontend/src/features/screener/index.ts @@ -1,3 +1,3 @@ -export { useScreener } from './model/useScreener'; -export { FilterPanel, FilterPanelShare, FilterPanelBond, ScreenerTable } from './ui'; -export type { ScreenerQuery } from './api/screenerApi'; +export type { ScreenerQuery } from './api/screenerApi' +export { useScreener } from './model/useScreener' +export { FilterPanel, FilterPanelBond, FilterPanelShare, ScreenerTable } from './ui' diff --git a/apps/frontend/src/features/screener/model/index.ts b/apps/frontend/src/features/screener/model/index.ts index 65fee28..6e5ae71 100644 --- a/apps/frontend/src/features/screener/model/index.ts +++ b/apps/frontend/src/features/screener/model/index.ts @@ -1 +1 @@ -export { useScreener } from './useScreener'; +export { useScreener } from './useScreener' diff --git a/apps/frontend/src/features/screener/model/useScreener.ts b/apps/frontend/src/features/screener/model/useScreener.ts index 9eea5a0..1ca58f6 100644 --- a/apps/frontend/src/features/screener/model/useScreener.ts +++ b/apps/frontend/src/features/screener/model/useScreener.ts @@ -1,10 +1,10 @@ -import { useSearchParams } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { getScreenerResults } from '../api/screenerApi'; -import type { ScreenerQuery } from '../api/screenerApi'; +import { useQuery } from '@tanstack/react-query' +import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams' +import type { ScreenerQuery } from '../api/screenerApi' +import { getScreenerResults } from '../api/screenerApi' export function useScreener() { - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParamsCompat() const params: ScreenerQuery = { type: (searchParams.get('type') as 'share' | 'bond') || 'share', @@ -44,9 +44,9 @@ export function useScreener() { sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined, page: searchParams.get('page') ? Number(searchParams.get('page')) : undefined, pageSize: searchParams.get('pageSize') ? Number(searchParams.get('pageSize')) : undefined, - }; + } - const queryKey = ['screener', params]; + const queryKey = ['screener', params] const query = useQuery({ queryKey, @@ -55,62 +55,62 @@ export function useScreener() { retry: 2, refetchOnWindowFocus: false, placeholderData: (previousData) => previousData, - }); + }) function setParam(key: string, value: string | undefined) { setSearchParams((prev) => { - const next = new URLSearchParams(prev); + const next = new URLSearchParams(prev) if (value === undefined || value === '') { - next.delete(key); + next.delete(key) } else { - next.set(key, value); + next.set(key, value) } - next.set('page', '1'); - return next; - }); + next.set('page', '1') + return next + }) } function setFilters(filters: Partial) { setSearchParams((prev) => { - const next = new URLSearchParams(prev); + const next = new URLSearchParams(prev) Object.entries(filters).forEach(([key, value]) => { if (value === undefined || value === null || value === '') { - next.delete(key); + next.delete(key) } else { - next.set(key, String(value)); + next.set(key, String(value)) } - }); - next.set('page', '1'); - return next; - }); + }) + next.set('page', '1') + return next + }) } function setPage(page: number) { setSearchParams((prev) => { - const next = new URLSearchParams(prev); - next.set('page', String(page)); - return next; - }); + const next = new URLSearchParams(prev) + next.set('page', String(page)) + return next + }) } function setSort(sortBy: string) { setSearchParams((prev) => { - const next = new URLSearchParams(prev); - const current = next.get('sortBy'); - const currentOrder = next.get('sortOrder') || 'asc'; + const next = new URLSearchParams(prev) + const current = next.get('sortBy') + const currentOrder = next.get('sortOrder') || 'asc' if (current === sortBy) { - next.set('sortOrder', currentOrder === 'asc' ? 'desc' : 'asc'); + next.set('sortOrder', currentOrder === 'asc' ? 'desc' : 'asc') } else { - next.set('sortBy', sortBy); - next.set('sortOrder', 'asc'); + next.set('sortBy', sortBy) + next.set('sortOrder', 'asc') } - next.set('page', '1'); - return next; - }); + next.set('page', '1') + return next + }) } function resetFilters() { - setSearchParams(new URLSearchParams({ type: params.type })); + setSearchParams(new URLSearchParams({ type: params.type })) } return { @@ -123,5 +123,5 @@ export function useScreener() { setSort, setParam, resetFilters, - }; + } } diff --git a/apps/frontend/src/features/screener/ui/FilterPanel.tsx b/apps/frontend/src/features/screener/ui/FilterPanel.tsx index b91b2b8..32d2e37 100644 --- a/apps/frontend/src/features/screener/ui/FilterPanel.tsx +++ b/apps/frontend/src/features/screener/ui/FilterPanel.tsx @@ -1,36 +1,36 @@ -import { useState } from 'react'; -import type { ScreenerQuery } from '../api/screenerApi'; -import { FilterPanelShare } from './FilterPanelShare'; -import { FilterPanelBond } from './FilterPanelBond'; +import { useState } from 'react' +import type { ScreenerQuery } from '../api/screenerApi' +import { FilterPanelBond } from './FilterPanelBond' +import { FilterPanelShare } from './FilterPanelShare' interface Props { - params: ScreenerQuery; - onApply: (filters: Partial) => void; - onReset: () => void; + params: ScreenerQuery + onApply: (filters: Partial) => void + onReset: () => void } export function FilterPanel({ params, onApply, onReset }: Props) { - const [type, setType] = useState<'share' | 'bond'>(params.type); - const [local, setLocal] = useState>({}); + const [type, setType] = useState<'share' | 'bond'>(params.type) + const [local, setLocal] = useState>({}) function handleApply() { - const filters: Partial = { type }; + const filters: Partial = { type } Object.entries(local).forEach(([key, value]) => { if (value !== '') { - const num = Number(value); - filters[key as keyof ScreenerQuery] = isNaN(num) ? (value as any) : (num as any); + const num = Number(value) + filters[key as keyof ScreenerQuery] = Number.isNaN(num) ? (value as any) : (num as any) } - }); - onApply(filters); + }) + onApply(filters) } function handleReset() { - setLocal({}); - onReset(); + setLocal({}) + onReset() } function updateField(key: string, value: string) { - setLocal((prev) => ({ ...prev, [key]: value })); + setLocal((prev) => ({ ...prev, [key]: value })) } return ( @@ -104,5 +104,5 @@ export function FilterPanel({ params, onApply, onReset }: Props) { - ); + ) } diff --git a/apps/frontend/src/features/screener/ui/FilterPanelBond.tsx b/apps/frontend/src/features/screener/ui/FilterPanelBond.tsx index c8382e9..1402e6e 100644 --- a/apps/frontend/src/features/screener/ui/FilterPanelBond.tsx +++ b/apps/frontend/src/features/screener/ui/FilterPanelBond.tsx @@ -1,7 +1,7 @@ interface Props { - params: Record; - local: Record; - updateField: (key: string, value: string) => void; + params: Record + local: Record + updateField: (key: string, value: string) => void } export function FilterPanelBond({ local, updateField }: Props) { @@ -16,7 +16,7 @@ export function FilterPanelBond({ local, updateField }: Props) { { key: 'couponMax', label: 'Купон (₽) до' }, { key: 'couponPercentMin', label: 'Купон % от' }, { key: 'couponPercentMax', label: 'Купон % до' }, - ]; + ] return (
@@ -49,5 +49,5 @@ export function FilterPanelBond({ local, updateField }: Props) {
))} - ); + ) } diff --git a/apps/frontend/src/features/screener/ui/FilterPanelShare.tsx b/apps/frontend/src/features/screener/ui/FilterPanelShare.tsx index 4af167b..2b708fd 100644 --- a/apps/frontend/src/features/screener/ui/FilterPanelShare.tsx +++ b/apps/frontend/src/features/screener/ui/FilterPanelShare.tsx @@ -1,7 +1,7 @@ interface Props { - params: Record; - local: Record; - updateField: (key: string, value: string) => void; + params: Record + local: Record + updateField: (key: string, value: string) => void } export function FilterPanelShare({ local, updateField }: Props) { @@ -12,7 +12,7 @@ export function FilterPanelShare({ local, updateField }: Props) { { key: 'changePercentMax', label: 'Изм. % до' }, { key: 'volumeMin', label: 'Объём от' }, { key: 'capitalizationMin', label: 'Капитализация от' }, - ]; + ] return (
@@ -45,5 +45,5 @@ export function FilterPanelShare({ local, updateField }: Props) {
))} - ); + ) } diff --git a/apps/frontend/src/features/screener/ui/ScreenerTable.tsx b/apps/frontend/src/features/screener/ui/ScreenerTable.tsx index 2998dbc..d5394ab 100644 --- a/apps/frontend/src/features/screener/ui/ScreenerTable.tsx +++ b/apps/frontend/src/features/screener/ui/ScreenerTable.tsx @@ -1,31 +1,31 @@ -import { Link } from 'react-router-dom'; -import type { ScreenerResult } from '@/shared/api/responses'; +import { Link } from '@tanstack/react-router' +import type { ScreenerResult } from '@/shared/api' interface Props { - result: ScreenerResult; - sortBy: string; - sortOrder: 'asc' | 'desc'; - onSort: (field: string) => void; - onPageChange: (page: number) => void; + result: ScreenerResult + sortBy: string + sortOrder: 'asc' | 'desc' + onSort: (field: string) => void + onPageChange: (page: number) => void } function formatNum(value: number | null | undefined, digits = 2): string { - if (value == null) return '—'; + if (value == null) return '—' return value.toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits, - }); + }) } function formatChange(value: number | null | undefined): { text: string; color: string } { - if (value == null) return { text: '—', color: 'inherit' }; - const color = value > 0 ? '#43a047' : value < 0 ? '#e53935' : 'inherit'; - return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }; + if (value == null) return { text: '—', color: 'inherit' } + const color = value > 0 ? '#43a047' : value < 0 ? '#e53935' : 'inherit' + return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color } } export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) { function SortHeader({ field, children }: { field: string; children: string }) { - const isActive = sortBy === field; + const isActive = sortBy === field return ( onSort(field)} @@ -42,10 +42,10 @@ export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange > {children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''} - ); + ) } - const isShare = result.items[0]?.type === 'share'; + const isShare = result.items[0]?.type === 'share' return (
@@ -95,8 +95,8 @@ export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange {result.items.map((item) => { - const change = formatChange(item.changePercent); - const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`; + const change = formatChange(item.changePercent) + const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}` return ( @@ -139,7 +139,7 @@ export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange )} - ); + ) })} @@ -167,5 +167,5 @@ export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange
)} - ); + ) } diff --git a/apps/frontend/src/features/screener/ui/index.ts b/apps/frontend/src/features/screener/ui/index.ts index 75910ff..bf95bd1 100644 --- a/apps/frontend/src/features/screener/ui/index.ts +++ b/apps/frontend/src/features/screener/ui/index.ts @@ -1,4 +1,4 @@ -export { FilterPanel } from './FilterPanel'; -export { FilterPanelShare } from './FilterPanelShare'; -export { FilterPanelBond } from './FilterPanelBond'; -export { ScreenerTable } from './ScreenerTable'; +export { FilterPanel } from './FilterPanel' +export { FilterPanelBond } from './FilterPanelBond' +export { FilterPanelShare } from './FilterPanelShare' +export { ScreenerTable } from './ScreenerTable' diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 1b8823c..78ab8cf 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,13 +1,23 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { AppProviders } from './app/providers/AppProviders'; -import App from './app/App'; -import './styles.css'; +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './app/App' +import { AppProviders } from './app/providers/AppProviders' +import './styles.css' +import { env } from './shared/config/env' -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - , -); +async function startApp() { + if (env.VITE_API_MOCK) { + const { worker } = await import('../mocks/browser') + await worker.start({ onUnhandledRequest: 'bypass' }) + } + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , + ) +} + +startApp() diff --git a/apps/frontend/src/pages/bond/index.ts b/apps/frontend/src/pages/bond/index.ts index ccfcab4..8dcf667 100644 --- a/apps/frontend/src/pages/bond/index.ts +++ b/apps/frontend/src/pages/bond/index.ts @@ -1 +1 @@ -export { BondPage } from './ui/BondPage'; +export { BondPage } from './ui/BondPage' diff --git a/apps/frontend/src/pages/bond/ui/BondPage.tsx b/apps/frontend/src/pages/bond/ui/BondPage.tsx index 62e2e15..6bba152 100644 --- a/apps/frontend/src/pages/bond/ui/BondPage.tsx +++ b/apps/frontend/src/pages/bond/ui/BondPage.tsx @@ -1,17 +1,17 @@ -import { useParams } from 'react-router-dom'; -import { useBond, useBondCandles } from '@/entities/bond'; -import { BondDetails } from '@/widgets/bond-details'; -import { PriceChart } from '@/widgets/price-chart'; +import { useParams } from '@tanstack/react-router' +import { useBond, useBondCandles } from '@/entities/bond' +import { BondDetails } from '@/widgets/bond-details' +import { PriceChart } from '@/widgets/price-chart' export function BondPage() { - const { secid } = useParams<{ secid: string }>(); - const { data: bond, isLoading, error } = useBond(secid!); - const till = new Date().toISOString().split('T')[0]; - const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - const { data: candles } = useBondCandles(secid!, '24h', from, till); + const { secid } = useParams<{ secid: string }>() + const { data: bond, isLoading, error } = useBond(secid!) + const till = new Date().toISOString().split('T')[0] + const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + const { data: candles } = useBondCandles(secid!, '24h', from, till) - if (isLoading) return
Загрузка...
; - if (error || !bond) return
Инструмент не найден
; + if (isLoading) return
Загрузка...
+ if (error || !bond) return
Инструмент не найден
return (
@@ -29,5 +29,5 @@ export function BondPage() {
- ); + ) } diff --git a/apps/frontend/src/pages/broker-account/index.ts b/apps/frontend/src/pages/broker-account/index.ts index 1db6b6b..c2e2913 100644 --- a/apps/frontend/src/pages/broker-account/index.ts +++ b/apps/frontend/src/pages/broker-account/index.ts @@ -1 +1 @@ -export { BrokerAccountOverviewPage } from './ui/BrokerAccountOverviewPage'; +export { BrokerAccountOverviewPage } from './ui/BrokerAccountOverviewPage' diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx index 2234a60..c5ae061 100644 --- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx +++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx @@ -1,24 +1,24 @@ -import { Link } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Text } from '@moex-vibe/design-system'; -import { useBrokerOperations } from '@/entities/broker-operation'; -import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; -import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'; -import { BrokerEventsOverview } from '@/widgets/broker-events-overview'; -import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; -import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview'; +import { Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link } from '@tanstack/react-router' +import { useBrokerOperations } from '@/entities/broker-operation' +import { useBrokerAccountContext } from '@/widgets/broker-account-layout' +import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart' +import { BrokerEventsOverview } from '@/widgets/broker-events-overview' +import { BrokerOperationsTable } from '@/widgets/broker-operations-table' +import { BrokerAssetCards, BrokerOverviewSkeleton, BrokerSummary } from '@/widgets/broker-overview' export function BrokerAccountOverviewPage() { - const { accountId, portfolio } = useBrokerAccountContext(); - const operations = useBrokerOperations(accountId, { limit: 5 }); + const { accountId, portfolio } = useBrokerAccountContext() + const operations = useBrokerOperations(accountId, { limit: 5 }) - if (portfolio.isLoading) return ; + if (portfolio.isLoading) return if (portfolio.error || !portfolio.data) { return ( Не удалось загрузить сводку счёта - ); + ) } return ( @@ -44,5 +44,5 @@ export function BrokerAccountOverviewPage() { /> )} - ); + ) } diff --git a/apps/frontend/src/pages/broker-accounts/index.ts b/apps/frontend/src/pages/broker-accounts/index.ts index a708da2..59e8550 100644 --- a/apps/frontend/src/pages/broker-accounts/index.ts +++ b/apps/frontend/src/pages/broker-accounts/index.ts @@ -1 +1 @@ -export { BrokerAccountsPage } from './ui/BrokerAccountsPage'; +export { BrokerAccountsPage } from './ui/BrokerAccountsPage' diff --git a/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx index ed655e5..0b0d672 100644 --- a/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx +++ b/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx @@ -1,12 +1,12 @@ -import { Box } from '@mui/material'; -import { EmptyState, Heading, Text } from '@moex-vibe/design-system'; +import { EmptyState, Heading, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' import { aggregateBrokerAccounts, - useBrokerAccounts, useBrokerAccountPortfolios, -} from '@/entities/broker-account'; -import { BrokerAccountCard } from '@/widgets/broker-account-card'; -import { BrokerAccountsSummary } from '@/widgets/broker-accounts-summary'; + useBrokerAccounts, +} from '@/entities/broker-account' +import { BrokerAccountCard } from '@/widgets/broker-account-card' +import { BrokerAccountsSummary } from '@/widgets/broker-accounts-summary' function BrokerAccountsPageSkeleton() { return ( @@ -42,20 +42,20 @@ function BrokerAccountsPageSkeleton() { ))} - ); + ) } export function BrokerAccountsPage() { - const { data: accounts, isLoading, error } = useBrokerAccounts(); - const safeAccounts = accounts ?? []; - const accountQueries = useBrokerAccountPortfolios(safeAccounts); + const { data: accounts, isLoading, error } = useBrokerAccounts() + const safeAccounts = accounts ?? [] + const accountQueries = useBrokerAccountPortfolios(safeAccounts) if (isLoading) { - return ; + return } if (error) { - return Не удалось загрузить счета; + return Не удалось загрузить счета } if (safeAccounts.length === 0) { @@ -72,17 +72,17 @@ export function BrokerAccountsPage() { description="После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу." /> - ); + ) } const successfulPortfolios = accountQueries .map(({ query }) => query.data) - .filter((portfolio): portfolio is NonNullable => Boolean(portfolio)); + .filter((portfolio): portfolio is NonNullable => Boolean(portfolio)) const loadingCount = accountQueries.filter( ({ query }) => (query.isLoading || query.isPending || query.isFetching) && !query.data, - ).length; - const availableCount = successfulPortfolios.length; - const aggregate = aggregateBrokerAccounts(successfulPortfolios); + ).length + const availableCount = successfulPortfolios.length + const aggregate = aggregateBrokerAccounts(successfulPortfolios) return ( @@ -112,11 +112,11 @@ export function BrokerAccountsPage() { isLoading={(query.isLoading || query.isPending || query.isFetching) && !query.data} error={(query.error as Error | null) ?? null} onRetry={() => { - void query.refetch(); + void query.refetch() }} /> ))} - ); + ) } diff --git a/apps/frontend/src/pages/broker-events/index.ts b/apps/frontend/src/pages/broker-events/index.ts index f1100d7..1db6292 100644 --- a/apps/frontend/src/pages/broker-events/index.ts +++ b/apps/frontend/src/pages/broker-events/index.ts @@ -1 +1 @@ -export { BrokerEventsPage } from './ui/BrokerEventsPage'; +export { BrokerEventsPage } from './ui/BrokerEventsPage' diff --git a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx index 1e0a7ab..17d6006 100644 --- a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx +++ b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx @@ -1,23 +1,34 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React, { type ReactNode } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BrokerEventsPage } from './BrokerEventsPage'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BrokerEventsPage } from './BrokerEventsPage' + +const mockSetSearchParams = vi.fn() +const mockSearchParams = new URLSearchParams() vi.mock('@/entities/broker-event', () => ({ useBrokerEvents: vi.fn(), -})); +})) vi.mock('@/widgets/broker-account-layout', () => ({ useBrokerAccountContext: () => ({ accountId: 'acc-1', portfolio: null }), -})); +})) -const mockSetSearchParams = vi.fn(); -let currentSearchParams = new URLSearchParams(); -vi.mock('react-router-dom', () => ({ - useSearchParams: () => [currentSearchParams, mockSetSearchParams], -})); +vi.mock('@/shared/lib/router/useSearchParams', () => ({ + useSearchParamsCompat: () => [mockSearchParams, mockSetSearchParams], +})) + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router') + return { + ...actual, + useNavigate: () => vi.fn(), + Link: actual.Link, + Outlet: actual.Outlet, + } +}) vi.mock('@moex-vibe/design-system', () => ({ Button: ({ children, onClick, disabled }: any) => ( @@ -42,16 +53,16 @@ vi.mock('@moex-vibe/design-system', () => ({ type={props.type || 'text'} /> ), -})); +})) -import { useBrokerEvents } from '@/entities/broker-event'; +import { useBrokerEvents } from '@/entities/broker-event' function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return {children}; - }; + return {children} + } } const mockData = { @@ -124,13 +135,15 @@ const mockData = { estimateMode: null, }, ], -}; +} describe('BrokerEventsPage', () => { beforeEach(() => { - vi.clearAllMocks(); - currentSearchParams = new URLSearchParams(); - }); + vi.clearAllMocks() + mockSearchParams.delete('from') + mockSearchParams.delete('to') + mockSearchParams.delete('types') + }) it('renders loading state', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -158,11 +171,11 @@ describe('BrokerEventsPage', () => { promise: new Promise(() => {}), status: 'pending', fetchStatus: 'fetching', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); - expect(screen.getByText('Загрузка событий…')).toBeInTheDocument(); - }); + render(, { wrapper: createWrapper() }) + expect(screen.getByText('Загрузка событий…')).toBeInTheDocument() + }) it('renders error state', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -190,11 +203,11 @@ describe('BrokerEventsPage', () => { promise: new Promise(() => {}), status: 'error', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); - expect(screen.getByText('Не удалось загрузить календарь событий')).toBeInTheDocument(); - }); + render(, { wrapper: createWrapper() }) + expect(screen.getByText('Не удалось загрузить календарь событий')).toBeInTheDocument() + }) it('renders empty state', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -252,11 +265,11 @@ describe('BrokerEventsPage', () => { }), status: 'success', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); - expect(screen.getByText('В выбранном диапазоне событий нет')).toBeInTheDocument(); - }); + render(, { wrapper: createWrapper() }) + expect(screen.getByText('В выбранном диапазоне событий нет')).toBeInTheDocument() + }) it('renders date range inputs', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -284,12 +297,12 @@ describe('BrokerEventsPage', () => { promise: Promise.resolve(mockData), status: 'success', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); - expect(screen.getByLabelText('С')).toBeInTheDocument(); - expect(screen.getByLabelText('По')).toBeInTheDocument(); - }); + render(, { wrapper: createWrapper() }) + expect(screen.getByLabelText('С')).toBeInTheDocument() + expect(screen.getByLabelText('По')).toBeInTheDocument() + }) it('renders events heading, summary and table', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -317,33 +330,31 @@ describe('BrokerEventsPage', () => { promise: Promise.resolve(mockData), status: 'success', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); + render(, { wrapper: createWrapper() }) - expect(screen.getByText('События')).toBeInTheDocument(); - expect(screen.getByText('Событий')).toBeInTheDocument(); - expect(screen.getByText('3')).toBeInTheDocument(); - expect(screen.getByText('Ближайшее')).toBeInTheDocument(); - expect(screen.getByText('Прогноз выплат')).toBeInTheDocument(); - expect(screen.getByText('Дивиденд')).toBeInTheDocument(); - expect(screen.getByText('Купон')).toBeInTheDocument(); - expect(screen.getByText('Погашение')).toBeInTheDocument(); - expect(screen.getByText('SBER')).toBeInTheDocument(); - expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument(); - expect(screen.getByText('VTBR')).toBeInTheDocument(); - expect(screen.getByText('Прогноз выплат')).toBeInTheDocument(); - expect(screen.getAllByText('Поступило').length).toBeGreaterThan(0); - expect(screen.getByText('Факт')).toBeInTheDocument(); - expect(screen.getAllByText('Прогноз').length).toBeGreaterThan(0); - }); + expect(screen.getByText('События')).toBeInTheDocument() + expect(screen.getByText('Событий')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('Ближайшее')).toBeInTheDocument() + expect(screen.getByText('Прогноз выплат')).toBeInTheDocument() + expect(screen.getByText('Дивиденд')).toBeInTheDocument() + expect(screen.getByText('Купон')).toBeInTheDocument() + expect(screen.getByText('Погашение')).toBeInTheDocument() + expect(screen.getByText('SBER')).toBeInTheDocument() + expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument() + expect(screen.getByText('VTBR')).toBeInTheDocument() + expect(screen.getByText('Прогноз выплат')).toBeInTheDocument() + expect(screen.getAllByText('Поступило').length).toBeGreaterThan(0) + expect(screen.getByText('Факт')).toBeInTheDocument() + expect(screen.getAllByText('Прогноз').length).toBeGreaterThan(0) + }) it('keeps date and type changes as draft until applying filters', async () => { - currentSearchParams = new URLSearchParams({ - from: '2026-06-15', - to: '2026-06-29', - types: 'dividend,coupon', - }); + mockSearchParams.set('from', '2026-06-15') + mockSearchParams.set('to', '2026-06-29') + mockSearchParams.set('types', 'dividend,coupon') vi.mocked(useBrokerEvents).mockReturnValue({ data: mockData, isLoading: false, @@ -369,21 +380,21 @@ describe('BrokerEventsPage', () => { promise: Promise.resolve(mockData), status: 'success', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); + render(, { wrapper: createWrapper() }) - await userEvent.clear(screen.getByLabelText('С')); - await userEvent.type(screen.getByLabelText('С'), '2026-06-10'); - await userEvent.click(screen.getByLabelText('Купоны')); + await userEvent.clear(screen.getByLabelText('С')) + await userEvent.type(screen.getByLabelText('С'), '2026-06-10') + await userEvent.click(screen.getByLabelText('Купоны')) - expect(mockSetSearchParams).not.toHaveBeenCalled(); + expect(mockSetSearchParams).not.toHaveBeenCalled() - await userEvent.click(screen.getByRole('button', { name: 'Показать' })); + await userEvent.click(screen.getByRole('button', { name: 'Показать' })) - const applied = mockSetSearchParams.mock.calls[0][0] as URLSearchParams; - expect(applied.get('from')).toBe('2026-06-10'); - expect(applied.get('to')).toBe('2026-06-29'); - expect(applied.get('types')).toBe('dividend'); - }); -}); + const applied = mockSetSearchParams.mock.calls[0][0] as URLSearchParams + expect(applied.get('from')).toBe('2026-06-10') + expect(applied.get('to')).toBe('2026-06-29') + expect(applied.get('types')).toBe('dividend') + }) +}) diff --git a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx index 112d724..8e3c251 100644 --- a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx +++ b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx @@ -1,122 +1,122 @@ -import { useState, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Button, Checkbox, Chip, Heading, Text, TextField } from '@moex-vibe/design-system'; -import dayjs from 'dayjs'; -import { useBrokerEvents } from '@/entities/broker-event'; -import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; -import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'; +import { Button, Checkbox, Chip, Heading, Text, TextField } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import dayjs from 'dayjs' +import { useEffect, useState } from 'react' +import { useBrokerEvents } from '@/entities/broker-event' +import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' +import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams' +import { useBrokerAccountContext } from '@/widgets/broker-account-layout' -const EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const; +const EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const -type EventType = (typeof EVENT_TYPES)[number]; +type EventType = (typeof EVENT_TYPES)[number] type Filters = { - from: string; - to: string; - types: EventType[]; -}; + from: string + to: string + types: EventType[] +} const EVENT_TYPE_OPTIONS: { value: EventType; label: string }[] = [ { value: 'dividend', label: 'Дивиденды' }, { value: 'coupon', label: 'Купоны' }, { value: 'maturity', label: 'Погашения' }, { value: 'offer', label: 'Оферты' }, -]; +] function eventTypeLabel(type: string): string { switch (type) { case 'dividend': - return 'Дивиденд'; + return 'Дивиденд' case 'coupon': - return 'Купон'; + return 'Купон' case 'maturity': - return 'Погашение'; + return 'Погашение' case 'offer': - return 'Оферта'; + return 'Оферта' default: - return type; + return type } } function defaultPeriod(): { from: string; to: string } { - const now = dayjs(); + const now = dayjs() return { from: now.subtract(7, 'day').format('YYYY-MM-DD'), to: now.add(7, 'day').format('YYYY-MM-DD'), - }; + } } function parseTypes(value: string | null): EventType[] { - if (!value) return [...EVENT_TYPES]; + if (!value) return [...EVENT_TYPES] const parsed = value .split(',') .map((type) => type.trim()) - .filter((type): type is EventType => EVENT_TYPES.includes(type as EventType)); + .filter((type): type is EventType => EVENT_TYPES.includes(type as EventType)) - return parsed.length > 0 ? parsed : [...EVENT_TYPES]; + return parsed.length > 0 ? parsed : [...EVENT_TYPES] } function filtersFromSearchParams(searchParams: URLSearchParams): Filters { - const def = defaultPeriod(); - const from = searchParams.get('from'); - const to = searchParams.get('to'); + const def = defaultPeriod() + const from = searchParams.get('from') + const to = searchParams.get('to') return { from: from && dayjs(from).isValid() ? from : def.from, to: to && dayjs(to).isValid() ? to : def.to, types: parseTypes(searchParams.get('types')), - }; + } } function filtersToSearchParams(filters: Filters): URLSearchParams { - const next = new URLSearchParams(); - next.set('from', filters.from); - next.set('to', filters.to); - next.set('types', filters.types.join(',')); - return next; + const next = new URLSearchParams() + next.set('from', filters.from) + next.set('to', filters.to) + next.set('types', filters.types.join(',')) + return next } function sourceLabel(source: string): string { - return source === 'actual' ? 'Факт' : 'Прогноз'; + return source === 'actual' ? 'Факт' : 'Прогноз' } export function BrokerEventsPage() { - const { accountId } = useBrokerAccountContext(); - const [searchParams, setSearchParams] = useSearchParams(); + const { accountId } = useBrokerAccountContext() + const [searchParams, setSearchParams] = useSearchParamsCompat() const [appliedFilters, setAppliedFilters] = useState(() => filtersFromSearchParams(searchParams), - ); + ) const [draftFilters, setDraftFilters] = useState(() => filtersFromSearchParams(searchParams), - ); + ) useEffect(() => { - const next = filtersFromSearchParams(searchParams); - setAppliedFilters(next); - setDraftFilters(next); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const next = filtersFromSearchParams(searchParams) + setAppliedFilters(next) + setDraftFilters(next) + }, [searchParams]) - const from = draftFilters.from; - const to = draftFilters.to; + const from = draftFilters.from + const to = draftFilters.to - const validFrom = dayjs(from); - const validTo = dayjs(to); + const validFrom = dayjs(from) + const validTo = dayjs(to) const dateError = from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom) ? '"По" не может быть раньше "С"' - : ''; - const typeError = draftFilters.types.length === 0 ? 'Выберите хотя бы один тип события' : ''; - const filterError = dateError || typeError; + : '' + const typeError = draftFilters.types.length === 0 ? 'Выберите хотя бы один тип события' : '' + const filterError = dateError || typeError const events = useBrokerEvents(filterError ? undefined : accountId, { from: appliedFilters.from, to: appliedFilters.to, types: appliedFilters.types.join(','), - }); + }) - const ev = events.data; + const ev = events.data function toggleType(type: EventType, checked: boolean) { setDraftFilters((current) => ({ @@ -124,13 +124,13 @@ export function BrokerEventsPage() { types: checked ? [...new Set([...current.types, type])] : current.types.filter((t) => t !== type), - })); + })) } function applyFilters() { - if (filterError) return; - setAppliedFilters(draftFilters); - setSearchParams(filtersToSearchParams(draftFilters), { replace: true }); + if (filterError) return + setAppliedFilters(draftFilters) + setSearchParams(filtersToSearchParams(draftFilters), { replace: true }) } return ( @@ -147,7 +147,7 @@ export function BrokerEventsPage() { type="date" value={from} onChange={(e) => { - setDraftFilters((current) => ({ ...current, from: e.target.value })); + setDraftFilters((current) => ({ ...current, from: e.target.value })) }} InputLabelProps={{ shrink: true }} /> @@ -156,7 +156,7 @@ export function BrokerEventsPage() { type="date" value={to} onChange={(e) => { - setDraftFilters((current) => ({ ...current, to: e.target.value })); + setDraftFilters((current) => ({ ...current, to: e.target.value })) }} InputLabelProps={{ shrink: true }} error={!!dateError} @@ -406,5 +406,5 @@ export function BrokerEventsPage() { ) : null} - ); + ) } diff --git a/apps/frontend/src/pages/broker-operations/index.ts b/apps/frontend/src/pages/broker-operations/index.ts index b6ce2df..1fcf31b 100644 --- a/apps/frontend/src/pages/broker-operations/index.ts +++ b/apps/frontend/src/pages/broker-operations/index.ts @@ -1 +1 @@ -export { BrokerOperationsPage } from './ui/BrokerOperationsPage'; +export { BrokerOperationsPage } from './ui/BrokerOperationsPage' diff --git a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx index f6e7135..0d6e038 100644 --- a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx +++ b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx @@ -1,36 +1,36 @@ -import { useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Heading, Text } from '@moex-vibe/design-system'; +import { Heading, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { useEffect } from 'react' 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'; +} from '@/entities/broker-operation' +import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams' +import { useCursorPagination } from '@/shared/lib/useCursorPagination' +import { useBrokerAccountContext } from '@/widgets/broker-account-layout' +import { BrokerOperationsTable } from '@/widgets/broker-operations-table' export function BrokerOperationsPage() { - const { accountId } = useBrokerAccountContext(); - const [searchParams, setSearchParams] = useSearchParams(); - const urlType = searchParams.get('type'); - const selectedType = isBrokerOperationType(urlType) ? urlType : ''; - const pagination = useCursorPagination(); + const { accountId } = useBrokerAccountContext() + const [searchParams, setSearchParams] = useSearchParamsCompat() + 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 + pagination.reset() + }, [pagination.reset]) function handleTypeChange(event: React.ChangeEvent) { - const nextType = event.target.value; - setSearchParams(nextType ? { type: nextType } : {}, { replace: true }); + const nextType = event.target.value + setSearchParams(nextType ? { type: nextType } : {}, { replace: true }) } const history = operations.error ? ( @@ -54,7 +54,7 @@ export function BrokerOperationsPage() { onNext: () => pagination.handleNext(operations.data?.nextCursor), }} /> - ); + ) return ( @@ -78,5 +78,5 @@ export function BrokerOperationsPage() { {history} - ); + ) } diff --git a/apps/frontend/src/pages/broker-positions/index.ts b/apps/frontend/src/pages/broker-positions/index.ts index f1ff7a6..1893069 100644 --- a/apps/frontend/src/pages/broker-positions/index.ts +++ b/apps/frontend/src/pages/broker-positions/index.ts @@ -1 +1 @@ -export { BrokerPositionsPage } from './ui/BrokerPositionsPage'; +export { BrokerPositionsPage } from './ui/BrokerPositionsPage' diff --git a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx index 7e75cc8..f824e5d 100644 --- a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx +++ b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx @@ -1,19 +1,19 @@ -import { Box } from '@mui/material'; -import { Heading, Text } from '@moex-vibe/design-system'; -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'; +import { Heading, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { useBrokerPositions } from '@/entities/broker-position' +import { useCursorPagination } from '@/shared/lib/useCursorPagination' +import { useBrokerAccountContext } from '@/widgets/broker-account-layout' +import { BrokerPositionTable } from '@/widgets/broker-positions-table' type BrokerPositionsPageProps = { - type: 'share' | 'bond'; - title: 'Акции' | 'Облигации'; -}; + 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 }); + const { accountId } = useBrokerAccountContext() + const pagination = useCursorPagination() + const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor }) if (positions.error) { return ( @@ -25,7 +25,7 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} - ); + ) } return ( @@ -39,5 +39,5 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { onNext={() => pagination.handleNext(positions.data?.nextCursor)} onPrevious={pagination.handlePrevious} /> - ); + ) } diff --git a/apps/frontend/src/pages/home/index.ts b/apps/frontend/src/pages/home/index.ts index a410f01..30c9457 100644 --- a/apps/frontend/src/pages/home/index.ts +++ b/apps/frontend/src/pages/home/index.ts @@ -1 +1 @@ -export { HomePage } from './ui/HomePage'; +export { HomePage } from './ui/HomePage' diff --git a/apps/frontend/src/pages/home/ui/HomePage.tsx b/apps/frontend/src/pages/home/ui/HomePage.tsx index 9f7f049..7e09b89 100644 --- a/apps/frontend/src/pages/home/ui/HomePage.tsx +++ b/apps/frontend/src/pages/home/ui/HomePage.tsx @@ -1,5 +1,5 @@ -import { Box } from '@mui/material'; -import { Heading, Text } from '@moex-vibe/design-system'; +import { Heading, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' export function HomePage() { return ( @@ -11,5 +11,5 @@ export function HomePage() { Введите название или тикер в строку поиска выше Данные задерживаются на 15 минут · Бесплатный API MOEX ISS - ); + ) } diff --git a/apps/frontend/src/pages/login/LoginPage.test.tsx b/apps/frontend/src/pages/login/LoginPage.test.tsx index 94b23b1..84d7cb2 100644 --- a/apps/frontend/src/pages/login/LoginPage.test.tsx +++ b/apps/frontend/src/pages/login/LoginPage.test.tsx @@ -1,42 +1,71 @@ -import { describe, it, expect } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { LoginPage } from './ui/LoginPage'; -import { renderWithProviders } from '@/shared/lib/test/test-utils'; +import { server } from '@mocks/server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider' +import { LoginPage } from './ui/LoginPage' -const API = '/api/v1'; +const API = '/api/v1' + +function loginTestRouter(initialPath: string) { + const rootRoute = createRootRoute() + const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: LoginPage, + }) + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home Page
, + }) + const routeTree = rootRoute.addChildren([loginRoute, homeRoute]) + const history = createMemoryHistory({ initialEntries: [initialPath] }) + return createRouter({ routeTree, history }) +} + +function renderLoginPage(router: ReturnType) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} describe('LoginPage', () => { it('renders login form', async () => { - server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); - renderWithProviders(, { route: '/login' }); - expect(await screen.findByText('Вход')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); - }); + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) + renderLoginPage(loginTestRouter('/login')) + expect(await screen.findByText('Вход')).toBeInTheDocument() + expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument() + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument() + }) it('redirects on successful login', async () => { - server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); - const user = userEvent.setup(); - renderWithProviders( - - } /> - Home Page} /> - , - { route: '/login' }, - ); + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) + const user = userEvent.setup() + renderLoginPage(loginTestRouter('/login')) - await screen.findByText('Вход'); + await screen.findByText('Вход') - await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); - await user.type(screen.getByPlaceholderText('••••••••'), 'password'); - await user.click(screen.getByRole('button', { name: 'Войти' })); + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com') + await user.type(screen.getByPlaceholderText('••••••••'), 'password') + await user.click(screen.getByRole('button', { name: 'Войти' })) - await screen.findByText('Home Page'); - }); + await screen.findByText('Home Page') + }) it('shows error on failed login', async () => { server.use( @@ -45,35 +74,35 @@ describe('LoginPage', () => { `${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), ), - ); - const user = userEvent.setup(); - renderWithProviders(, { route: '/login' }); + ) + const user = userEvent.setup() + renderLoginPage(loginTestRouter('/login')) - await screen.findByText('Вход'); + await screen.findByText('Вход') - await user.type(screen.getByPlaceholderText('email@example.com'), 'bad@test.com'); - await user.type(screen.getByPlaceholderText('••••••••'), 'wrong'); - await user.click(screen.getByRole('button', { name: 'Войти' })); + await user.type(screen.getByPlaceholderText('email@example.com'), 'bad@test.com') + await user.type(screen.getByPlaceholderText('••••••••'), 'wrong') + await user.click(screen.getByRole('button', { name: 'Войти' })) - expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); - }); + expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument() + }) it('shows loading state while submitting', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), http.post(`${API}/auth/login`, () => new Promise(() => {})), - ); - const user = userEvent.setup(); - renderWithProviders(, { route: '/login' }); + ) + const user = userEvent.setup() + renderLoginPage(loginTestRouter('/login')) - await screen.findByText('Вход'); + await screen.findByText('Вход') - await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); - await user.type(screen.getByPlaceholderText('••••••••'), 'password'); - await user.click(screen.getByRole('button', { name: 'Войти' })); + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com') + await user.type(screen.getByPlaceholderText('••••••••'), 'password') + await user.click(screen.getByRole('button', { name: 'Войти' })) await waitFor(() => { - expect(screen.getByRole('button', { name: 'Войти' })).toBeDisabled(); - }); - }); -}); + expect(screen.getByRole('button', { name: 'Войти' })).toBeDisabled() + }) + }) +}) diff --git a/apps/frontend/src/pages/login/index.ts b/apps/frontend/src/pages/login/index.ts index 3935214..39d858d 100644 --- a/apps/frontend/src/pages/login/index.ts +++ b/apps/frontend/src/pages/login/index.ts @@ -1 +1 @@ -export { LoginPage } from './ui/LoginPage'; +export { LoginPage } from './ui/LoginPage' diff --git a/apps/frontend/src/pages/login/ui/LoginPage.tsx b/apps/frontend/src/pages/login/ui/LoginPage.tsx index 98b4cb4..0dcd760 100644 --- a/apps/frontend/src/pages/login/ui/LoginPage.tsx +++ b/apps/frontend/src/pages/login/ui/LoginPage.tsx @@ -1,31 +1,32 @@ -import { useState, type FormEvent } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Button, Heading, Text, TextField } from '@moex-vibe/design-system'; -import { useSession } from '@/entities/session'; +import { Button, Heading, Text, TextField } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link, useNavigate } from '@tanstack/react-router' +import { type FormEvent, useState } from 'react' +import { useSession } from '@/entities/session' +import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams' export function LoginPage() { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { login } = useSession(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); + const navigate = useNavigate() + const [searchParams] = useSearchParamsCompat() + const { login } = useSession() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) - const redirect = searchParams.get('redirect') || '/'; + const redirect = searchParams.get('redirect') || '/' async function handleSubmit(e: FormEvent) { - e.preventDefault(); - setError(''); - setLoading(true); + e.preventDefault() + setError('') + setLoading(true) try { - await login(email, password); - navigate(redirect); + await login(email, password) + navigate({ to: redirect }) } catch (err) { - setError(err instanceof Error ? err.message : 'Ошибка входа'); + setError(err instanceof Error ? err.message : 'Ошибка входа') } finally { - setLoading(false); + setLoading(false) } } @@ -69,5 +70,5 @@ export function LoginPage() { - ); + ) } diff --git a/apps/frontend/src/pages/portfolios/index.ts b/apps/frontend/src/pages/portfolios/index.ts index 34e0478..0adf102 100644 --- a/apps/frontend/src/pages/portfolios/index.ts +++ b/apps/frontend/src/pages/portfolios/index.ts @@ -1,2 +1,2 @@ -export { PortfoliosListPage } from './ui/PortfoliosListPage'; -export { PortfolioDetailPage } from './ui/PortfolioDetailPage'; +export { PortfolioDetailPage } from './ui/PortfolioDetailPage' +export { PortfoliosListPage } from './ui/PortfoliosListPage' diff --git a/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx b/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx index 6387f4d..ee88d50 100644 --- a/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx +++ b/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx @@ -1,30 +1,30 @@ -import { useState } from 'react'; -import { useParams, Link } from 'react-router-dom'; -import { usePortfolio, usePortfolioMutations, usePositionMutations } 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'; +import { Link, useParams } from '@tanstack/react-router' +import { useState } from 'react' +import { usePortfolio, usePortfolioMutations, usePositionMutations } from '@/entities/portfolio' +import { AddPositionForm } from '@/features/add-position' +import { BondPositionTable } from '@/widgets/bond-positions-table' +import { AnalyticsSummary } from '@/widgets/portfolio-analytics' +import { PortfolioForm } from '@/widgets/portfolio-form' +import { PortfolioSummary } from '@/widgets/portfolio-summary' +import { SharePositionTable } from '@/widgets/share-positions-table' export function PortfolioDetailPage() { - const { id } = useParams<{ id: string }>(); - const portfolioId = parseInt(id!, 10); + const { id } = useParams({ from: '/portfolios/$id' }) + const portfolioId = parseInt(id!, 10) - const { data: portfolio, isLoading, error } = usePortfolio(portfolioId); - const { update, remove } = usePortfolioMutations(); - const { update: updatePosition, remove: removePosition } = usePositionMutations(portfolioId); + const { data: portfolio, isLoading, error } = usePortfolio(portfolioId) + const { update, remove } = usePortfolioMutations() + const { update: updatePosition, remove: removePosition } = usePositionMutations(portfolioId) - const [editing, setEditing] = useState(false); - const [showAddForm, setShowAddForm] = useState(false); + const [editing, setEditing] = useState(false) + const [showAddForm, setShowAddForm] = useState(false) if (isLoading) { return (
Загрузка...
- ); + ) } if (error || !portfolio) { @@ -32,12 +32,12 @@ export function PortfolioDetailPage() {
Ошибка загрузки портфеля
- ); + ) } async function handleDelete() { if (window.confirm('Удалить портфель и все позиции?')) { - remove.mutate(portfolioId); + remove.mutate(portfolioId) } } @@ -146,7 +146,7 @@ export function PortfolioDetailPage() { positions={portfolio.positions.filter((p) => p.type === 'share')} onUpdatePosition={(positionId, data) => updatePosition.mutate({ positionId, data })} onDeletePosition={(positionId) => { - if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId); + if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId) }} /> @@ -154,9 +154,9 @@ export function PortfolioDetailPage() { positions={portfolio.positions.filter((p) => p.type === 'bond')} onUpdatePosition={(positionId, data) => updatePosition.mutate({ positionId, data })} onDeletePosition={(positionId) => { - if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId); + if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId) }} /> - ); + ) } diff --git a/apps/frontend/src/pages/portfolios/ui/PortfoliosListPage.tsx b/apps/frontend/src/pages/portfolios/ui/PortfoliosListPage.tsx index 9f8fc28..345b32d 100644 --- a/apps/frontend/src/pages/portfolios/ui/PortfoliosListPage.tsx +++ b/apps/frontend/src/pages/portfolios/ui/PortfoliosListPage.tsx @@ -1,19 +1,19 @@ -import { useState } from 'react'; -import { usePortfolios, usePortfolioMutations } from '@/entities/portfolio'; -import { PortfolioCard } from '@/widgets/portfolio-card'; -import { PortfolioForm } from '@/widgets/portfolio-form'; +import { useState } from 'react' +import { usePortfolioMutations, usePortfolios } from '@/entities/portfolio' +import { PortfolioCard } from '@/widgets/portfolio-card' +import { PortfolioForm } from '@/widgets/portfolio-form' export function PortfoliosListPage() { - const { data: portfolios, isLoading, error } = usePortfolios(); - const { create } = usePortfolioMutations(); - const [showForm, setShowForm] = useState(false); + const { data: portfolios, isLoading, error } = usePortfolios() + const { create } = usePortfolioMutations() + const [showForm, setShowForm] = useState(false) if (isLoading) { return (
Загрузка...
- ); + ) } if (error) { @@ -21,7 +21,7 @@ export function PortfoliosListPage() {
Ошибка загрузки портфелей
- ); + ) } return ( @@ -90,5 +90,5 @@ export function PortfoliosListPage() { )} - ); + ) } diff --git a/apps/frontend/src/pages/profile/ProfilePage.test.tsx b/apps/frontend/src/pages/profile/ProfilePage.test.tsx index 907bc0c..1c9323a 100644 --- a/apps/frontend/src/pages/profile/ProfilePage.test.tsx +++ b/apps/frontend/src/pages/profile/ProfilePage.test.tsx @@ -1,64 +1,64 @@ -import { describe, it, expect } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { ProfilePage } from './ui/ProfilePage'; -import { renderWithProviders } from '@/shared/lib/test/test-utils'; +import { server } from '@mocks/server' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { renderWithProviders } from '@/shared/lib/test/test-utils' +import { ProfilePage } from './ui/ProfilePage' -const API = '/api/v1'; +const API = '/api/v1' describe('ProfilePage', () => { it('renders user profile', async () => { - renderWithProviders(, { route: '/profile' }); - expect(await screen.findByText('Профиль')).toBeInTheDocument(); - expect(await screen.findByText('user@test.com')).toBeInTheDocument(); - expect(await screen.findByText('user')).toBeInTheDocument(); - }); + renderWithProviders(, { route: '/profile' }) + expect(await screen.findByText('Профиль')).toBeInTheDocument() + expect(await screen.findByText('user@test.com')).toBeInTheDocument() + expect(await screen.findByText('user')).toBeInTheDocument() + }) it('shows user name in input', async () => { - renderWithProviders(, { route: '/profile' }); - const input = await screen.findByDisplayValue('Test User'); - expect(input).toBeInTheDocument(); - }); + renderWithProviders(, { route: '/profile' }) + const input = await screen.findByDisplayValue('Test User') + expect(input).toBeInTheDocument() + }) it('updates profile on save', async () => { - const user = userEvent.setup(); - renderWithProviders(, { route: '/profile' }); + const user = userEvent.setup() + renderWithProviders(, { route: '/profile' }) - const input = await screen.findByDisplayValue('Test User'); - await user.clear(input); - await user.type(input, 'Updated User'); - await user.click(screen.getByRole('button', { name: 'Сохранить' })); + const input = await screen.findByDisplayValue('Test User') + await user.clear(input) + await user.type(input, 'Updated User') + await user.click(screen.getByRole('button', { name: 'Сохранить' })) - expect(await screen.findByText('Профиль обновлён')).toBeInTheDocument(); - }); + expect(await screen.findByText('Профиль обновлён')).toBeInTheDocument() + }) it('shows error message on failed update', async () => { - server.use(http.patch(`${API}/auth/me`, () => new HttpResponse(null, { status: 500 }))); - const user = userEvent.setup(); - renderWithProviders(, { route: '/profile' }); + server.use(http.patch(`${API}/auth/me`, () => new HttpResponse(null, { status: 500 }))) + const user = userEvent.setup() + renderWithProviders(, { route: '/profile' }) - const input = await screen.findByDisplayValue('Test User'); - await user.clear(input); - await user.type(input, 'New Name'); - await user.click(screen.getByRole('button', { name: 'Сохранить' })); + const input = await screen.findByDisplayValue('Test User') + await user.clear(input) + await user.type(input, 'New Name') + await user.click(screen.getByRole('button', { name: 'Сохранить' })) - expect(await screen.findByText('Не удалось обновить профиль')).toBeInTheDocument(); - }); + expect(await screen.findByText('Не удалось обновить профиль')).toBeInTheDocument() + }) it('shows saving state', async () => { - server.use(http.patch(`${API}/auth/me`, () => new Promise(() => {}))); - const user = userEvent.setup(); - renderWithProviders(, { route: '/profile' }); + server.use(http.patch(`${API}/auth/me`, () => new Promise(() => {}))) + const user = userEvent.setup() + renderWithProviders(, { route: '/profile' }) - const input = await screen.findByDisplayValue('Test User'); - await user.clear(input); - await user.type(input, 'New Name'); - await user.click(screen.getByRole('button', { name: 'Сохранить' })); + const input = await screen.findByDisplayValue('Test User') + await user.clear(input) + await user.type(input, 'New Name') + await user.click(screen.getByRole('button', { name: 'Сохранить' })) await waitFor(() => { - expect(screen.getByRole('button', { name: 'Сохранить' })).toBeDisabled(); - }); - }); -}); + expect(screen.getByRole('button', { name: 'Сохранить' })).toBeDisabled() + }) + }) +}) diff --git a/apps/frontend/src/pages/profile/index.ts b/apps/frontend/src/pages/profile/index.ts index 7d9c888..1baaa20 100644 --- a/apps/frontend/src/pages/profile/index.ts +++ b/apps/frontend/src/pages/profile/index.ts @@ -1 +1 @@ -export { ProfilePage } from './ui/ProfilePage'; +export { ProfilePage } from './ui/ProfilePage' diff --git a/apps/frontend/src/pages/profile/ui/ProfilePage.tsx b/apps/frontend/src/pages/profile/ui/ProfilePage.tsx index ddce145..a731779 100644 --- a/apps/frontend/src/pages/profile/ui/ProfilePage.tsx +++ b/apps/frontend/src/pages/profile/ui/ProfilePage.tsx @@ -1,29 +1,29 @@ -import { useState, type FormEvent } from 'react'; -import { Box } from '@mui/material'; -import { Button, Heading, Surface, Text, TextField } from '@moex-vibe/design-system'; -import { useSession } from '@/entities/session'; +import { Button, Heading, Surface, Text, TextField } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { type FormEvent, useState } from 'react' +import { useSession } from '@/entities/session' export function ProfilePage() { - const { user, updateProfile } = useSession(); - const [name, setName] = useState(user?.name || ''); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(''); + const { user, updateProfile } = useSession() + const [name, setName] = useState(user?.name || '') + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') async function handleSubmit(e: FormEvent) { - e.preventDefault(); - setSaving(true); - setMessage(''); + e.preventDefault() + setSaving(true) + setMessage('') try { - await updateProfile({ name: name || undefined }); - setMessage('Профиль обновлён'); + await updateProfile({ name: name || undefined }) + setMessage('Профиль обновлён') } catch { - setMessage('Не удалось обновить профиль'); + setMessage('Не удалось обновить профиль') } finally { - setSaving(false); + setSaving(false) } } - if (!user) return null; + if (!user) return null return ( @@ -58,5 +58,5 @@ export function ProfilePage() { - ); + ) } diff --git a/apps/frontend/src/pages/register/RegisterPage.test.tsx b/apps/frontend/src/pages/register/RegisterPage.test.tsx index 09a51c0..c5126b8 100644 --- a/apps/frontend/src/pages/register/RegisterPage.test.tsx +++ b/apps/frontend/src/pages/register/RegisterPage.test.tsx @@ -1,58 +1,87 @@ -import { describe, it, expect } from 'vitest'; -import { screen } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { RegisterPage } from './ui/RegisterPage'; -import { renderWithProviders } from '@/shared/lib/test/test-utils'; +import { server } from '@mocks/server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider' +import { RegisterPage } from './ui/RegisterPage' -const API = '/api/v1'; +const API = '/api/v1' + +function registerTestRouter(initialPath: string) { + const rootRoute = createRootRoute() + const registerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/register', + component: RegisterPage, + }) + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home Page
, + }) + const routeTree = rootRoute.addChildren([registerRoute, homeRoute]) + const history = createMemoryHistory({ initialEntries: [initialPath] }) + return createRouter({ routeTree, history }) +} + +function renderRegisterPage(router: ReturnType) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} describe('RegisterPage', () => { it('renders registration form', async () => { - server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); - renderWithProviders(, { route: '/register' }); - expect(await screen.findByText('Регистрация')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Иван Иванов')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); - }); + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) + renderRegisterPage(registerTestRouter('/register')) + expect(await screen.findByText('Регистрация')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Иван Иванов')).toBeInTheDocument() + expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument() + }) it('shows password mismatch error', async () => { - server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); - const user = userEvent.setup(); - renderWithProviders(, { route: '/register' }); + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) + const user = userEvent.setup() + renderRegisterPage(registerTestRouter('/register')) - await screen.findByText('Регистрация'); + await screen.findByText('Регистрация') - await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); - await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password1'); - await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password2'); - await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com') + await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password1') + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password2') + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })) - expect(await screen.findByText('Пароли не совпадают')).toBeInTheDocument(); - }); + expect(await screen.findByText('Пароли не совпадают')).toBeInTheDocument() + }) it('redirects on successful registration', async () => { - server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))); - const user = userEvent.setup(); - renderWithProviders( - - } /> - Home Page} /> - , - { route: '/register' }, - ); + server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) + const user = userEvent.setup() + renderRegisterPage(registerTestRouter('/register')) - await screen.findByText('Регистрация'); + await screen.findByText('Регистрация') - await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); - await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password'); - await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); - await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com') + await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password') + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password') + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })) - await screen.findByText('Home Page'); - }); + await screen.findByText('Home Page') + }) it('shows error on failed registration', async () => { server.use( @@ -61,17 +90,17 @@ describe('RegisterPage', () => { `${API}/auth/register`, () => new HttpResponse(null, { status: 409, statusText: 'Conflict' }), ), - ); - const user = userEvent.setup(); - renderWithProviders(, { route: '/register' }); + ) + const user = userEvent.setup() + renderRegisterPage(registerTestRouter('/register')) - await screen.findByText('Регистрация'); + await screen.findByText('Регистрация') - await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com'); - await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password'); - await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); - await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com') + await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password') + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password') + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })) - expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); - }); -}); + expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument() + }) +}) diff --git a/apps/frontend/src/pages/register/index.ts b/apps/frontend/src/pages/register/index.ts index 2c39f32..c7eb09a 100644 --- a/apps/frontend/src/pages/register/index.ts +++ b/apps/frontend/src/pages/register/index.ts @@ -1 +1 @@ -export { RegisterPage } from './ui/RegisterPage'; +export { RegisterPage } from './ui/RegisterPage' diff --git a/apps/frontend/src/pages/register/ui/RegisterPage.tsx b/apps/frontend/src/pages/register/ui/RegisterPage.tsx index 7811b12..3be9dec 100644 --- a/apps/frontend/src/pages/register/ui/RegisterPage.tsx +++ b/apps/frontend/src/pages/register/ui/RegisterPage.tsx @@ -1,39 +1,40 @@ -import { useState, type FormEvent } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Button, Heading, Text, TextField } from '@moex-vibe/design-system'; -import { useSession } from '@/entities/session'; +import { Button, Heading, Text, TextField } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link, useNavigate } from '@tanstack/react-router' +import { type FormEvent, useState } from 'react' +import { useSession } from '@/entities/session' +import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams' export function RegisterPage() { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { register } = useSession(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [name, setName] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); + const navigate = useNavigate() + const [searchParams] = useSearchParamsCompat() + const { register } = useSession() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [name, setName] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) - const redirect = searchParams.get('redirect') || '/'; + const redirect = searchParams.get('redirect') || '/' async function handleSubmit(e: FormEvent) { - e.preventDefault(); - setError(''); + e.preventDefault() + setError('') if (password !== confirmPassword) { - setError('Пароли не совпадают'); - return; + setError('Пароли не совпадают') + return } - setLoading(true); + setLoading(true) try { - await register(email, password, name || undefined); - navigate(redirect); + await register(email, password, name || undefined) + navigate({ to: redirect }) } catch (err) { - setError(err instanceof Error ? err.message : 'Ошибка регистрации'); + setError(err instanceof Error ? err.message : 'Ошибка регистрации') } finally { - setLoading(false); + setLoading(false) } } @@ -90,5 +91,5 @@ export function RegisterPage() { - ); + ) } diff --git a/apps/frontend/src/pages/screener/index.ts b/apps/frontend/src/pages/screener/index.ts index e0db44a..917da2b 100644 --- a/apps/frontend/src/pages/screener/index.ts +++ b/apps/frontend/src/pages/screener/index.ts @@ -1 +1 @@ -export { ScreenerPage } from './ui/ScreenerPage'; +export { ScreenerPage } from './ui/ScreenerPage' diff --git a/apps/frontend/src/pages/screener/ui/ScreenerPage.tsx b/apps/frontend/src/pages/screener/ui/ScreenerPage.tsx index bba51bd..60d73b5 100644 --- a/apps/frontend/src/pages/screener/ui/ScreenerPage.tsx +++ b/apps/frontend/src/pages/screener/ui/ScreenerPage.tsx @@ -1,8 +1,8 @@ -import { useScreener, FilterPanel, ScreenerTable } from '@/features/screener'; +import { FilterPanel, ScreenerTable, useScreener } from '@/features/screener' export function ScreenerPage() { const { params, result, isLoading, error, setFilters, setPage, setSort, resetFilters } = - useScreener(); + useScreener() return (
@@ -53,5 +53,5 @@ export function ScreenerPage() { )}
- ); + ) } diff --git a/apps/frontend/src/pages/stock/index.ts b/apps/frontend/src/pages/stock/index.ts index cb139bc..940d78b 100644 --- a/apps/frontend/src/pages/stock/index.ts +++ b/apps/frontend/src/pages/stock/index.ts @@ -1 +1 @@ -export { StockPage } from './ui/StockPage'; +export { StockPage } from './ui/StockPage' diff --git a/apps/frontend/src/pages/stock/ui/StockPage.tsx b/apps/frontend/src/pages/stock/ui/StockPage.tsx index a41bdef..1ee32db 100644 --- a/apps/frontend/src/pages/stock/ui/StockPage.tsx +++ b/apps/frontend/src/pages/stock/ui/StockPage.tsx @@ -1,19 +1,19 @@ -import { useParams } from 'react-router-dom'; -import { useStock, useStockCandles, useStockDividends } from '@/entities/stock'; -import { StockDetails } from '@/widgets/stock-details'; -import { PriceChart } from '@/widgets/price-chart'; -import { DividendsTable } from '@/widgets/dividends-table'; +import { useParams } from '@tanstack/react-router' +import { useStock, useStockCandles, useStockDividends } from '@/entities/stock' +import { DividendsTable } from '@/widgets/dividends-table' +import { PriceChart } from '@/widgets/price-chart' +import { StockDetails } from '@/widgets/stock-details' export function StockPage() { - const { secid } = useParams<{ secid: string }>(); - const { data: stock, isLoading, error } = useStock(secid!); - const till = new Date().toISOString().split('T')[0]; - const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - const { data: candles } = useStockCandles(secid!, '24h', from, till); - const { data: dividends } = useStockDividends(secid!); + const { secid } = useParams({ from: '/stocks/$secid' }) + const { data: stock, isLoading, error } = useStock(secid!) + const till = new Date().toISOString().split('T')[0] + const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + const { data: candles } = useStockCandles(secid!, '24h', from, till) + const { data: dividends } = useStockDividends(secid!) - if (isLoading) return
Загрузка...
; - if (error || !stock) return
Инструмент не найден
; + if (isLoading) return
Загрузка...
+ if (error || !stock) return
Инструмент не найден
return (
@@ -33,5 +33,5 @@ export function StockPage() { {dividends && dividends.length > 0 && }
- ); + ) } diff --git a/apps/frontend/src/shared/api/client.test.ts b/apps/frontend/src/shared/api/client.test.ts deleted file mode 100644 index fc6bf61..0000000 --- a/apps/frontend/src/shared/api/client.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/shared/lib/test/server'; -import { request, configureAuth } from './client'; -import { - setAccessToken, - getAccessToken, - setOnUnauthorized, - handleUnauthorized, -} from '@/entities/session/api/tokenManager'; - -const API = '/api/v1'; - -beforeEach(() => { - setAccessToken(null); - configureAuth({ - getAccessToken, - handleUnauthorized, - }); -}); - -describe('request', () => { - it('makes GET request and returns data', async () => { - const result = await request<{ status: string; timestamp: string; uptime: number }>( - '/api/v1/health', - ); - expect(result.data.status).toBe('ok'); - }); - - it('supports the single API envelope shape documented by Swagger', async () => { - server.use( - http.get(`${API}/test-single-envelope`, () => - HttpResponse.json({ - data: { ok: true }, - meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' }, - }), - ), - ); - - const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope'); - - expect(result).toEqual({ - data: { ok: true }, - meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' }, - }); - }); - - it('includes Authorization header when token is set', async () => { - setAccessToken('test-token'); - let capturedAuth: string | null = null; - server.use( - http.get(`${API}/test-auth`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ - data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, - }); - }), - ); - await request('/api/v1/test-auth'); - expect(capturedAuth).toBe('Bearer test-token'); - }); - - it('retries on 401 and succeeds after refresh', async () => { - setAccessToken('expired-token'); - let attempts = 0; - server.use( - http.get(`${API}/test-retry`, ({ request }) => { - attempts++; - const auth = request.headers.get('Authorization'); - if (auth === 'Bearer expired-token') { - return new HttpResponse(null, { status: 401 }); - } - return HttpResponse.json({ - data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, - }); - }), - http.post(`${API}/auth/refresh`, () => - HttpResponse.json({ - data: { - data: { - user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, - accessToken: 'new-token', - }, - meta: { fromCache: false, cachedAt: null }, - }, - }), - ), - ); - const result = await request<{ ok: boolean }>('/api/v1/test-retry'); - expect(attempts).toBe(2); - expect(result.data).toEqual({ ok: true }); - expect(getAccessToken()).toBe('new-token'); - }); - - it('throws on persistent 401 and clears token', async () => { - setAccessToken('expired-token'); - let unauthorizedCalled = false; - setOnUnauthorized(() => { - unauthorizedCalled = true; - }); - server.use( - http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })), - http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), - ); - await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла'); - expect(getAccessToken()).toBeNull(); - expect(unauthorizedCalled).toBe(true); - }); - - it('throws on non-ok response with status text', async () => { - server.use( - http.get( - `${API}/test-error`, - () => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' }), - ), - ); - await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404'); - }); - - it('sends JSON body for POST requests', async () => { - let capturedBody: string | null = null; - server.use( - http.post(`${API}/test-post`, async ({ request }) => { - capturedBody = await request.text(); - return HttpResponse.json({ - data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, - }); - }), - ); - await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } }); - expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' })); - }); - - it('does not send auth header when skipAuth is true', async () => { - setAccessToken('test-token'); - let capturedAuth: string | null = null; - server.use( - http.get(`${API}/test-skip`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); - return HttpResponse.json({ - data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, - }); - }), - ); - await request('/api/v1/test-skip', undefined, { skipAuth: true }); - expect(capturedAuth).toBeNull(); - }); - - it('sets query params correctly', async () => { - let capturedUrl = ''; - server.use( - http.get(`${API}/test-params`, ({ request }) => { - capturedUrl = request.url; - return HttpResponse.json({ - data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } }, - }); - }), - ); - await request('/api/v1/test-params', { q: 'sber', type: 'share' }); - expect(capturedUrl).toContain('q=sber'); - expect(capturedUrl).toContain('type=share'); - }); -}); diff --git a/apps/frontend/src/shared/api/client.ts b/apps/frontend/src/shared/api/client.ts deleted file mode 100644 index c80556e..0000000 --- a/apps/frontend/src/shared/api/client.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { ApiEnvelope, ApiResponseMeta, HealthResponse } from './responses'; - -export type AuthConfig = { - getAccessToken: () => string | null; - handleUnauthorized: () => Promise; -}; - -let authConfig: AuthConfig = { - getAccessToken: () => null, - handleUnauthorized: async () => false, -}; - -export function configureAuth(config: AuthConfig) { - authConfig = config; -} - -export function normalizeEnvelope(json: unknown): { data: T; meta: ApiResponseMeta } { - const envelope = json as ApiEnvelope; - if ( - envelope.data && - typeof envelope.data === 'object' && - 'data' in envelope.data && - 'meta' in envelope.data - ) { - return envelope.data as { data: T; meta: ApiResponseMeta }; - } - - return { - data: envelope.data as T, - meta: envelope.meta, - }; -} - -export async function request( - path: string, - params?: Record, - options?: { method?: string; body?: unknown; skipAuth?: boolean }, -): Promise<{ data: T; meta: ApiResponseMeta }> { - const url = new URL(path, window.location.origin); - - if (params) { - for (const [k, v] of Object.entries(params)) { - if (v !== undefined) url.searchParams.set(k, v); - } - } - - const headers: Record = {}; - if (!options?.skipAuth) { - const token = authConfig.getAccessToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - } - - if (options?.body && !(options.body instanceof FormData)) { - headers['Content-Type'] = 'application/json'; - } - - const fetchOptions: RequestInit = { - method: options?.method ?? 'GET', - headers, - credentials: 'include', - }; - - if (options?.body !== undefined) { - fetchOptions.body = - options.body instanceof FormData ? options.body : JSON.stringify(options.body); - } - - let res = await fetch(url.toString(), fetchOptions); - - if (res.status === 401 && !options?.skipAuth) { - const refreshed = await authConfig.handleUnauthorized(); - if (refreshed) { - const token = authConfig.getAccessToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - res = await fetch(url.toString(), { ...fetchOptions, headers }); - } else { - throw new Error('Сессия истекла'); - } - } - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`); - } - - const json = await res.json(); - return normalizeEnvelope(json); -} - -export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> { - return request('/api/v1/health'); -} diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts index 8d9d91b..566da60 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -1,33 +1,62 @@ -export { request, configureAuth, getHealth } from './client'; -export type { - ApiResponseMeta, - ApiEnvelope, - StockMarketData, - ShareResponse, - DividendItem, - ShareHistoryItem, - BondMarketData, - BondResponse, - BondHistoryItem, - CandleItem, - SearchResultItem, - HealthResponse, - UserResponse, - AuthResponse, - Portfolio, - PositionWithPrice, - PortfolioDetail, - Position, - PortfolioSummary, - AnalyticsResponse, - ScreenerItem, - ScreenerResult, - BrokerMoney, - BrokerAccount, - BrokerPosition, - BrokerPortfolio, - BrokerOperationCategory, - BrokerOperation, - BrokerOperationsPage, - BrokerPositionsPage, -} from './responses'; +import type { components } from './types' + +export { configureKyAuth, getHealth, request } from './kyClient' + +export type ApiResponseMeta = components['schemas']['ApiResponseMeta'] + +export interface ApiEnvelope { + data: T + meta: ApiResponseMeta +} + +// Auth +export type AuthResponse = components['schemas']['AuthTokenDataDto'] +export type UserResponse = components['schemas']['AuthUserDto'] + +// Shares +export type ShareResponse = components['schemas']['ShareResponseDto'] +export type StockMarketData = components['schemas']['StockMarketDataDto'] +export type DividendItem = components['schemas']['DividendItemDto'] +export type ShareHistoryItem = components['schemas']['HistoryItemDto'] + +// Bonds +export type BondResponse = components['schemas']['BondResponseDto'] +export type BondMarketData = components['schemas']['BondMarketDataDto'] +export type BondHistoryItem = components['schemas']['BondHistoryItemDto'] + +// Candles +export type CandleItem = components['schemas']['CandleItemDto'] + +// Search +export type SearchResultItem = components['schemas']['SearchResultItemDto'] + +// Screener +export type ScreenerResult = components['schemas']['ScreenerResultDto'] +export type ScreenerItem = components['schemas']['ScreenerItemDto'] + +// Portfolio +export type Portfolio = components['schemas']['PortfolioListResponseDto'] +export type PortfolioDetail = components['schemas']['PortfolioDetailResponseDto'] +export type Position = components['schemas']['PositionResponseDto'] +export type PositionWithPrice = components['schemas']['PositionWithPriceDto'] +export type PortfolioSummary = components['schemas']['PortfolioSummaryDto'] +export type AnalyticsResponse = components['schemas']['AnalyticsResponseDto'] + +// Health +export type HealthResponse = components['schemas']['HealthResponseDto'] + +// Broker +export type BrokerAccount = components['schemas']['BrokerAccountResponseDto'] +export type BrokerPortfolio = components['schemas']['BrokerPortfolioResponseDto'] +export type BrokerMoney = components['schemas']['BrokerMoneyDto'] +export type BrokerPosition = components['schemas']['BrokerPositionResponseDto'] +export type BrokerOperation = components['schemas']['BrokerOperationResponseDto'] +export type BrokerOperationsPage = components['schemas']['BrokerOperationsPageResponseDto'] +export type BrokerPositionsPage = components['schemas']['BrokerPositionsPageResponseDto'] +export type BrokerOperationCategory = + components['schemas']['BrokerOperationResponseDto']['category'] + +// Broker events +export type BrokerEventItem = components['schemas']['BrokerEventItemDto'] +export type BrokerEventsData = components['schemas']['BrokerEventsDataDto'] +export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto'] diff --git a/apps/frontend/src/shared/api/kyClient.ts b/apps/frontend/src/shared/api/kyClient.ts index c2de9fe..0bcc757 100644 --- a/apps/frontend/src/shared/api/kyClient.ts +++ b/apps/frontend/src/shared/api/kyClient.ts @@ -1,39 +1,123 @@ -import ky from 'ky'; -import type { AuthConfig } from './client'; +import ky, { HTTPError } from 'ky' + +interface ApiResponseMeta { + cachedAt: string | null + fromCache: boolean +} + +interface ApiEnvelope { + data: T + meta: ApiResponseMeta +} + +interface AuthConfig { + getAccessToken: () => string | null + handleUnauthorized: () => Promise +} let authConfig: AuthConfig = { getAccessToken: () => null, handleUnauthorized: async () => false, -}; +} export function configureKyAuth(config: AuthConfig) { - authConfig = config; + authConfig = config } export const kyApi = ky.create({ - hooks: { - beforeRequest: [ - (request) => { - const token = authConfig.getAccessToken(); - if (token) { - request.headers.set('Authorization', `Bearer ${token}`); + credentials: 'include', +}) + +function buildUrl(path: string): string { + return new URL(path, window.location.origin).toString() +} + +export function normalizeEnvelope(json: unknown): { data: T; meta: ApiResponseMeta } { + const envelope = json as ApiEnvelope + if ( + envelope.data && + typeof envelope.data === 'object' && + 'data' in envelope.data && + 'meta' in envelope.data + ) { + return envelope.data as { data: T; meta: ApiResponseMeta } + } + + return { + data: envelope.data as T, + meta: envelope.meta, + } +} + +function pickMethod(method: string): 'get' | 'post' | 'patch' | 'delete' { + const m = method.toLowerCase() + if (m === 'post' || m === 'patch' || m === 'delete') return m + return 'get' +} + +export async function request( + path: string, + params?: Record, + options?: { method?: string; body?: unknown; skipAuth?: boolean }, +): Promise<{ data: T; meta: ApiResponseMeta }> { + const method = pickMethod(options?.method ?? 'get') + const url = buildUrl(path) + + const searchParams = new URLSearchParams() + if (params) { + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) searchParams.set(k, v) + } + } + + async function doRequest(updatedToken?: string | null): Promise { + const kyOptions: Record = {} + + if (searchParams.toString()) { + kyOptions.searchParams = searchParams + } + + if (options?.body !== undefined) { + kyOptions.json = options.body + } + + if (!options?.skipAuth) { + const token = updatedToken !== undefined ? updatedToken : authConfig.getAccessToken() + if (token) { + kyOptions.headers = { Authorization: `Bearer ${token}` } + } + } + + try { + return await kyApi[method](url, kyOptions) + } catch (error) { + if (error instanceof HTTPError && error.response.status === 401 && !options?.skipAuth) { + const refreshed = await authConfig.handleUnauthorized() + if (refreshed) { + const newToken = authConfig.getAccessToken() + return doRequest(newToken) } - }, - ], - afterResponse: [ - async (request, options, response) => { - if (response.status === 401) { - const refreshed = await authConfig.handleUnauthorized(); - if (refreshed) { - const token = authConfig.getAccessToken(); - if (token) { - request.headers.set('Authorization', `Bearer ${token}`); - } - return kyApi(request); - } - } - return response; - }, - ], - }, -}); + throw new Error('Сессия истекла') + } + if (error instanceof HTTPError) { + const status = error.response.status + const text = await error.response.text().catch(() => '') + throw new Error( + `Ошибка API: ${status} ${error.response.statusText}${text ? ` - ${text}` : ''}`, + ) + } + throw error + } + } + + const response = await doRequest() + const json = await response.json() + return normalizeEnvelope(json) +} + +export function getHealth(): Promise<{ + data: { status: string; timestamp: string; uptime: number } + meta: ApiResponseMeta +}> { + return request('/api/v1/health') +} diff --git a/apps/frontend/src/shared/api/responses.ts b/apps/frontend/src/shared/api/responses.ts deleted file mode 100644 index 539214a..0000000 --- a/apps/frontend/src/shared/api/responses.ts +++ /dev/null @@ -1,391 +0,0 @@ -export interface ApiResponseMeta { - cachedAt: string | null; - fromCache: boolean; -} - -export interface ApiEnvelope { - data: T; - meta: ApiResponseMeta; -} - -export interface StockMarketData { - price: number | null; - change: number | null; - changePercent: number | null; - open: number | null; - high: number | null; - low: number | null; - volume: number; - value: number; - issueCapitalization: number | null; - updatedAt: string; -} - -export interface ShareResponse { - secid: string; - isin: string; - name: string; - shortName: string; - latName: string | null; - listLevel: number; - issueSize: number; - faceValue: number; - faceUnit: string; - type: string; - marketData: StockMarketData; -} - -export interface DividendItem { - registryCloseDate: string; - value: number; - currency: string; -} - -export interface ShareHistoryItem { - date: string; - open: number; - high: number; - low: number; - close: number; - volume: number; - value: number; -} - -export interface BondMarketData { - price: number | null; - yieldToMaturity: number | null; - duration: number | null; - accruedInt: number | null; - couponValue: number | null; - couponPercent: number | null; - nextCouponDate: string | null; - open: number; - high: number | null; - low: number | null; - volume: number; - updatedAt: string; -} - -export interface BondResponse { - secid: string; - isin: string; - name: string; - shortName: string; - latName: string | null; - listLevel: number; - issueSize: number; - faceValue: number; - faceUnit: string; - matDate: string; - couponValue: number; - couponPercent: number | null; - couponPeriod: number; - nextCoupon: string | null; - accruedInt: number; - bondType: string; - bondSubType: string; - offerDate: string | null; - buybackDate: string | null; - marketData: BondMarketData; -} - -export interface BondHistoryItem { - date: string; - closePrice: number; - yieldClose: number | null; - duration: number | null; -} - -export interface CandleItem { - open: number; - high: number; - low: number; - close: number; - volume: number; - value: number; - begin: string; - end: string; -} - -export interface SearchResultItem { - secid: string; - isin: string; - shortName: string; - type: 'share' | 'bond'; - listLevel: number; - currency: string | null; - price: number | null; -} - -export interface HealthResponse { - status: string; - timestamp: string; - uptime: number; -} - -export interface UserResponse { - id: number; - email: string; - name: string | null; - role: string; -} - -export interface AuthResponse { - user: UserResponse; - accessToken: string; -} - -export interface Portfolio { - id: number; - name: string; - description: string | null; - currency: string; - createdAt: string; - updatedAt: string; - totalValue: number; - positionCount: number; - shareCount: number; - bondCount: number; -} - -export interface PositionWithPrice { - id: number; - portfolioId: number; - secid: string; - shortName: string | null; - type: 'share' | 'bond'; - quantity: number; - notes: string | null; - tags: string[] | null; - currentPrice: number | null; - buyPrice: number | null; - buyDate: string | null; - totalCost: number | null; - currentValue: number | null; - pnl: number | null; - pnlPercent: number | null; - dividendIncome: number | null; - totalReturn: number | null; - totalReturnPercent: number | null; - weightPercent: number; - change?: number | null; - changePercent?: number | null; - yieldToMaturity?: number | null; - duration?: number | null; - couponValue?: number | null; - couponPercent?: number | null; - nextCouponDate?: string | null; - matDate?: string | null; - accruedInt?: number | null; - bid?: number | null; - offer?: number | null; - couponPeriod?: number | null; - bondType?: string | null; - offerDate?: string | null; -} - -export interface PortfolioDetail extends Portfolio { - positions: PositionWithPrice[]; - totalValue: number; - analytics: PortfolioSummary; -} - -export interface Position { - id: number; - secid: string; - quantity: number; - notes: string | null; - tags: string[] | null; - portfolioId: number; - createdAt: string; - updatedAt: string; -} - -export interface PortfolioSummary { - totalInvested: number; - totalValue: number; - totalPnl: number; - totalPnlPercent: number | null; - totalDividends: number; - totalReturn: number; - totalReturnPercent: number | null; - positionCount: number; - weightedYield: number | null; -} - -export interface AnalyticsResponse { - positions: PositionWithPrice[]; - summary: PortfolioSummary; -} - -export interface ScreenerItem { - secid: string; - shortName: string; - isin: string; - type: 'share' | 'bond'; - price: number | null; - change: number | null; - changePercent: number | null; - volume: number; - listLevel: number; - capitalization: number | null; - yieldToMaturity: number | null; - duration: number | null; - couponValue: number | null; - couponPercent: number | null; - accruedInt: number | null; - matDate: string | null; - bondType: string | null; -} - -export interface ScreenerResult { - items: ScreenerItem[]; - total: number; - page: number; - pageSize: number; - totalPages: number; -} - -export interface BrokerMoney { - currency: string; - units: string; - nano: number; - value: number; -} - -export interface BrokerAccount { - id: string; - type: 'brokerage' | 'iis'; - name: string; - status: string; - openedAt: string | null; - accessLevel: string | null; -} - -export interface BrokerPosition { - figi: string | null; - instrumentUid: string | null; - positionUid: string | null; - ticker: string | null; - classCode: string | null; - instrumentType: string | null; - name: string | null; - quantity: number | null; - blockedLots: number | null; - currentPrice: BrokerMoney | null; - currentValue: BrokerMoney | null; - averagePositionPrice: BrokerMoney | null; - expectedYieldPercent: number | null; - dailyYield: BrokerMoney | null; -} - -export interface BrokerPortfolio { - account: BrokerAccount; - positionCounts: { - shares: number; - bonds: number; - etf: number; - other: number; - }; - totals: { - shares: BrokerMoney | null; - bonds: BrokerMoney | null; - etf: BrokerMoney | null; - currencies: BrokerMoney | null; - futures: BrokerMoney | null; - options: BrokerMoney | null; - structuredProducts: BrokerMoney | null; - dfa: BrokerMoney | null; - portfolio: BrokerMoney | null; - }; - yields: { - expectedPercent: number | null; - daily: BrokerMoney | null; - dailyPercent: number | null; - }; - cash: BrokerMoney[]; - blockedCash: BrokerMoney[]; - asOf: string; -} - -export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'; - -export interface BrokerOperation { - cursor: string | null; - accountId: string; - id: string | null; - parentOperationId: string | null; - date: string | null; - type: string; - category: BrokerOperationCategory; - description: string | null; - name: string | null; - state: string | null; - instrumentUid: string | null; - figi: string | null; - ticker: string | null; - classCode: string | null; - instrumentType: string | null; - payment: BrokerMoney | null; - price: BrokerMoney | null; - commission: BrokerMoney | null; - yield: BrokerMoney | null; - accruedInt: BrokerMoney | null; - quantity: number | null; - quantityDone: number | null; -} - -export interface BrokerOperationsPage { - accountId: string; - items: BrokerOperation[]; - nextCursor: string | null; - hasNext: boolean; - asOf: string; -} - -export interface BrokerPositionsPage { - accountId: string; - items: BrokerPosition[]; - nextCursor: string | null; - hasNext: boolean; - asOf: string; -} - -export interface BrokerPortfolioEvent { - id: string; - type: 'dividend' | 'coupon' | 'maturity' | 'offer'; - source: 'forecast' | 'actual'; - category: 'cashflow' | 'corporate'; - eventDate: string; - paymentDate: string | null; - ticker: string | null; - name: string | null; - instrumentUid: string | null; - instrumentType: 'share' | 'bond' | 'other'; - quantitySnapshot: number | null; - payoutPerUnit: number | null; - estimatedAmount: number | null; - actualAmount: number | null; - currency: string | null; - estimateMode: 'current_position' | null; -} - -export interface BrokerEventsSummary { - eventCount: number; - nearestEventDate: string | null; - totalEstimatedCashflow: number; - actualCashflow: number; - forecastEstimatedCashflow: number; - dividendsTotal: number; - couponsTotal: number; - principalRepaymentTotal: number; - actualDividendsTotal: number; - actualCouponsTotal: number; - actualPrincipalRepaymentTotal: number; -} - -export interface BrokerEventsData { - items: BrokerPortfolioEvent[]; - summary: BrokerEventsSummary; - asOf: string; -} diff --git a/apps/frontend/src/shared/api/types.ts b/apps/frontend/src/shared/api/types.ts index c551319..33b54c3 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -6,708 +6,970 @@ export interface paths { '/api/v1/health': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Проверка состояния сервиса */ - get: operations['HealthController_check']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['HealthController_check'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/auth/register': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never /** Register new user */ - post: operations['AuthController_register']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + post: operations['AuthController_register'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/auth/login': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never /** Login with email and password */ - post: operations['AuthController_login']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + post: operations['AuthController_login'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/auth/refresh': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never /** Refresh access token */ - post: operations['AuthController_refresh']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + post: operations['AuthController_refresh'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/auth/logout': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never /** Logout user */ - post: operations['AuthController_logout']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + post: operations['AuthController_logout'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/auth/me': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Get current user profile */ - get: operations['AuthController_getProfile']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; + get: operations['AuthController_getProfile'] + put?: never + post?: never + delete?: never + options?: never + head?: never /** Update current user profile */ - patch: operations['AuthController_updateProfile']; - trace?: never; - }; + patch: operations['AuthController_updateProfile'] + trace?: never + } '/api/v1/securities/search': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Поиск по инструментам */ - get: operations['SecuritiesController_search']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['SecuritiesController_search'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/screener': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Фильтр ценных бумаг по параметрам */ - get: operations['SecuritiesController_screener']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['SecuritiesController_screener'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/shares/{secid}': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить спецификацию акции */ - get: operations['SharesController_getShare']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['SharesController_getShare'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/shares/{secid}/marketdata': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить рыночные данные акции */ - get: operations['SharesController_getMarketData']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['SharesController_getMarketData'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/shares/{secid}/dividends': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить дивиденды */ - get: operations['SharesController_getDividends']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['SharesController_getDividends'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/shares/{secid}/history': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить дневную историю торгов акции */ - get: operations['SharesController_getHistory']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['SharesController_getHistory'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/bonds/{secid}': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить спецификацию облигации */ - get: operations['BondsController_getBond']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['BondsController_getBond'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/bonds/{secid}/marketdata': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить рыночные данные облигации */ - get: operations['BondsController_getMarketData']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['BondsController_getMarketData'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/bonds/{secid}/history': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить дневную историю торгов облигации */ - get: operations['BondsController_getHistory']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['BondsController_getHistory'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/shares/{secid}/candles': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить свечи акции */ - get: operations['CandlesController_getShareCandles']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['CandlesController_getShareCandles'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/securities/bonds/{secid}/candles': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Получить свечи облигации */ - get: operations['CandlesController_getBondCandles']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['CandlesController_getBondCandles'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/portfolios': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Get all portfolios for current user */ - get: operations['PortfolioController_findAll']; - put?: never; + get: operations['PortfolioController_findAll'] + put?: never /** Create a new portfolio */ - post: operations['PortfolioController_create']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + post: operations['PortfolioController_create'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/portfolios/{id}': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Get portfolio details with positions and prices */ - get: operations['PortfolioController_findOne']; - put?: never; - post?: never; + get: operations['PortfolioController_findOne'] + put?: never + post?: never /** Delete portfolio */ - delete: operations['PortfolioController_remove']; - options?: never; - head?: never; + delete: operations['PortfolioController_remove'] + options?: never + head?: never /** Update portfolio */ - patch: operations['PortfolioController_update']; - trace?: never; - }; + patch: operations['PortfolioController_update'] + trace?: never + } '/api/v1/portfolios/{id}/positions': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never /** Add position to portfolio */ - post: operations['PortfolioController_addPosition']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + post: operations['PortfolioController_addPosition'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/portfolios/{id}/positions/{positionId}': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post?: never /** Remove position from portfolio */ - delete: operations['PortfolioController_removePosition']; - options?: never; - head?: never; + delete: operations['PortfolioController_removePosition'] + options?: never + head?: never /** Update position */ - patch: operations['PortfolioController_updatePosition']; - trace?: never; - }; + patch: operations['PortfolioController_updatePosition'] + trace?: never + } '/api/v1/portfolios/{id}/analytics': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Get portfolio analytics with PnL */ - get: operations['PortfolioController_getAnalytics']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['PortfolioController_getAnalytics'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/broker/accounts': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Get open T-Bank brokerage and IIS accounts */ - get: operations['TBankController_getAccounts']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['TBankController_getAccounts'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/broker/accounts/{accountId}/portfolio': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Get T-Bank broker account portfolio with cash and positions */ - get: operations['TBankController_getPortfolio']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['TBankController_getPortfolio'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/broker/accounts/{accountId}/positions': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Get paginated T-Bank broker account positions */ - get: operations['TBankController_getPositions']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['TBankController_getPositions'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/broker/accounts/{accountId}/operations': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } /** Get paginated T-Bank broker account operations */ - get: operations['TBankController_getOperations']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + get: operations['TBankController_getOperations'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/api/v1/broker/accounts/{accountId}/events': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get broker account calendar events and cashflow */ + get: operations['TBankController_getEvents'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/broker/accounts/{accountId}/operations/sync': { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never /** Synchronize T-Bank broker account operations into local history */ - post: operations['TBankController_syncOperations']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + post: operations['TBankController_syncOperations'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } } -export type webhooks = Record; +export type webhooks = Record export interface components { schemas: { + ApiResponseMeta: { + cachedAt: string | null + fromCache: boolean + } + HealthResponseDto: { + /** @example ok */ + status: string + /** @example 2026-06-23T06:00:00.000Z */ + timestamp: string + /** @example 12345 */ + uptime: number + } + HealthEnvelopeDto: { + data: components['schemas']['HealthResponseDto'] + meta: components['schemas']['ApiResponseMeta'] + } RegisterDto: { /** @example user@example.com */ - email: string; + email: string /** @example securePass123 */ - password: string; + password: string /** @example John */ - name?: string; - }; + name?: string + } AuthUserDto: { - id: number; - email: string; - name: string | null; - role: string; - }; + id: number + email: string + name: string | null + role: string + } AuthTokenDataDto: { - user: components['schemas']['AuthUserDto']; - accessToken: string; - }; + user: components['schemas']['AuthUserDto'] + accessToken: string + } AuthResponseMetaDto: { - cachedAt: string | null; - fromCache: boolean; - }; + cachedAt: string | null + fromCache: boolean + } AuthTokenResponseDto: { - data: components['schemas']['AuthTokenDataDto']; - meta: components['schemas']['AuthResponseMetaDto']; - }; + data: components['schemas']['AuthTokenDataDto'] + meta: components['schemas']['AuthResponseMetaDto'] + } LoginDto: { /** @example user@example.com */ - email: string; + email: string /** @example securePass123 */ - password: string; - }; + password: string + } LogoutDataDto: { - message: string; - }; + message: string + } AuthLogoutResponseDto: { - data: components['schemas']['LogoutDataDto']; - meta: components['schemas']['AuthResponseMetaDto']; - }; + data: components['schemas']['LogoutDataDto'] + meta: components['schemas']['AuthResponseMetaDto'] + } AuthProfileResponseDto: { - data: components['schemas']['AuthUserDto']; - meta: components['schemas']['AuthResponseMetaDto']; - }; + data: components['schemas']['AuthUserDto'] + meta: components['schemas']['AuthResponseMetaDto'] + } UpdateProfileDto: { /** @example John Doe */ - name?: string; - }; + name?: string + } + SearchResultItemDto: { + /** @example SBER */ + secid: string + /** @example RU0009029540 */ + isin: string + /** @example Сбербанк */ + shortName: string + /** @enum {string} */ + type: 'share' | 'bond' + /** @example 1 */ + listLevel: number + /** @example RUB */ + currency?: string | null + /** @example 322.35 */ + price?: number | null + } + SearchEnvelopeDto: { + data: components['schemas']['SearchResultItemDto'][] + meta: components['schemas']['ApiResponseMeta'] + } ScreenerItemDto: { /** @example SBER */ - secid: string; + secid: string /** @example Сбербанк */ - shortName: string; + shortName: string /** @example RU0009029540 */ - isin: string; + isin: string /** @enum {string} */ - type: 'share' | 'bond'; + type: 'share' | 'bond' /** @example 322.35 */ - price?: number | null; + price?: number | null /** @example 1.15 */ - change?: number | null; + change?: number | null /** @example 0.36 */ - changePercent?: number | null; + changePercent?: number | null /** @example 1925163 */ - volume: number; + volume: number /** @example 1 */ - listLevel: number; + listLevel: number /** @example 6958336818320 */ - capitalization?: number | null; + capitalization?: number | null /** @example 12.71 */ - yieldToMaturity?: number | null; + yieldToMaturity?: number | null /** @example 4.5 */ - duration?: number | null; + duration?: number | null /** @example 40.64 */ - couponValue?: number | null; + couponValue?: number | null /** @example 8.15 */ - couponPercent?: number | null; + couponPercent?: number | null /** @example 29.48 */ - accruedInt?: number | null; + accruedInt?: number | null /** @example 2027-02-03 */ - matDate?: string | null; + matDate?: string | null /** @example ОФЗ-ПД */ - bondType?: string | null; - }; + bondType?: string | null + } ScreenerResultDto: { - items: components['schemas']['ScreenerItemDto'][]; - total: number; - page: number; - pageSize: number; - totalPages: number; - }; + items: components['schemas']['ScreenerItemDto'][] + total: number + page: number + pageSize: number + totalPages: number + } ScreenerResponseMetaDto: { - cachedAt: string | null; - fromCache: boolean; - }; + cachedAt: string | null + fromCache: boolean + } ScreenerResponseDto: { - data: components['schemas']['ScreenerResultDto']; - meta: components['schemas']['ScreenerResponseMetaDto']; - }; + data: components['schemas']['ScreenerResultDto'] + meta: components['schemas']['ScreenerResponseMetaDto'] + } + StockMarketDataDto: { + /** @example 322.35 */ + price: number + /** @example 1.15 */ + change: number + /** @example 0.36 */ + changePercent: number + /** @example 321.3 */ + open: number + /** @example 322.66 */ + high?: Record + /** @example 321.2 */ + low?: Record + /** @example 1925163 */ + volume: number + /** @example 620184479 */ + value: number + /** @example 6958336818320 */ + issueCapitalization?: Record + updatedAt: string + } + ShareResponseDto: { + /** @example SBER */ + secid: string + /** @example RU0009029540 */ + isin: string + /** @example Сбербанк России ПАО ао */ + name: string + /** @example Сбербанк */ + shortName: string + latName?: Record + /** @example 1 */ + listLevel: number + /** @example 21586948000 */ + issueSize: number + /** @example 3 */ + faceValue: number + /** @example RUB */ + faceUnit: string + /** @example common_share */ + type: string + marketData: components['schemas']['StockMarketDataDto'] + } + ShareEnvelopeDto: { + data: components['schemas']['ShareResponseDto'] + meta: components['schemas']['ApiResponseMeta'] + } + ShareMarketDataResponseDto: { + /** @example 322.35 */ + price: number + /** @example 1.15 */ + change: number + /** @example 0.36 */ + changePercent: number + /** @example 321.3 */ + open: number + /** @example 322.66 */ + high?: Record + /** @example 321.2 */ + low?: Record + /** @example 1925163 */ + volume: number + /** @example 620184479 */ + value: number + /** @example 6958336818320 */ + issueCapitalization?: Record + updatedAt: string + } + ShareMarketDataEnvelopeDto: { + data: components['schemas']['ShareMarketDataResponseDto'] + meta: components['schemas']['ApiResponseMeta'] + } + DividendItemDto: { + /** @example 2026-05-15 */ + registryCloseDate: string + /** @example 33.47 */ + value: number + /** @example RUB */ + currency: string + } + DividendsEnvelopeDto: { + data: components['schemas']['DividendItemDto'][] + meta: components['schemas']['ApiResponseMeta'] + } + HistoryItemDto: { + /** @example 2026-06-01 */ + date: string + /** @example 321.3 */ + open: number + /** @example 322.66 */ + high: number + /** @example 321.2 */ + low: number + /** @example 322.35 */ + close: number + /** @example 1925163 */ + volume: number + /** @example 620184479 */ + value: number + } + ShareHistoryEnvelopeDto: { + data: components['schemas']['HistoryItemDto'][] + meta: components['schemas']['ApiResponseMeta'] + } + BondMarketDataDto: { + /** + * @description Цена в % от номинала + * @example 100.45 + */ + price: number + /** @example 12.71 */ + yieldToMaturity?: Record + duration?: Record + /** @example 29.48 */ + accruedInt: number + /** @example 40.64 */ + couponValue: number + /** @example 8.15 */ + couponPercent?: Record + /** @example 2026-08-05 */ + nextCouponDate?: Record + open: number + high?: Record + low?: Record + volume: number + updatedAt: string + } + BondResponseDto: { + /** @example SU26207RMFS9 */ + secid: string + /** @example RU000A0JS3W6 */ + isin: string + /** @example ОФЗ-ПД 26207 03/02/27 */ + name: string + /** @example ОФЗ 26207 */ + shortName: string + latName?: Record + /** @example 1 */ + listLevel: number + /** @example 370200604 */ + issueSize: number + /** @example 1000 */ + faceValue: number + /** @example RUB */ + faceUnit: string + /** @example 2027-02-03 */ + matDate: string + /** @example 40.64 */ + couponValue: number + /** @example 8.15 */ + couponPercent?: Record + /** @example 182 */ + couponPeriod: number + /** @example 2026-08-05 */ + nextCoupon: Record + /** @example 29.48 */ + accruedInt: number + /** @example Фикс с известным купоном */ + bondType: string + /** @example До погашения */ + bondSubType: string + offerDate?: Record + buybackDate?: Record + marketData: components['schemas']['BondMarketDataDto'] + } + BondEnvelopeDto: { + data: components['schemas']['BondResponseDto'] + meta: components['schemas']['ApiResponseMeta'] + } + BondMarketDataEnvelopeDto: { + data: components['schemas']['BondMarketDataDto'] + meta: components['schemas']['ApiResponseMeta'] + } + BondHistoryItemDto: { + /** @example 2026-06-01 */ + date: string + /** @example 100.45 */ + closePrice: number + /** @example 12.71 */ + yieldClose?: number | null + /** @example 4.5 */ + duration?: number | null + } + BondHistoryEnvelopeDto: { + data: components['schemas']['BondHistoryItemDto'][] + meta: components['schemas']['ApiResponseMeta'] + } + CandleItemDto: { + /** @example 321.3 */ + open: number + /** @example 322.66 */ + high: number + /** @example 321.2 */ + low: number + /** @example 322.35 */ + close: number + /** @example 1925163 */ + volume: number + /** @example 620184479 */ + value: number + /** @example 2026-06-01T10:00:00 */ + begin: string + /** @example 2026-06-01T10:59:00 */ + end: string + } + CandleEnvelopeDto: { + data: components['schemas']['CandleItemDto'][] + meta: components['schemas']['ApiResponseMeta'] + } PortfolioResponseMetaDto: { - cachedAt: string | null; - fromCache: boolean; - }; + cachedAt: string | null + fromCache: boolean + } PortfolioListResponseDto: { - id: number; - name: string; - description?: string | null; + id: number + name: string + description?: string | null /** @default RUB */ - currency: string; - createdAt: string; - updatedAt: string; + currency: string + createdAt: string + updatedAt: string /** @description Total market value of all positions */ - totalValue: number; + totalValue: number /** @description Total number of positions */ - positionCount: number; + positionCount: number /** @description Number of share positions */ - shareCount: number; + shareCount: number /** @description Number of bond positions */ - bondCount: number; - }; + bondCount: number + } PortfolioListEnvelopeDto: { - data: components['schemas']['PortfolioListResponseDto'][]; - meta: components['schemas']['PortfolioResponseMetaDto']; - }; + data: components['schemas']['PortfolioListResponseDto'][] + meta: components['schemas']['PortfolioResponseMetaDto'] + } CreatePortfolioDto: { /** @example Мой портфель */ - name: string; + name: string /** @example Описание портфеля */ - description?: string; + description?: string /** * @default RUB * @enum {string} */ - currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; - }; + currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN' + } PortfolioResponseDto: { - id: number; - name: string; - description?: string | null; + id: number + name: string + description?: string | null /** @default RUB */ - currency: string; - createdAt: string; - updatedAt: string; - }; + currency: string + createdAt: string + updatedAt: string + } PortfolioEnvelopeDto: { - data: components['schemas']['PortfolioResponseDto']; - meta: components['schemas']['PortfolioResponseMetaDto']; - }; + data: components['schemas']['PortfolioResponseDto'] + meta: components['schemas']['PortfolioResponseMetaDto'] + } PositionWithPriceDto: { - id: number; + id: number /** @example SBER */ - secid: string; - shortName?: string | null; + secid: string + shortName?: string | null /** * @example share * @enum {string} */ - type: 'share' | 'bond'; + type: 'share' | 'bond' /** @example 10 */ - quantity: number; - buyPrice?: number | null; - buyDate?: string | null; - notes?: string | null; - tags?: string[] | null; - currentPrice?: number | null; - totalCost?: number | null; - currentValue?: number | null; - weightPercent: number; - pnl?: number | null; - pnlPercent?: number | null; - dividendIncome?: number | null; - totalReturn?: number | null; - totalReturnPercent?: number | null; - change?: number | null; - changePercent?: number | null; - yieldToMaturity?: number | null; - duration?: number | null; - couponValue?: number | null; - couponPercent?: number | null; - nextCouponDate?: string | null; - matDate?: string | null; - accruedInt?: number | null; - bid?: number | null; - offer?: number | null; - couponPeriod?: number | null; - bondType?: string | null; - offerDate?: string | null; - }; + quantity: number + buyPrice?: number | null + buyDate?: string | null + notes?: string | null + tags?: string[] | null + currentPrice?: number | null + totalCost?: number | null + currentValue?: number | null + weightPercent: number + pnl?: number | null + pnlPercent?: number | null + dividendIncome?: number | null + totalReturn?: number | null + totalReturnPercent?: number | null + change?: number | null + changePercent?: number | null + yieldToMaturity?: number | null + duration?: number | null + couponValue?: number | null + couponPercent?: number | null + nextCouponDate?: string | null + matDate?: string | null + accruedInt?: number | null + bid?: number | null + offer?: number | null + couponPeriod?: number | null + bondType?: string | null + offerDate?: string | null + } PortfolioSummaryDto: { - totalInvested: number; - totalValue: number; - totalPnl: number; - totalPnlPercent: number | null; - totalDividends: number; - totalReturn: number; - totalReturnPercent: number | null; - positionCount: number; - weightedYield: number | null; - }; + totalInvested: number + totalValue: number + totalPnl: number + totalPnlPercent: number | null + totalDividends: number + totalReturn: number + totalReturnPercent: number | null + positionCount: number + weightedYield: number | null + } PortfolioDetailResponseDto: { - id: number; - name: string; - description?: string | null; + id: number + name: string + description?: string | null /** @default RUB */ - currency: string; - createdAt: string; - updatedAt: string; - positions: components['schemas']['PositionWithPriceDto'][]; - totalValue: number; - analytics: components['schemas']['PortfolioSummaryDto']; - }; + currency: string + createdAt: string + updatedAt: string + positions: components['schemas']['PositionWithPriceDto'][] + totalValue: number + analytics: components['schemas']['PortfolioSummaryDto'] + } PortfolioDetailEnvelopeDto: { - data: components['schemas']['PortfolioDetailResponseDto']; - meta: components['schemas']['PortfolioResponseMetaDto']; - }; + data: components['schemas']['PortfolioDetailResponseDto'] + meta: components['schemas']['PortfolioResponseMetaDto'] + } UpdatePortfolioDto: { /** @example Мой портфель */ - name?: string; + name?: string /** @example Обновлённое описание */ - description?: string; + description?: string /** * @default RUB * @enum {string} */ - currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; - }; + currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN' + } AddPositionDto: { /** @example SBER */ - secid: string; + secid: string /** @example 10 */ - quantity: number; + quantity: number /** @example 250.5 */ - buyPrice?: number; + buyPrice?: number /** @example 2026-06-01 */ - buyDate?: string; + buyDate?: string /** @example Покупка на дип */ - notes?: string; + notes?: string /** * @example [ * "DIVIDEND", @@ -723,33 +985,33 @@ export interface components { | 'ETF' | 'GOVERNMENT' | 'CASH' - )[]; - }; + )[] + } PositionResponseDto: { - id: number; + id: number /** @example SBER */ - secid: string; + secid: string /** @example 10 */ - quantity: number; - notes?: string | null; - tags?: string[] | null; - portfolioId: number; - createdAt: string; - updatedAt: string; - }; + quantity: number + notes?: string | null + tags?: string[] | null + portfolioId: number + createdAt: string + updatedAt: string + } PositionEnvelopeDto: { - data: components['schemas']['PositionResponseDto']; - meta: components['schemas']['PortfolioResponseMetaDto']; - }; + data: components['schemas']['PositionResponseDto'] + meta: components['schemas']['PortfolioResponseMetaDto'] + } UpdatePositionDto: { /** @example 15 */ - quantity?: number; + quantity?: number /** @example 260 */ - buyPrice?: number; + buyPrice?: number /** @example 2026-06-15 */ - buyDate?: string; + buyDate?: string /** @example Докупка */ - notes?: string; + notes?: string /** * @example [ * "DIVIDEND" @@ -764,874 +1026,969 @@ export interface components { | 'ETF' | 'GOVERNMENT' | 'CASH' - )[]; - }; + )[] + } AnalyticsResponseDto: { - positions: components['schemas']['PositionWithPriceDto'][]; - summary: components['schemas']['PortfolioSummaryDto']; - }; + positions: components['schemas']['PositionWithPriceDto'][] + summary: components['schemas']['PortfolioSummaryDto'] + } AnalyticsEnvelopeDto: { - data: components['schemas']['AnalyticsResponseDto']; - meta: components['schemas']['PortfolioResponseMetaDto']; - }; + data: components['schemas']['AnalyticsResponseDto'] + meta: components['schemas']['PortfolioResponseMetaDto'] + } BrokerAccountResponseDto: { - id: string; + id: string /** @enum {string} */ - type: 'brokerage' | 'iis'; - name: string; - status: string; - openedAt: Record | null; - accessLevel: Record | null; - }; + type: 'brokerage' | 'iis' + name: string + status: string + openedAt: Record | null + accessLevel: Record | null + } BrokerResponseMetaDto: { - cachedAt: Record | null; - fromCache: boolean; - }; + cachedAt: Record | null + fromCache: boolean + } BrokerAccountsEnvelopeDto: { - data: components['schemas']['BrokerAccountResponseDto'][]; - meta: components['schemas']['BrokerResponseMetaDto']; - }; + data: components['schemas']['BrokerAccountResponseDto'][] + meta: components['schemas']['BrokerResponseMetaDto'] + } BrokerPortfolioPositionCountsDto: { - shares: number; - bonds: number; - etf: number; - other: number; - }; + shares: number + bonds: number + etf: number + other: number + } BrokerMoneyDto: { - currency: string; - units: string; - nano: number; - value: number; - }; + currency: string + units: string + nano: number + value: number + } BrokerPortfolioTotalsDto: { - shares: components['schemas']['BrokerMoneyDto'] | null; - bonds: components['schemas']['BrokerMoneyDto'] | null; - etf: components['schemas']['BrokerMoneyDto'] | null; - currencies: components['schemas']['BrokerMoneyDto'] | null; - futures: components['schemas']['BrokerMoneyDto'] | null; - options: components['schemas']['BrokerMoneyDto'] | null; - structuredProducts: components['schemas']['BrokerMoneyDto'] | null; - dfa: components['schemas']['BrokerMoneyDto'] | null; - portfolio: components['schemas']['BrokerMoneyDto'] | null; - }; + shares: components['schemas']['BrokerMoneyDto'] | null + bonds: components['schemas']['BrokerMoneyDto'] | null + etf: components['schemas']['BrokerMoneyDto'] | null + currencies: components['schemas']['BrokerMoneyDto'] | null + futures: components['schemas']['BrokerMoneyDto'] | null + options: components['schemas']['BrokerMoneyDto'] | null + structuredProducts: components['schemas']['BrokerMoneyDto'] | null + dfa: components['schemas']['BrokerMoneyDto'] | null + portfolio: components['schemas']['BrokerMoneyDto'] | null + } BrokerPortfolioYieldsDto: { - expectedPercent: Record | null; - daily: components['schemas']['BrokerMoneyDto'] | null; - dailyPercent: Record | null; - }; + expectedPercent: Record | null + daily: components['schemas']['BrokerMoneyDto'] | null + dailyPercent: Record | null + } BrokerPortfolioResponseDto: { - account: components['schemas']['BrokerAccountResponseDto']; - positionCounts: components['schemas']['BrokerPortfolioPositionCountsDto']; - totals: components['schemas']['BrokerPortfolioTotalsDto']; - yields: components['schemas']['BrokerPortfolioYieldsDto']; - cash: components['schemas']['BrokerMoneyDto'][]; - blockedCash: components['schemas']['BrokerMoneyDto'][]; - asOf: string; - }; + account: components['schemas']['BrokerAccountResponseDto'] + positionCounts: components['schemas']['BrokerPortfolioPositionCountsDto'] + totals: components['schemas']['BrokerPortfolioTotalsDto'] + yields: components['schemas']['BrokerPortfolioYieldsDto'] + cash: components['schemas']['BrokerMoneyDto'][] + blockedCash: components['schemas']['BrokerMoneyDto'][] + asOf: string + } BrokerPortfolioEnvelopeDto: { - data: components['schemas']['BrokerPortfolioResponseDto']; - meta: components['schemas']['BrokerResponseMetaDto']; - }; + data: components['schemas']['BrokerPortfolioResponseDto'] + meta: components['schemas']['BrokerResponseMetaDto'] + } BrokerPositionResponseDto: { - figi: Record | null; - instrumentUid: Record | null; - positionUid: Record | null; - ticker: Record | null; - classCode: Record | null; - instrumentType: Record | null; - name: Record | null; - quantity: Record | null; - blockedLots: Record | null; - currentPrice: components['schemas']['BrokerMoneyDto'] | null; - currentValue: components['schemas']['BrokerMoneyDto'] | null; - averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null; - expectedYieldPercent: Record | null; - dailyYield: components['schemas']['BrokerMoneyDto'] | null; - }; + figi: Record | null + instrumentUid: Record | null + positionUid: Record | null + ticker: Record | null + classCode: Record | null + instrumentType: Record | null + name: Record | null + quantity: Record | null + blockedLots: Record | null + currentPrice: components['schemas']['BrokerMoneyDto'] | null + currentValue: components['schemas']['BrokerMoneyDto'] | null + averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null + expectedYieldPercent: Record | null + dailyYield: components['schemas']['BrokerMoneyDto'] | null + } BrokerPositionsPageResponseDto: { - accountId: string; - items: components['schemas']['BrokerPositionResponseDto'][]; - nextCursor: Record | null; - hasNext: boolean; - asOf: string; - }; + accountId: string + items: components['schemas']['BrokerPositionResponseDto'][] + nextCursor: Record | null + hasNext: boolean + asOf: string + } BrokerPositionsEnvelopeDto: { - data: components['schemas']['BrokerPositionsPageResponseDto']; - meta: components['schemas']['BrokerResponseMetaDto']; - }; + data: components['schemas']['BrokerPositionsPageResponseDto'] + meta: components['schemas']['BrokerResponseMetaDto'] + } BrokerOperationResponseDto: { - cursor: Record | null; - accountId: string; - id: Record | null; - parentOperationId: Record | null; - date: Record | null; - type: string; + cursor: Record | null + accountId: string + id: Record | null + parentOperationId: Record | null + date: Record | null + type: string /** @enum {string} */ - category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'; - description: Record | null; - name: Record | null; - state: Record | null; - instrumentUid: Record | null; - figi: Record | null; - ticker: Record | null; - classCode: Record | null; - instrumentType: Record | null; - payment: components['schemas']['BrokerMoneyDto'] | null; - price: components['schemas']['BrokerMoneyDto'] | null; - commission: components['schemas']['BrokerMoneyDto'] | null; - yield: components['schemas']['BrokerMoneyDto'] | null; - accruedInt: components['schemas']['BrokerMoneyDto'] | null; - quantity: Record | null; - quantityDone: Record | null; - }; + category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other' + description: Record | null + name: Record | null + state: Record | null + instrumentUid: Record | null + figi: Record | null + ticker: Record | null + classCode: Record | null + instrumentType: Record | null + payment: components['schemas']['BrokerMoneyDto'] | null + price: components['schemas']['BrokerMoneyDto'] | null + commission: components['schemas']['BrokerMoneyDto'] | null + yield: components['schemas']['BrokerMoneyDto'] | null + accruedInt: components['schemas']['BrokerMoneyDto'] | null + quantity: Record | null + quantityDone: Record | null + } BrokerOperationsPageResponseDto: { - accountId: string; - items: components['schemas']['BrokerOperationResponseDto'][]; - nextCursor: Record | null; - hasNext: boolean; - asOf: string; - }; + accountId: string + items: components['schemas']['BrokerOperationResponseDto'][] + nextCursor: Record | null + hasNext: boolean + asOf: string + } BrokerOperationsEnvelopeDto: { - data: components['schemas']['BrokerOperationsPageResponseDto']; - meta: components['schemas']['BrokerResponseMetaDto']; - }; + data: components['schemas']['BrokerOperationsPageResponseDto'] + meta: components['schemas']['BrokerResponseMetaDto'] + } + BrokerEventItemDto: { + id: string + /** @enum {string} */ + type: 'dividend' | 'coupon' | 'maturity' | 'offer' + /** @enum {string} */ + source: 'forecast' | 'actual' + /** @enum {string} */ + category: 'cashflow' | 'corporate' + eventDate: string + paymentDate: string | null + ticker: string | null + name: string | null + instrumentUid: string | null + /** @enum {string} */ + instrumentType: 'share' | 'bond' | 'other' + quantitySnapshot: number | null + payoutPerUnit: number | null + estimatedAmount: number | null + actualAmount: number | null + currency: string | null + /** @enum {string|null} */ + estimateMode: 'current_position' | null + } + BrokerEventsSummaryDto: { + eventCount: number + nearestEventDate: string | null + totalEstimatedCashflow: number + actualCashflow: number + forecastEstimatedCashflow: number + dividendsTotal: number + couponsTotal: number + principalRepaymentTotal: number + actualDividendsTotal: number + actualCouponsTotal: number + actualPrincipalRepaymentTotal: number + } + BrokerEventsDataDto: { + items: components['schemas']['BrokerEventItemDto'][] + summary: components['schemas']['BrokerEventsSummaryDto'] + asOf: string + } + BrokerEventsEnvelopeDto: { + data: components['schemas']['BrokerEventsDataDto'] + meta: components['schemas']['BrokerResponseMetaDto'] + } BrokerOperationSyncResponseDto: { /** @example 42 */ - upserted: number; - }; + upserted: number + } BrokerOperationSyncEnvelopeDto: { - data: components['schemas']['BrokerOperationSyncResponseDto']; - meta: components['schemas']['BrokerResponseMetaDto']; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; + data: components['schemas']['BrokerOperationSyncResponseDto'] + meta: components['schemas']['BrokerResponseMetaDto'] + } + } + responses: never + parameters: never + requestBodies: never + headers: never + pathItems: never } -export type $defs = Record; +export type $defs = Record export interface operations { HealthController_check: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['HealthEnvelopeDto'] + } + } + } + } AuthController_register: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } requestBody: { content: { - 'application/json': components['schemas']['RegisterDto']; - }; - }; + 'application/json': components['schemas']['RegisterDto'] + } + } responses: { 201: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['AuthTokenResponseDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['AuthTokenResponseDto'] + } + } + } + } AuthController_login: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } requestBody: { content: { - 'application/json': components['schemas']['LoginDto']; - }; - }; + 'application/json': components['schemas']['LoginDto'] + } + } responses: { 201: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['AuthTokenResponseDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['AuthTokenResponseDto'] + } + } + } + } AuthController_refresh: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['AuthTokenResponseDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['AuthTokenResponseDto'] + } + } + } + } AuthController_logout: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['AuthLogoutResponseDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['AuthLogoutResponseDto'] + } + } + } + } AuthController_getProfile: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['AuthProfileResponseDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['AuthProfileResponseDto'] + } + } + } + } AuthController_updateProfile: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } requestBody: { content: { - 'application/json': components['schemas']['UpdateProfileDto']; - }; - }; + 'application/json': components['schemas']['UpdateProfileDto'] + } + } responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['AuthProfileResponseDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['AuthProfileResponseDto'] + } + } + } + } SecuritiesController_search: { parameters: { query: { /** @description Поисковый запрос (тикер, название, ISIN) */ - q: string; - type?: 'all' | 'share' | 'bond'; - limit?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; + q: string + type?: 'all' | 'share' | 'bond' + limit?: number + } + header?: never + path?: never + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['SearchEnvelopeDto'] + } + } + } + } SecuritiesController_screener: { parameters: { query: { - type: 'share' | 'bond'; - priceMin?: number; - priceMax?: number; - volumeMin?: number; - listLevel?: number; - changePercentMin?: number; - changePercentMax?: number; - capitalizationMin?: number; - yieldMin?: number; - yieldMax?: number; - durationMin?: number; - durationMax?: number; - couponMin?: number; - couponMax?: number; - couponPercentMin?: number; - couponPercentMax?: number; - maturityBefore?: string; - maturityAfter?: string; - bondType?: string; - sortBy?: string; - sortOrder?: string; - page?: number; - pageSize?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; + type: 'share' | 'bond' + priceMin?: number + priceMax?: number + volumeMin?: number + listLevel?: number + changePercentMin?: number + changePercentMax?: number + capitalizationMin?: number + yieldMin?: number + yieldMax?: number + durationMin?: number + durationMax?: number + couponMin?: number + couponMax?: number + couponPercentMin?: number + couponPercentMax?: number + maturityBefore?: string + maturityAfter?: string + bondType?: string + sortBy?: string + sortOrder?: string + page?: number + pageSize?: number + } + header?: never + path?: never + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['ScreenerResponseDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['ScreenerResponseDto'] + } + } + } + } SharesController_getShare: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ShareEnvelopeDto'] + } + } + } + } SharesController_getMarketData: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ShareMarketDataEnvelopeDto'] + } + } + } + } SharesController_getDividends: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['DividendsEnvelopeDto'] + } + } + } + } SharesController_getHistory: { parameters: { query: { - from: string; - till: string; - }; - header?: never; + from: string + till: string + } + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ShareHistoryEnvelopeDto'] + } + } + } + } BondsController_getBond: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BondEnvelopeDto'] + } + } + } + } BondsController_getMarketData: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BondMarketDataEnvelopeDto'] + } + } + } + } BondsController_getHistory: { parameters: { query: { - from: string; - till: string; - }; - header?: never; + from: string + till: string + } + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BondHistoryEnvelopeDto'] + } + } + } + } CandlesController_getShareCandles: { parameters: { query: { - interval: '1h' | '24h'; - from: string; - till: string; - }; - header?: never; + interval: '1h' | '24h' + from: string + till: string + } + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['CandleEnvelopeDto'] + } + } + } + } CandlesController_getBondCandles: { parameters: { query: { - interval: '1h' | '24h'; - from: string; - till: string; - }; - header?: never; + interval: '1h' | '24h' + from: string + till: string + } + header?: never path: { - secid: string; - }; - cookie?: never; - }; - requestBody?: never; + secid: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['CandleEnvelopeDto'] + } + } + } + } PortfolioController_findAll: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['PortfolioListEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['PortfolioListEnvelopeDto'] + } + } + } + } PortfolioController_create: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + query?: never + header?: never + path?: never + cookie?: never + } requestBody: { content: { - 'application/json': components['schemas']['CreatePortfolioDto']; - }; - }; + 'application/json': components['schemas']['CreatePortfolioDto'] + } + } responses: { 201: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['PortfolioEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['PortfolioEnvelopeDto'] + } + } + } + } PortfolioController_findOne: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - id: number; - }; - cookie?: never; - }; - requestBody?: never; + id: number + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['PortfolioDetailEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['PortfolioDetailEnvelopeDto'] + } + } + } + } PortfolioController_remove: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - id: number; - }; - cookie?: never; - }; - requestBody?: never; + id: number + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { 'application/json': { - data: null; - meta: components['schemas']['PortfolioResponseMetaDto']; - }; - }; - }; - }; - }; + data: null + meta: components['schemas']['PortfolioResponseMetaDto'] + } + } + } + } + } PortfolioController_update: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - id: number; - }; - cookie?: never; - }; + id: number + } + cookie?: never + } requestBody: { content: { - 'application/json': components['schemas']['UpdatePortfolioDto']; - }; - }; + 'application/json': components['schemas']['UpdatePortfolioDto'] + } + } responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['PortfolioEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['PortfolioEnvelopeDto'] + } + } + } + } PortfolioController_addPosition: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - id: number; - }; - cookie?: never; - }; + id: number + } + cookie?: never + } requestBody: { content: { - 'application/json': components['schemas']['AddPositionDto']; - }; - }; + 'application/json': components['schemas']['AddPositionDto'] + } + } responses: { 201: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['PositionEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['PositionEnvelopeDto'] + } + } + } + } PortfolioController_removePosition: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - id: number; - positionId: number; - }; - cookie?: never; - }; - requestBody?: never; + id: number + positionId: number + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { 'application/json': { - data: null; - meta: components['schemas']['PortfolioResponseMetaDto']; - }; - }; - }; - }; - }; + data: null + meta: components['schemas']['PortfolioResponseMetaDto'] + } + } + } + } + } PortfolioController_updatePosition: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - id: number; - positionId: number; - }; - cookie?: never; - }; + id: number + positionId: number + } + cookie?: never + } requestBody: { content: { - 'application/json': components['schemas']['UpdatePositionDto']; - }; - }; + 'application/json': components['schemas']['UpdatePositionDto'] + } + } responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['PositionEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['PositionEnvelopeDto'] + } + } + } + } PortfolioController_getAnalytics: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - id: number; - }; - cookie?: never; - }; - requestBody?: never; + id: number + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['AnalyticsEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['AnalyticsEnvelopeDto'] + } + } + } + } TBankController_getAccounts: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['BrokerAccountsEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['BrokerAccountsEnvelopeDto'] + } + } + } + } TBankController_getPortfolio: { parameters: { - query?: never; - header?: never; + query?: never + header?: never path: { - accountId: string; - }; - cookie?: never; - }; - requestBody?: never; + accountId: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['BrokerPortfolioEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['BrokerPortfolioEnvelopeDto'] + } + } + } + } TBankController_getPositions: { parameters: { query?: { /** @description Cursor for pagination (positionUid) */ - cursor?: string; - limit?: number; + cursor?: string + limit?: number /** @description Filter by instrument type (share, bond, etf, etc.) */ - type?: string; - }; - header?: never; + type?: string + } + header?: never path: { - accountId: string; - }; - cookie?: never; - }; - requestBody?: never; + accountId: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['BrokerPositionsEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['BrokerPositionsEnvelopeDto'] + } + } + } + } TBankController_getOperations: { parameters: { query?: { - from?: string; - to?: string; - cursor?: string; - limit?: number; - instrumentId?: string; - operationTypes?: string; - state?: string; - }; - header?: never; + from?: string + to?: string + cursor?: string + limit?: number + instrumentId?: string + operationTypes?: string + state?: string + } + header?: never path: { - accountId: string; - }; - cookie?: never; - }; - requestBody?: never; + accountId: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['BrokerOperationsEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['BrokerOperationsEnvelopeDto'] + } + } + } + } + TBankController_getEvents: { + parameters: { + query: { + /** @description Start date inclusive (YYYY-MM-DD) */ + from: string + /** @description End date inclusive (YYYY-MM-DD) */ + to: string + /** @description Comma-separated event types to include */ + types?: string + } + header?: never + path: { + accountId: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BrokerEventsEnvelopeDto'] + } + } + } + } TBankController_syncOperations: { parameters: { query: { - from: string; - to: string; - }; - header?: never; + from: string + to: string + } + header?: never path: { - accountId: string; - }; - cookie?: never; - }; - requestBody?: never; + accountId: string + } + cookie?: never + } + requestBody?: never responses: { 200: { headers: { - [name: string]: unknown; - }; + [name: string]: unknown + } content: { - 'application/json': components['schemas']['BrokerOperationSyncEnvelopeDto']; - }; - }; - }; - }; + 'application/json': components['schemas']['BrokerOperationSyncEnvelopeDto'] + } + } + } + } } diff --git a/apps/frontend/src/shared/config/env.ts b/apps/frontend/src/shared/config/env.ts new file mode 100644 index 0000000..4e2d1b9 --- /dev/null +++ b/apps/frontend/src/shared/config/env.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +const envSchema = z.object({ + VITE_API_MOCK: z + .enum(['true', 'false']) + .optional() + .default('false') + .transform((v) => v === 'true'), + VITE_API_URL: z.string().optional().default('http://localhost:3000'), +}) + +const parsed = envSchema.safeParse(import.meta.env) + +if (!parsed.success) { + console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors) + throw new Error('Invalid environment variables — check console for details') +} + +export const env = parsed.data diff --git a/apps/frontend/src/shared/lib/dates/index.ts b/apps/frontend/src/shared/lib/dates/index.ts index 7e7d85c..d9306c8 100644 --- a/apps/frontend/src/shared/lib/dates/index.ts +++ b/apps/frontend/src/shared/lib/dates/index.ts @@ -1,14 +1,14 @@ -import dayjs from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime'; -import 'dayjs/locale/ru'; +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import 'dayjs/locale/ru' -dayjs.extend(relativeTime); -dayjs.locale('ru'); +dayjs.extend(relativeTime) +dayjs.locale('ru') export const formatDate = (value: string | number | Date, format = 'DD.MM.YYYY'): string => { - return dayjs(value).format(format); -}; + return dayjs(value).format(format) +} export const formatRelative = (value: string | number | Date): string => { - return dayjs(value).fromNow(); -}; + return dayjs(value).fromNow() +} diff --git a/apps/frontend/src/shared/lib/formatters.ts b/apps/frontend/src/shared/lib/formatters.ts index a634c20..f528fcf 100644 --- a/apps/frontend/src/shared/lib/formatters.ts +++ b/apps/frontend/src/shared/lib/formatters.ts @@ -1,89 +1,89 @@ -import type { BrokerMoney } from '@/shared/api/responses'; +import type { BrokerMoney } from '@/shared/api' export function formatBrokerCurrencyValue(currency: string, value: number): string { return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: currency || 'RUB', maximumFractionDigits: 2, - }).format(value); + }).format(value) } export function formatBrokerMoney(value: BrokerMoney | null | undefined): string { if (!value) { - return '—'; + return '—' } - return formatBrokerCurrencyValue(value.currency, value.value); + return formatBrokerCurrencyValue(value.currency, value.value) } export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string { if (value === null) { - return '—'; + return '—' } - const formatted = formatBrokerCurrencyValue(currency, Math.abs(value)); + const formatted = formatBrokerCurrencyValue(currency, Math.abs(value)) if (value > 0) { - return `+${formatted}`; + return `+${formatted}` } if (value < 0) { - return `\u2212${formatted}`; + return `\u2212${formatted}` } - return formatted; + return formatted } export function formatBrokerSignedMoney(value: BrokerMoney | null | undefined): string { if (!value) { - return '\u2014'; + return '\u2014' } - return formatBrokerSignedCurrencyValue(value.currency, value.value); + return formatBrokerSignedCurrencyValue(value.currency, value.value) } export function formatBrokerPercent(value: number | null): string { if (value === null) { - return '—'; + return '—' } - return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`; + return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%` } export function formatBrokerSignedPercent(value: number | null): string { if (value === null) { - return '—'; + return '—' } - const formatted = formatBrokerPercent(Math.abs(value)); + const formatted = formatBrokerPercent(Math.abs(value)) if (value > 0) { - return `+${formatted}`; + return `+${formatted}` } if (value < 0) { - return `\u2212${formatted}`; + return `\u2212${formatted}` } - return formatted; + return formatted } export function formatBrokerDate(value: string | null | undefined): string | null { if (!value) { - return null; + return null } - return new Date(value).toLocaleDateString('ru-RU'); + return new Date(value).toLocaleDateString('ru-RU') } export function pluralize(n: number, one: string, few: string, many: string): string { - const abs = Math.abs(n); - const modulo100 = abs % 100; - const modulo10 = modulo100 % 10; + const abs = Math.abs(n) + const modulo100 = abs % 100 + const modulo10 = modulo100 % 10 - if (modulo100 > 10 && modulo100 < 20) return many; - if (modulo10 === 1) return one; - if (modulo10 >= 2 && modulo10 <= 4) return few; + if (modulo100 > 10 && modulo100 < 20) return many + if (modulo10 === 1) return one + if (modulo10 >= 2 && modulo10 <= 4) return few - return many; + return many } diff --git a/apps/frontend/src/shared/lib/router/useSearchParams.ts b/apps/frontend/src/shared/lib/router/useSearchParams.ts new file mode 100644 index 0000000..161969a --- /dev/null +++ b/apps/frontend/src/shared/lib/router/useSearchParams.ts @@ -0,0 +1,27 @@ +import { useLocation, useNavigate } from '@tanstack/react-router' +import { useCallback, useMemo } from 'react' + +export function useSearchParamsCompat() { + const navigate = useNavigate() + const searchStr = useLocation().searchStr as string | undefined + + const searchParams = useMemo( + () => new URLSearchParams(searchStr?.replace(/^\?/, '') ?? ''), + [searchStr], + ) + + const setSearchParams = useCallback( + (updater: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams)) => { + const current = new URLSearchParams(window.location.search) + const next = typeof updater === 'function' ? updater(current) : updater + const search: Record = {} + next.forEach((value, key) => { + search[key] = value + }) + navigate({ search, replace: true }) + }, + [navigate], + ) + + return [searchParams, setSearchParams] as const +} diff --git a/apps/frontend/src/shared/lib/session-context.ts b/apps/frontend/src/shared/lib/session-context.ts index 36ef2b9..f9a5179 100644 --- a/apps/frontend/src/shared/lib/session-context.ts +++ b/apps/frontend/src/shared/lib/session-context.ts @@ -1,15 +1,15 @@ -import { createContext } from 'react'; -import type { UserResponse } from '@/shared/api/responses'; +import { createContext } from 'react' +import type { UserResponse } from '@/shared/api' export interface SessionContextValue { - user: UserResponse | null; - accessToken: string | null; - isAuthenticated: boolean; - isLoading: boolean; - login: (email: string, password: string) => Promise; - register: (email: string, password: string, name?: string) => Promise; - logout: () => Promise; - updateProfile: (data: { name?: string }) => Promise; + user: UserResponse | null + accessToken: string | null + isAuthenticated: boolean + isLoading: boolean + login: (email: string, password: string) => Promise + register: (email: string, password: string, name?: string) => Promise + logout: () => Promise + updateProfile: (data: { name?: string }) => Promise } -export const SessionContext = createContext(null); +export const SessionContext = createContext(null) diff --git a/apps/frontend/src/shared/lib/styles/clsx.ts b/apps/frontend/src/shared/lib/styles/clsx.ts index 1f75838..35251f1 100644 --- a/apps/frontend/src/shared/lib/styles/clsx.ts +++ b/apps/frontend/src/shared/lib/styles/clsx.ts @@ -1,5 +1,5 @@ -import { clsx, type ClassValue } from 'clsx'; +import { type ClassValue, clsx } from 'clsx' export function cn(...inputs: ClassValue[]) { - return clsx(inputs); + return clsx(inputs) } diff --git a/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx b/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx index f13b5ef..527c610 100644 --- a/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx +++ b/apps/frontend/src/shared/lib/test/TestSessionProvider.tsx @@ -1,56 +1,56 @@ -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'; +import { type ReactNode, useCallback, useEffect, useState } from 'react' +import * as sessionApi from '@/entities/session/api/sessionApi' +import { SessionContext, type SessionContextValue } from '@/shared/lib/session-context' export function TestSessionProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [accessToken, setAccessToken] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [user, setUser] = useState(null) + const [accessToken, setAccessToken] = useState(null) + const [isLoading, setIsLoading] = useState(true) useEffect(() => { - let cancelled = false; + let cancelled = false sessionApi .refresh() .then((result) => { if (!cancelled) { - setUser(result.user); - setAccessToken(result.accessToken); + setUser(result.user) + setAccessToken(result.accessToken) } }) .catch(() => {}) .finally(() => { - if (!cancelled) setIsLoading(false); - }); + if (!cancelled) setIsLoading(false) + }) return () => { - cancelled = true; - }; - }, []); + cancelled = true + } + }, []) const login = useCallback(async (email: string, password: string) => { - const result = await sessionApi.login(email, password); - setUser(result.user); - setAccessToken(result.accessToken); - }, []); + 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 result = await sessionApi.register(email, password, name) + setUser(result.user) + setAccessToken(result.accessToken) + }, []) const logout = useCallback(async () => { - await sessionApi.logout(); - setUser(null); - setAccessToken(null); - }, []); + await sessionApi.logout() + setUser(null) + setAccessToken(null) + }, []) const updateProfile = useCallback(async (data: { name?: string }) => { - const updated = await sessionApi.updateProfile(data); - setUser(updated); - }, []); + const updated = await sessionApi.updateProfile(data) + setUser(updated) + }, []) if (isLoading) { - return
Загрузка...
; + return
Загрузка...
} const value: SessionContextValue = { @@ -62,7 +62,7 @@ export function TestSessionProvider({ children }: { children: ReactNode }) { register, logout, updateProfile, - }; + } - return {children}; + return {children} } diff --git a/apps/frontend/src/shared/lib/test/factories.ts b/apps/frontend/src/shared/lib/test/factories.ts index af91dde..82e474d 100644 --- a/apps/frontend/src/shared/lib/test/factories.ts +++ b/apps/frontend/src/shared/lib/test/factories.ts @@ -1,14 +1,14 @@ import type { - ShareResponse, - BondResponse, + AuthResponse, BondMarketData, - StockMarketData, + BondResponse, CandleItem, DividendItem, SearchResultItem, + ShareResponse, + StockMarketData, UserResponse, - AuthResponse, -} from '@/shared/api/responses'; +} from '@/shared/api' export function createMockMarketData(overrides: Partial = {}): StockMarketData { return { @@ -23,7 +23,7 @@ export function createMockMarketData(overrides: Partial = {}): issueCapitalization: 6250000000000, updatedAt: '2024-01-15T10:00:00Z', ...overrides, - }; + } } export function createMockShare(overrides: Partial = {}): ShareResponse { @@ -40,7 +40,7 @@ export function createMockShare(overrides: Partial = {}): ShareRe type: 'common_share', marketData: createMockMarketData(), ...overrides, - }; + } } export function createMockBondMarketData(overrides: Partial = {}): BondMarketData { @@ -58,7 +58,7 @@ export function createMockBondMarketData(overrides: Partial = {} volume: 1000000, updatedAt: '2024-01-15T10:00:00Z', ...overrides, - }; + } } export function createMockBond(overrides: Partial = {}): BondResponse { @@ -84,7 +84,7 @@ export function createMockBond(overrides: Partial = {}): BondRespo buybackDate: null, marketData: createMockBondMarketData(), ...overrides, - }; + } } export function createMockUser(overrides: Partial = {}): UserResponse { @@ -94,7 +94,7 @@ export function createMockUser(overrides: Partial = {}): UserRespo name: 'Test User', role: 'user', ...overrides, - }; + } } export function createMockAuth(overrides: Partial = {}): AuthResponse { @@ -102,7 +102,7 @@ export function createMockAuth(overrides: Partial = {}): AuthRespo user: createMockUser(), accessToken: 'mock-token', ...overrides, - }; + } } export function createMockCandles(count = 2): CandleItem[] { @@ -115,11 +115,11 @@ export function createMockCandles(count = 2): CandleItem[] { value: 280000000, begin: `2024-01-${15 + i}T10:00:00Z`, end: `2024-01-${15 + i}T18:00:00Z`, - })); + })) } export function createMockDividends(): DividendItem[] { - return [{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }]; + return [{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }] } export function createMockSearchResults(): SearchResultItem[] { @@ -142,5 +142,5 @@ export function createMockSearchResults(): SearchResultItem[] { currency: 'RUB', price: 0.0234, }, - ]; + ] } diff --git a/apps/frontend/src/shared/lib/test/handlers.ts b/apps/frontend/src/shared/lib/test/handlers.ts deleted file mode 100644 index bceb62e..0000000 --- a/apps/frontend/src/shared/lib/test/handlers.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import type { - ShareResponse, - BondResponse, - CandleItem, - SearchResultItem, -} from '@/shared/api/responses'; - -const API = '/api/v1'; - -const mockShare: ShareResponse = { - secid: 'SBER', - isin: 'RU0009029540', - name: 'Сбер Банк', - shortName: 'Сбер', - latName: 'Sberbank', - listLevel: 1, - issueSize: 21586900000, - faceValue: 3, - faceUnit: 'RUB', - type: 'common_share', - marketData: { - price: 289.5, - change: 2.5, - changePercent: 0.87, - open: 287, - high: 291, - low: 286.5, - volume: 15000000, - value: 4350000000, - issueCapitalization: 6250000000000, - updatedAt: '2024-01-15T10:00:00Z', - }, -}; - -const mockBond: BondResponse = { - secid: 'SU26238RMFS5', - isin: 'RU000A101XU7', - name: 'ОФЗ 26238', - shortName: 'ОФЗ 26238', - latName: null, - listLevel: 1, - issueSize: 500000000, - faceValue: 1000, - faceUnit: 'RUB', - matDate: '2027-05-15', - couponValue: 36.9, - couponPercent: 7.5, - couponPeriod: 182, - nextCoupon: '2024-07-15', - accruedInt: 8.45, - bondType: 'ОФЗ', - bondSubType: 'ОФЗ-ПД', - offerDate: null, - buybackDate: null, - marketData: { - price: 98.5, - yieldToMaturity: 8.2, - duration: 3.5, - accruedInt: 8.45, - couponValue: 36.9, - couponPercent: 7.5, - nextCouponDate: '2024-07-15', - open: 98.0, - high: 99.0, - low: 97.5, - volume: 1000000, - updatedAt: '2024-01-15T10:00:00Z', - }, -}; - -const mockCandles: CandleItem[] = [ - { - open: 280, - high: 290, - low: 278, - close: 289.5, - volume: 1000000, - value: 280000000, - begin: '2024-01-15T10:00:00Z', - end: '2024-01-15T18:00:00Z', - }, - { - open: 289, - high: 292, - low: 285, - close: 288, - volume: 800000, - value: 231200000, - begin: '2024-01-16T10:00:00Z', - end: '2024-01-16T18:00:00Z', - }, -]; - -const mockDividends = [ - { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, - { registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' }, -]; - -const mockSearchResults: SearchResultItem[] = [ - { - secid: 'SBER', - isin: 'RU0009029540', - shortName: 'Сбер', - type: 'share', - listLevel: 1, - currency: 'RUB', - price: 289.5, - }, - { - secid: 'VTBR', - isin: 'RU000A0JP5V6', - shortName: 'ВТБ', - type: 'share', - listLevel: 1, - currency: 'RUB', - price: 0.0234, - }, -]; - -const userResponse = { - id: 1, - email: 'user@test.com', - name: 'Test User', - role: 'user', -}; - -const authResponse = { - user: userResponse, - accessToken: 'mock-access-token', -}; - -const envelope = (data: unknown) => ({ - data: { data, meta: { fromCache: false, cachedAt: null } }, -}); - -export const handlers = [ - http.get(`${API}/securities/search`, ({ request }) => { - const url = new URL(request.url); - const q = url.searchParams.get('q') || ''; - if (q.length < 2) { - return HttpResponse.json(envelope([])); - } - const filtered = mockSearchResults.filter( - (r) => - r.secid.toLowerCase().includes(q.toLowerCase()) || - r.shortName.toLowerCase().includes(q.toLowerCase()), - ); - return HttpResponse.json(envelope(filtered)); - }), - - http.get(`${API}/securities/shares/:secid`, ({ params }) => { - const { secid } = params; - if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); - return HttpResponse.json(envelope({ ...mockShare, secid } as ShareResponse)); - }), - - http.get(`${API}/securities/shares/:secid/candles`, () => - HttpResponse.json(envelope(mockCandles)), - ), - - http.get(`${API}/securities/shares/:secid/dividends`, () => - HttpResponse.json(envelope(mockDividends)), - ), - - http.get(`${API}/securities/bonds/:secid`, ({ params }) => { - const { secid } = params; - if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); - return HttpResponse.json(envelope({ ...mockBond, secid } as BondResponse)); - }), - - http.get(`${API}/securities/bonds/:secid/candles`, () => - HttpResponse.json(envelope(mockCandles)), - ), - - http.get(`${API}/auth/me`, () => HttpResponse.json(envelope(userResponse))), - - http.post(`${API}/auth/login`, () => HttpResponse.json(envelope(authResponse))), - - http.post(`${API}/auth/register`, () => HttpResponse.json(envelope(authResponse))), - - http.post(`${API}/auth/refresh`, () => HttpResponse.json(envelope(authResponse))), - - http.post(`${API}/auth/logout`, () => HttpResponse.json(envelope({ message: 'Logged out' }))), - - http.patch(`${API}/auth/me`, () => - HttpResponse.json(envelope({ ...userResponse, name: 'Updated' })), - ), - - http.get(`${API}/health`, () => - HttpResponse.json( - envelope({ status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }), - ), - ), -]; diff --git a/apps/frontend/src/shared/lib/test/server.ts b/apps/frontend/src/shared/lib/test/server.ts deleted file mode 100644 index e52fee0..0000000 --- a/apps/frontend/src/shared/lib/test/server.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { setupServer } from 'msw/node'; -import { handlers } from './handlers'; - -export const server = setupServer(...handlers); diff --git a/apps/frontend/src/shared/lib/test/setup.ts b/apps/frontend/src/shared/lib/test/setup.ts index 1b15647..d36df91 100644 --- a/apps/frontend/src/shared/lib/test/setup.ts +++ b/apps/frontend/src/shared/lib/test/setup.ts @@ -1,9 +1,9 @@ -import '@testing-library/jest-dom'; -import { server } from './server'; +import '@testing-library/jest-dom' +import { server } from '@mocks/server' -beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) Object.defineProperty(window, 'matchMedia', { writable: true, @@ -17,11 +17,11 @@ Object.defineProperty(window, 'matchMedia', { removeEventListener: () => {}, dispatchEvent: () => false, }), -}); +}) // Prevent lightweight-charts animation frame callbacks in jsdom -window.requestAnimationFrame = () => 0; -window.cancelAnimationFrame = () => {}; +window.requestAnimationFrame = () => 0 +window.cancelAnimationFrame = () => {} // Mock canvas 2D context to prevent jsdom "Not implemented" warnings from lightweight-charts HTMLCanvasElement.prototype.getContext = ((contextId: string) => { @@ -67,7 +67,7 @@ HTMLCanvasElement.prototype.getContext = ((contextId: string) => { shadowColor: '', shadowOffsetX: 0, shadowOffsetY: 0, - } as unknown as CanvasRenderingContext2D; + } as unknown as CanvasRenderingContext2D } - return null; -}) as typeof HTMLCanvasElement.prototype.getContext; + return null +}) as typeof HTMLCanvasElement.prototype.getContext diff --git a/apps/frontend/src/shared/lib/test/test-utils.tsx b/apps/frontend/src/shared/lib/test/test-utils.tsx index 7452a0a..80641c5 100644 --- a/apps/frontend/src/shared/lib/test/test-utils.tsx +++ b/apps/frontend/src/shared/lib/test/test-utils.tsx @@ -1,12 +1,12 @@ -import { type ReactElement } from 'react'; -import { render, type RenderOptions } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter } from 'react-router-dom'; -import { TestSessionProvider } from './TestSessionProvider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router' +import { type RenderOptions, render } from '@testing-library/react' +import type { ReactElement } from 'react' +import { TestSessionProvider } from './TestSessionProvider' interface CustomRenderOptions extends Omit { - queryClient?: QueryClient; - route?: string; + queryClient?: QueryClient + route?: string } function createTestQueryClient() { @@ -15,29 +15,30 @@ function createTestQueryClient() { queries: { retry: false, gcTime: 0 }, mutations: { retry: false }, }, - }); + }) } export function renderWithProviders( ui: ReactElement, { queryClient = createTestQueryClient(), - route = '/', + route: _route = '/', ...renderOptions }: CustomRenderOptions = {}, ) { function Wrapper({ children }: { children: React.ReactNode }) { + const rootRoute = createRootRoute({ + component: () => <>{children}, + }) + const router = createRouter({ routeTree: rootRoute }) return ( - - {children} - + + + - ); + ) } - return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient }; + return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient } } diff --git a/apps/frontend/src/shared/lib/useCursorPagination.ts b/apps/frontend/src/shared/lib/useCursorPagination.ts index 6bf48b6..9df823e 100644 --- a/apps/frontend/src/shared/lib/useCursorPagination.ts +++ b/apps/frontend/src/shared/lib/useCursorPagination.ts @@ -1,34 +1,34 @@ -import { useState, useCallback } from 'react'; +import { useCallback, useState } from 'react' export function useCursorPagination() { - const [cursor, setCursor] = useState(undefined); - const [cursorStack, setCursorStack] = useState>([]); + const [cursor, setCursor] = useState(undefined) + const [cursorStack, setCursorStack] = useState>([]) const handleNext = useCallback( (nextCursor: string | null | undefined) => { - if (!nextCursor) return; - setCursorStack((prev) => [...prev, cursor]); - setCursor(nextCursor); + 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; - }, []); + 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([]); - }, []); + setCursor(undefined) + setCursorStack([]) + }, []) return { cursor, @@ -36,5 +36,5 @@ export function useCursorPagination() { handleNext, handlePrevious, reset, - }; + } } diff --git a/apps/frontend/src/shared/ui/Table/Table.tsx b/apps/frontend/src/shared/ui/Table/Table.tsx index 0cf7d47..89c6777 100644 --- a/apps/frontend/src/shared/ui/Table/Table.tsx +++ b/apps/frontend/src/shared/ui/Table/Table.tsx @@ -1,7 +1,7 @@ -import { flexRender, type Table as TanStackTable } from '@tanstack/react-table'; +import { flexRender, type Table as TanStackTable } from '@tanstack/react-table' interface TableProps { - table: TanStackTable; + table: TanStackTable } export function Table({ table }: TableProps) { @@ -38,5 +38,5 @@ export function Table({ table }: TableProps) { - ); + ) } diff --git a/apps/frontend/src/shared/ui/Table/index.ts b/apps/frontend/src/shared/ui/Table/index.ts index c6f09c3..3be5c81 100644 --- a/apps/frontend/src/shared/ui/Table/index.ts +++ b/apps/frontend/src/shared/ui/Table/index.ts @@ -1 +1 @@ -export { Table } from './Table'; +export { Table } from './Table' diff --git a/apps/frontend/src/shared/ui/TableSkeleton.tsx b/apps/frontend/src/shared/ui/TableSkeleton.tsx index 34e94bd..d079ee0 100644 --- a/apps/frontend/src/shared/ui/TableSkeleton.tsx +++ b/apps/frontend/src/shared/ui/TableSkeleton.tsx @@ -1,12 +1,12 @@ -import { Skeleton } from '@moex-vibe/design-system'; +import { Skeleton } from '@moex-vibe/design-system' const tdStyle = { borderBottom: '1px solid #eeeeee', padding: '10px 8px', verticalAlign: 'top', -} satisfies React.CSSProperties; +} satisfies React.CSSProperties -type Column = { width: string }; +type Column = { width: string } export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) { return ( @@ -21,5 +21,5 @@ export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: C ))} - ); + ) } diff --git a/apps/frontend/src/shared/ui/broker-allocation-bar/BrokerAllocationBar.tsx b/apps/frontend/src/shared/ui/broker-allocation-bar/BrokerAllocationBar.tsx index 6df64ac..659329e 100644 --- a/apps/frontend/src/shared/ui/broker-allocation-bar/BrokerAllocationBar.tsx +++ b/apps/frontend/src/shared/ui/broker-allocation-bar/BrokerAllocationBar.tsx @@ -1,25 +1,25 @@ -import { Box } from '@mui/material'; -import { Text } from '@moex-vibe/design-system'; +import { Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' type AllocationBarItem = { - key: string; - label: string; - percent: number; - value: number; - color: string; -}; + key: string + label: string + percent: number + value: number + color: string +} export function BrokerAllocationBar({ items, title, }: { - items: AllocationBarItem[]; - title: string; + items: AllocationBarItem[] + title: string }) { - const positiveItems = items.filter((item) => item.value > 0); + const positiveItems = items.filter((item) => item.value > 0) if (positiveItems.length === 0) { - return Нет данных для распределения; + return Нет данных для распределения } return ( @@ -72,5 +72,5 @@ export function BrokerAllocationBar({ ))} - ); + ) } diff --git a/apps/frontend/src/shared/ui/broker-allocation-bar/index.ts b/apps/frontend/src/shared/ui/broker-allocation-bar/index.ts index 23c643f..ea7d62a 100644 --- a/apps/frontend/src/shared/ui/broker-allocation-bar/index.ts +++ b/apps/frontend/src/shared/ui/broker-allocation-bar/index.ts @@ -1 +1 @@ -export { BrokerAllocationBar } from './BrokerAllocationBar'; +export { BrokerAllocationBar } from './BrokerAllocationBar' diff --git a/apps/frontend/src/shared/ui/index.ts b/apps/frontend/src/shared/ui/index.ts index acc0989..fc7204a 100644 --- a/apps/frontend/src/shared/ui/index.ts +++ b/apps/frontend/src/shared/ui/index.ts @@ -1 +1 @@ -export { TableSkeleton } from './TableSkeleton'; +export { TableSkeleton } from './TableSkeleton' diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 600402b..5f6196b 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -29,7 +29,7 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--color-bg); color: var(--color-text); line-height: 1.6; @@ -95,5 +95,3 @@ a { animation: none; } } - - diff --git a/apps/frontend/src/widgets/bond-details/index.ts b/apps/frontend/src/widgets/bond-details/index.ts index c8ebf2e..bd7307f 100644 --- a/apps/frontend/src/widgets/bond-details/index.ts +++ b/apps/frontend/src/widgets/bond-details/index.ts @@ -1 +1 @@ -export { BondDetails } from './ui/BondDetails'; +export { BondDetails } from './ui/BondDetails' diff --git a/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx b/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx index 169a2a4..dd3fdc9 100644 --- a/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx +++ b/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx @@ -1,33 +1,33 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { BondDetails } from './BondDetails'; -import { createMockBond } from '@/shared/lib/test/factories'; +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { createMockBond } from '@/shared/lib/test/factories' +import { BondDetails } from './BondDetails' describe('BondDetails', () => { it('renders bond details', () => { - const bond = createMockBond(); - render(); - expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument(); - expect(screen.getAllByText('RU000A101XU7').length).toBe(2); - }); + const bond = createMockBond() + render() + expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument() + expect(screen.getAllByText('RU000A101XU7').length).toBe(2) + }) it('shows price as percentage', () => { - const bond = createMockBond(); - render(); - expect(screen.getByText('98.50%')).toBeInTheDocument(); - }); + const bond = createMockBond() + render() + expect(screen.getByText('98.50%')).toBeInTheDocument() + }) it('renders maturity date', () => { - const bond = createMockBond(); - render(); - expect(screen.getByText('2027-05-15')).toBeInTheDocument(); - }); + const bond = createMockBond() + render() + expect(screen.getByText('2027-05-15')).toBeInTheDocument() + }) it('shows coupon with percentage', () => { - const bond = createMockBond(); - render(); - expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument(); - }); + const bond = createMockBond() + render() + expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument() + }) it('shows coupon without percentage when null', () => { const bond = createMockBond({ @@ -35,10 +35,10 @@ describe('BondDetails', () => { ...createMockBond().marketData, couponPercent: null, }, - }); - render(); - expect(screen.getByText('36.9 ₽')).toBeInTheDocument(); - }); + }) + render() + expect(screen.getByText('36.9 ₽')).toBeInTheDocument() + }) it('shows dash for missing next coupon date', () => { const bond = createMockBond({ @@ -46,11 +46,11 @@ describe('BondDetails', () => { ...createMockBond().marketData, nextCouponDate: null, }, - }); - render(); - const dashes = screen.getAllByText('—'); - expect(dashes.length).toBeGreaterThanOrEqual(1); - }); + }) + render() + const dashes = screen.getAllByText('—') + expect(dashes.length).toBeGreaterThanOrEqual(1) + }) it('shows dash for null yieldToMaturity', () => { const bond = createMockBond({ @@ -58,10 +58,10 @@ describe('BondDetails', () => { ...createMockBond().marketData, yieldToMaturity: null, }, - }); - render(); - expect(screen.getByText('—')).toBeInTheDocument(); - }); + }) + render() + expect(screen.getByText('—')).toBeInTheDocument() + }) it('preserves unit suffixes for missing values', () => { const bond = createMockBond({ @@ -72,20 +72,20 @@ describe('BondDetails', () => { couponPercent: 7.5, accruedInt: null, }, - }); + }) - render(); + render() - expect(screen.getByText('—%')).toBeInTheDocument(); - expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument(); - expect(screen.getByText('— ₽')).toBeInTheDocument(); - }); + expect(screen.getByText('—%')).toBeInTheDocument() + expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument() + expect(screen.getByText('— ₽')).toBeInTheDocument() + }) it('shows bond type', () => { - const bond = createMockBond(); - render(); - expect(screen.getByText('ОФЗ')).toBeInTheDocument(); - }); + const bond = createMockBond() + render() + expect(screen.getByText('ОФЗ')).toBeInTheDocument() + }) it('preserves percent suffix when price is missing', () => { const bond = createMockBond({ @@ -93,10 +93,10 @@ describe('BondDetails', () => { ...createMockBond().marketData, price: null, }, - }); - render(); - expect(screen.getByText('—%')).toBeInTheDocument(); - }); + }) + render() + expect(screen.getByText('—%')).toBeInTheDocument() + }) it('preserves currency and percentage formatting when coupon value is missing', () => { const bond = createMockBond({ @@ -104,11 +104,11 @@ describe('BondDetails', () => { ...createMockBond().marketData, couponValue: null, }, - }); - render(); - expect(screen.getByText('Купон')).toBeInTheDocument(); - expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument(); - }); + }) + render() + expect(screen.getByText('Купон')).toBeInTheDocument() + expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument() + }) it('preserves currency suffix when accrued interest is missing', () => { const bond = createMockBond({ @@ -116,9 +116,9 @@ describe('BondDetails', () => { ...createMockBond().marketData, accruedInt: null, }, - }); - render(); - expect(screen.getByText('НКД')).toBeInTheDocument(); - expect(screen.getByText('— ₽')).toBeInTheDocument(); - }); -}); + }) + render() + expect(screen.getByText('НКД')).toBeInTheDocument() + expect(screen.getByText('— ₽')).toBeInTheDocument() + }) +}) diff --git a/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx b/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx index dbf7588..9a7b19d 100644 --- a/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx +++ b/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx @@ -1,7 +1,7 @@ -import type { BondResponse } from '@/shared/api/responses'; +import type { BondResponse } from '@/shared/api' interface BondDetailsProps { - bond: BondResponse; + bond: BondResponse } const rowStyle: React.CSSProperties = { @@ -9,10 +9,10 @@ const rowStyle: React.CSSProperties = { justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #eee', -}; +} export function BondDetails({ bond }: BondDetailsProps) { - const md = bond.marketData; + const md = bond.marketData return (
Доходность к погашению - {md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'} + {md.yieldToMaturity != null ? `${md.yieldToMaturity.toFixed(2)}%` : '—'}
Дюрация @@ -79,5 +79,5 @@ export function BondDetails({ bond }: BondDetailsProps) {
- ); + ) } diff --git a/apps/frontend/src/widgets/bond-positions-table/index.ts b/apps/frontend/src/widgets/bond-positions-table/index.ts index add2bda..f6a769a 100644 --- a/apps/frontend/src/widgets/bond-positions-table/index.ts +++ b/apps/frontend/src/widgets/bond-positions-table/index.ts @@ -1 +1 @@ -export { BondPositionTable } from './ui/BondPositionTable'; +export { BondPositionTable } from './ui/BondPositionTable' diff --git a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx index e1ae9b6..53a2c26 100644 --- a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx +++ b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx @@ -1,53 +1,53 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import type { PositionWithPrice } from '@/shared/api/responses'; +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import type { PositionWithPrice } from '@/shared/api' interface Props { - position: PositionWithPrice; - onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; - onDelete: () => void; + position: PositionWithPrice + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void + onDelete: () => void } export function BondPositionRow({ position, onUpdate, onDelete }: Props) { - const [editingQty, setEditingQty] = useState(false); - const [editingPrice, setEditingPrice] = useState(false); - const [qty, setQty] = useState(String(position.quantity)); - const [price, setPrice] = useState(String(position.buyPrice ?? '')); + const [editingQty, setEditingQty] = useState(false) + const [editingPrice, setEditingPrice] = useState(false) + const [qty, setQty] = useState(String(position.quantity)) + const [price, setPrice] = useState(String(position.buyPrice ?? '')) function handleSaveQty() { - const num = parseInt(qty, 10); - if (!isNaN(num) && num >= 0 && num !== position.quantity) { - onUpdate({ quantity: num }); + const num = parseInt(qty, 10) + if (!Number.isNaN(num) && num >= 0 && num !== position.quantity) { + onUpdate({ quantity: num }) } - setEditingQty(false); + setEditingQty(false) } function handleSavePrice() { - const num = parseFloat(price); - if (!isNaN(num) && num >= 0 && num !== position.buyPrice) { - onUpdate({ buyPrice: num }); + const num = parseFloat(price) + if (!Number.isNaN(num) && num >= 0 && num !== position.buyPrice) { + onUpdate({ buyPrice: num }) } else if (price === '' && position.buyPrice !== null) { - onUpdate({ buyPrice: undefined }); + onUpdate({ buyPrice: undefined }) } - setEditingPrice(false); + setEditingPrice(false) } function formatDate(dateStr: string | null | undefined): string { - if (!dateStr) return '—'; - return new Date(dateStr).toLocaleDateString('ru-RU'); + if (!dateStr) return '—' + return new Date(dateStr).toLocaleDateString('ru-RU') } function formatPct(value: number | null | undefined): string { - return value != null ? `${value.toFixed(2)}%` : '—'; + return value != null ? `${value.toFixed(2)}%` : '—' } function formatRubles(value: number | null | undefined): string { return value != null ? value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) - : '—'; + : '—' } - const totalAccrued = position.accruedInt != null ? position.accruedInt * position.quantity : null; + const totalAccrued = position.accruedInt != null ? position.accruedInt * position.quantity : null return ( @@ -71,7 +71,6 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { onChange={(e) => setQty(e.target.value)} onBlur={handleSaveQty} onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()} - autoFocus style={{ width: 80, padding: '4px 8px', @@ -83,8 +82,8 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { ) : ( { - setQty(String(position.quantity)); - setEditingQty(true); + setQty(String(position.quantity)) + setEditingQty(true) }} style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} > @@ -101,7 +100,6 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { onChange={(e) => setPrice(e.target.value)} onBlur={handleSavePrice} onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()} - autoFocus style={{ width: 90, padding: '4px 8px', @@ -114,8 +112,8 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { ) : ( { - setPrice(String(position.buyPrice ?? '')); - setEditingPrice(true); + setPrice(String(position.buyPrice ?? '')) + setEditingPrice(true) }} style={{ cursor: 'pointer', @@ -218,5 +216,5 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { - ); + ) } diff --git a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx index c1dfc2b..e6592a6 100644 --- a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx +++ b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx @@ -1,17 +1,17 @@ -import { BondPositionRow } from './BondPositionRow'; -import type { PositionWithPrice } from '@/shared/api/responses'; +import type { PositionWithPrice } from '@/shared/api' +import { BondPositionRow } from './BondPositionRow' interface Props { - positions: PositionWithPrice[]; + positions: PositionWithPrice[] onUpdatePosition: ( positionId: number, data: { quantity?: number; buyPrice?: number; buyDate?: string }, - ) => void; - onDeletePosition: (positionId: number) => void; + ) => void + onDeletePosition: (positionId: number) => void } export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) { - if (positions.length === 0) return null; + if (positions.length === 0) return null return (
@@ -269,5 +269,5 @@ export function BondPositionTable({ positions, onUpdatePosition, onDeletePositio
- ); + ) } diff --git a/apps/frontend/src/widgets/broker-account-card/index.ts b/apps/frontend/src/widgets/broker-account-card/index.ts index 10b8dd8..6ad6dcd 100644 --- a/apps/frontend/src/widgets/broker-account-card/index.ts +++ b/apps/frontend/src/widgets/broker-account-card/index.ts @@ -1 +1 @@ -export { BrokerAccountCard } from './ui/BrokerAccountCard'; +export { BrokerAccountCard } from './ui/BrokerAccountCard' diff --git a/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx index dd80bad..efb58a2 100644 --- a/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx +++ b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx @@ -1,18 +1,18 @@ -import { Link } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Alert, Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'; -import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'; -import { buildBrokerAllocation } from '@/entities/broker-position'; -import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar'; +import { Alert, Button, Heading, Skeleton, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link } from '@tanstack/react-router' +import { buildBrokerAllocation } from '@/entities/broker-position' +import type { BrokerAccount, BrokerPortfolio } from '@/shared/api' import { + formatBrokerDate, formatBrokerMoney, formatBrokerSignedCurrencyValue, formatBrokerSignedPercent, - formatBrokerDate, -} from '@/shared/lib/formatters'; +} from '@/shared/lib/formatters' +import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar' function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { - return type === 'iis' ? 'ИИС' : 'Брокерский счёт'; + return type === 'iis' ? 'ИИС' : 'Брокерский счёт' } const cardSx = { @@ -22,7 +22,7 @@ const cardSx = { borderColor: 'rgba(21, 61, 43, 0.12)', bgcolor: 'rgba(255, 255, 255, 0.92)', boxShadow: '0 20px 45px rgba(31, 48, 39, 0.08)', -}; +} const statSx = { display: 'grid', @@ -32,7 +32,7 @@ const statSx = { background: 'linear-gradient(135deg, rgba(255,255,255,0.92), rgba(241,245,239,0.88))', border: '1px solid', borderColor: 'rgba(31,48,39,0.08)', -}; +} function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) { return ( @@ -58,15 +58,15 @@ function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabe - ); + ) } function BrokerAccountCardError({ account, onRetry, }: { - account: BrokerAccount; - onRetry: () => void; + account: BrokerAccount + onRetry: () => void }) { return ( @@ -89,19 +89,19 @@ function BrokerAccountCardError({ Не удалось загрузить данные счёта - ); + ) } function BrokerAccountCardSuccess({ account, portfolio, }: { - account: BrokerAccount; - portfolio: BrokerPortfolio; + account: BrokerAccount + portfolio: BrokerPortfolio }) { - const typeLabel = brokerAccountTypeLabel(account.type); - const openedAt = formatBrokerDate(account.openedAt); - const allocation = buildBrokerAllocation(portfolio); + const typeLabel = brokerAccountTypeLabel(account.type) + const openedAt = formatBrokerDate(account.openedAt) + const allocation = buildBrokerAllocation(portfolio) return ( - ); + ) } export function BrokerAccountCard({ @@ -185,11 +185,11 @@ export function BrokerAccountCard({ error, onRetry, }: { - account: BrokerAccount; - portfolio?: BrokerPortfolio; - isLoading: boolean; - error: Error | null; - onRetry: () => void; + account: BrokerAccount + portfolio?: BrokerPortfolio + isLoading: boolean + error: Error | null + onRetry: () => void }) { if (isLoading && !portfolio) { return ( @@ -197,12 +197,12 @@ export function BrokerAccountCard({ name={account.name} typeLabel={brokerAccountTypeLabel(account.type)} /> - ); + ) } if (error || !portfolio) { - return ; + return } - return ; + return } diff --git a/apps/frontend/src/widgets/broker-account-layout/index.ts b/apps/frontend/src/widgets/broker-account-layout/index.ts index d78b2fc..004ed7c 100644 --- a/apps/frontend/src/widgets/broker-account-layout/index.ts +++ b/apps/frontend/src/widgets/broker-account-layout/index.ts @@ -1,2 +1,3 @@ -export { BrokerAccountLayout, useBrokerAccountContext } from './ui/BrokerAccountLayout'; -export type { BrokerAccountContext } from './ui/BrokerAccountLayout'; +export { useBrokerAccountContext } from './lib/useBrokerAccountContext' +export type { BrokerAccountContextValue } from './ui/BrokerAccountLayout' +export { BrokerAccountLayout } from './ui/BrokerAccountLayout' diff --git a/apps/frontend/src/widgets/broker-account-layout/lib/useBrokerAccountContext.ts b/apps/frontend/src/widgets/broker-account-layout/lib/useBrokerAccountContext.ts new file mode 100644 index 0000000..2df2875 --- /dev/null +++ b/apps/frontend/src/widgets/broker-account-layout/lib/useBrokerAccountContext.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { BrokerAccountContext } from '../ui/BrokerAccountLayout' + +export function useBrokerAccountContext() { + const context = useContext(BrokerAccountContext) + if (!context) { + throw new Error('useBrokerAccountContext must be used within BrokerAccountLayout') + } + return context +} diff --git a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx index 6f5f928..d524645 100644 --- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -1,99 +1,97 @@ -import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Heading } from '@moex-vibe/design-system'; -import { useBrokerPortfolio } from '@/entities/broker-account'; +import { Heading } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import type { UseQueryResult } from '@tanstack/react-query' +import { Link, useParams } from '@tanstack/react-router' +import { createContext, type ReactNode } from 'react' +import { useBrokerPortfolio } from '@/entities/broker-account' +import type { BrokerPortfolio } from '@/shared/api' -export type BrokerAccountContext = { - accountId: string; - portfolio: ReturnType; -}; - -export function useBrokerAccountContext() { - return useOutletContext(); +const baseLinkStyle: React.CSSProperties = { + padding: '10px 12px', + borderRadius: 8, + color: 'var(--color-text-secondary)', + textDecoration: 'none', + whiteSpace: 'nowrap', + flexShrink: 0, + display: 'inline-flex', + alignItems: 'center', + fontSize: 14, } const links = [ - { to: '', label: 'Обзор', end: true }, + { to: '', label: 'Обзор' }, { to: '/shares', label: 'Акции' }, { to: '/bonds', label: 'Облигации' }, { to: '/operations', label: 'Операции' }, { to: '/events', label: 'События' }, -]; +] -export function BrokerAccountLayout() { - const { accountId = '' } = useParams(); - const portfolio = useBrokerPortfolio(accountId); - const basePath = `/broker/${encodeURIComponent(accountId)}`; - const context: BrokerAccountContext = { accountId, portfolio }; +export interface BrokerAccountContextValue { + accountId: string + portfolio: UseQueryResult +} + +export const BrokerAccountContext = createContext(null) + +export function BrokerAccountLayout({ children }: { children: ReactNode }) { + const { accountId = '' } = useParams({ from: '/broker/$accountId' }) + const portfolio = useBrokerPortfolio(accountId) + const basePath = `/broker/${encodeURIComponent(accountId)}` return ( - - - {portfolio.data?.account.name || 'Брокерский счёт'} - + + + + {portfolio.data?.account.name || 'Брокерский счёт'} + - - {links.map((link) => ( - - {link.label} - - ))} - + + {links.map((link) => ( + + {link.label} + + ))} + - - + {children} - - ); + + ) } diff --git a/apps/frontend/src/widgets/broker-accounts-summary/index.ts b/apps/frontend/src/widgets/broker-accounts-summary/index.ts index 5a4c1dc..bf1a80d 100644 --- a/apps/frontend/src/widgets/broker-accounts-summary/index.ts +++ b/apps/frontend/src/widgets/broker-accounts-summary/index.ts @@ -1 +1 @@ -export { BrokerAccountsSummary } from './ui/BrokerAccountsSummary'; +export { BrokerAccountsSummary } from './ui/BrokerAccountsSummary' diff --git a/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx b/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx index 7ac60ea..c44d5a9 100644 --- a/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx +++ b/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx @@ -1,13 +1,13 @@ -import { Box } from '@mui/material'; -import { Skeleton, Text } from '@moex-vibe/design-system'; -import type { BrokerAccountsAggregate } from '@/entities/broker-account'; -import { buildBrokerAllocation } from '@/entities/broker-position'; -import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar'; +import { Skeleton, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import type { BrokerAccountsAggregate } from '@/entities/broker-account' +import { buildBrokerAllocation } from '@/entities/broker-position' import { formatBrokerCurrencyValue, formatBrokerSignedCurrencyValue, formatBrokerSignedPercent, -} from '@/shared/lib/formatters'; +} from '@/shared/lib/formatters' +import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar' export function BrokerAccountsSummary({ aggregate, @@ -15,10 +15,10 @@ export function BrokerAccountsSummary({ totalCount, isLoading, }: { - aggregate: BrokerAccountsAggregate; - availableCount: number; - totalCount: number; - isLoading: boolean; + aggregate: BrokerAccountsAggregate + availableCount: number + totalCount: number + isLoading: boolean }) { if (isLoading) { return ( @@ -54,7 +54,7 @@ export function BrokerAccountsSummary({ ))} - ); + ) } return ( @@ -150,7 +150,7 @@ export function BrokerAccountsSummary({ cash: [], blockedCash: [], asOf: '', - }); + }) return ( - ); + ) })} - ); + ) } diff --git a/apps/frontend/src/widgets/broker-allocation-chart/index.ts b/apps/frontend/src/widgets/broker-allocation-chart/index.ts index b01ae2e..2c3cc06 100644 --- a/apps/frontend/src/widgets/broker-allocation-chart/index.ts +++ b/apps/frontend/src/widgets/broker-allocation-chart/index.ts @@ -1 +1 @@ -export { BrokerAllocationChart } from './ui/BrokerAllocationChart'; +export { BrokerAllocationChart } from './ui/BrokerAllocationChart' diff --git a/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx b/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx index 8efdef9..25183da 100644 --- a/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx +++ b/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx @@ -1,31 +1,31 @@ -import type { BrokerPortfolio } from '@/shared/api/responses'; -import { Box } from '@mui/material'; -import { Text } from '@moex-vibe/design-system'; -import { buildBrokerAllocation } from '@/entities/broker-position'; -import { formatBrokerCurrencyValue } from '@/shared/lib/formatters'; +import { Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { buildBrokerAllocation } from '@/entities/broker-position' +import type { BrokerPortfolio } from '@/shared/api' +import { formatBrokerCurrencyValue } from '@/shared/lib/formatters' -const RADIUS = 44; -const CIRCUMFERENCE = 2 * Math.PI * RADIUS; +const RADIUS = 44 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS function allocationCurrency(portfolio: BrokerPortfolio) { return ( portfolio.totals.portfolio?.currency || Object.values(portfolio.totals).find((total) => total?.currency)?.currency || 'RUB' - ); + ) } export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) { - const { sectors, negative } = buildBrokerAllocation(portfolio); - const currency = allocationCurrency(portfolio); - let remaining = CIRCUMFERENCE; + const { sectors, negative } = buildBrokerAllocation(portfolio) + const currency = allocationCurrency(portfolio) + let remaining = CIRCUMFERENCE const arcs = sectors.map((sector) => { - const dashOffset = -(CIRCUMFERENCE - remaining); - const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE; - const dashLength = Math.min(Math.max(rawDashLength, 0), remaining); - remaining = Math.max(0, remaining - dashLength); - return { ...sector, dashOffset, dashLength }; - }); + const dashOffset = -(CIRCUMFERENCE - remaining) + const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE + const dashLength = Math.min(Math.max(rawDashLength, 0), remaining) + remaining = Math.max(0, remaining - dashLength) + return { ...sector, dashOffset, dashLength } + }) return ( @@ -91,5 +91,5 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli )} - ); + ) } diff --git a/apps/frontend/src/widgets/broker-events-overview/index.ts b/apps/frontend/src/widgets/broker-events-overview/index.ts index 47e04ca..65e8da9 100644 --- a/apps/frontend/src/widgets/broker-events-overview/index.ts +++ b/apps/frontend/src/widgets/broker-events-overview/index.ts @@ -1 +1 @@ -export { BrokerEventsOverview } from './ui/BrokerEventsOverview'; +export { BrokerEventsOverview } from './ui/BrokerEventsOverview' diff --git a/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.test.tsx b/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.test.tsx index 73f7922..4099242 100644 --- a/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.test.tsx +++ b/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.test.tsx @@ -1,31 +1,44 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; -import { type ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BrokerEventsOverview } from './BrokerEventsOverview'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BrokerEventsOverview } from './BrokerEventsOverview' vi.mock('@/entities/broker-event', () => ({ useBrokerEvents: vi.fn(), -})); +})) -import { useBrokerEvents } from '@/entities/broker-event'; +import { useBrokerEvents } from '@/entities/broker-event' vi.mock('@moex-vibe/design-system', () => ({ Heading: ({ children }: { children: ReactNode }) =>

{children}

, Text: ({ children }: { children: ReactNode }) => {children}, -})); +})) + +const mockNavigate = vi.fn() +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => mockNavigate, + Link: ({ + to, + children, + ...props + }: { + to: string + children: ReactNode + [key: string]: unknown + }) => ( + + {children} + + ), +})) function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return function Wrapper({ children }: { children: ReactNode }) { - return ( - - {children} - - ); - }; + return {children} + } } const mockItems = [ @@ -47,12 +60,12 @@ const mockItems = [ estimatedAmount: 36.9, currency: 'RUB' as const, }, -]; +] describe('BrokerEventsOverview', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) it('renders loading state', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -80,11 +93,11 @@ describe('BrokerEventsOverview', () => { promise: new Promise(() => {}), status: 'pending', fetchStatus: 'fetching', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); - expect(screen.getByText('Загрузка событий…')).toBeInTheDocument(); - }); + render(, { wrapper: createWrapper() }) + expect(screen.getByText('Загрузка событий…')).toBeInTheDocument() + }) it('returns null on error', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -112,13 +125,13 @@ describe('BrokerEventsOverview', () => { promise: new Promise(() => {}), status: 'error', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) const { container } = render(, { wrapper: createWrapper(), - }); - expect(container).toBeEmptyDOMElement(); - }); + }) + expect(container).toBeEmptyDOMElement() + }) it('returns null when items array is empty', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -162,13 +175,13 @@ describe('BrokerEventsOverview', () => { }), status: 'success', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) const { container } = render(, { wrapper: createWrapper(), - }); - expect(container).toBeEmptyDOMElement(); - }); + }) + expect(container).toBeEmptyDOMElement() + }) it('renders event items and link to full page', () => { vi.mocked(useBrokerEvents).mockReturnValue({ @@ -212,19 +225,19 @@ describe('BrokerEventsOverview', () => { }), status: 'success', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); + render(, { wrapper: createWrapper() }) - expect(screen.getByText('Ближайшие события')).toBeInTheDocument(); - expect(screen.getByText('SBER')).toBeInTheDocument(); - expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument(); - expect(screen.getByText('Все события')).toBeInTheDocument(); + expect(screen.getByText('Ближайшие события')).toBeInTheDocument() + expect(screen.getByText('SBER')).toBeInTheDocument() + expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument() + expect(screen.getByText('Все события')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Все события' })).toHaveAttribute( 'href', '/broker/acc-1/events', - ); - }); + ) + }) it('renders at most 5 items', () => { const manyItems = Array.from({ length: 7 }, (_, i) => ({ @@ -235,7 +248,7 @@ describe('BrokerEventsOverview', () => { eventDate: '2026-06-25', estimatedAmount: 100, currency: 'RUB' as const, - })); + })) vi.mocked(useBrokerEvents).mockReturnValue({ data: { @@ -278,10 +291,10 @@ describe('BrokerEventsOverview', () => { }), status: 'success', fetchStatus: 'idle', - } as unknown as ReturnType); + } as unknown as ReturnType) - render(, { wrapper: createWrapper() }); + render(, { wrapper: createWrapper() }) - expect(screen.getAllByText(/TICKER/)).toHaveLength(5); - }); -}); + expect(screen.getAllByText(/TICKER/)).toHaveLength(5) + }) +}) diff --git a/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.tsx b/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.tsx index 0b6603f..010bfbe 100644 --- a/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.tsx +++ b/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.tsx @@ -1,46 +1,46 @@ -import { Link } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Heading, Text } from '@moex-vibe/design-system'; -import { useBrokerEvents } from '@/entities/broker-event'; -import { formatBrokerCurrencyValue } from '@/shared/lib/formatters'; +import { Heading, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link } from '@tanstack/react-router' +import { useBrokerEvents } from '@/entities/broker-event' +import { formatBrokerCurrencyValue } from '@/shared/lib/formatters' function formatDate(value: string | null): string { - if (!value) return '-'; - return new Date(value).toLocaleDateString('ru-RU'); + if (!value) return '-' + return new Date(value).toLocaleDateString('ru-RU') } function eventTypeLabel(type: string): string { switch (type) { case 'dividend': - return 'Дивиденд'; + return 'Дивиденд' case 'coupon': - return 'Купон'; + return 'Купон' case 'maturity': - return 'Погашение'; + return 'Погашение' case 'offer': - return 'Оферта'; + return 'Оферта' default: - return type; + return type } } function defaultPeriod(): { from: string; to: string } { - const now = new Date(); - const from = new Date(now); - const to = new Date(now); - to.setDate(to.getDate() + 7); + const now = new Date() + const from = new Date(now) + const to = new Date(now) + to.setDate(to.getDate() + 7) return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10), - }; + } } export function BrokerEventsOverview({ accountId }: { accountId: string }) { - const period = defaultPeriod(); - const events = useBrokerEvents(accountId, period); - const items = events.data?.items ?? []; + const period = defaultPeriod() + const events = useBrokerEvents(accountId, period) + const items = events.data?.items ?? [] - if (events.error) return null; + if (events.error) return null if (events.isLoading) { return ( @@ -49,12 +49,12 @@ export function BrokerEventsOverview({ accountId }: { accountId: string }) { Загрузка событий… - ); + ) } - if (items.length === 0) return null; + if (items.length === 0) return null - const sliced = items.slice(0, 5); + const sliced = items.slice(0, 5) return ( @@ -95,5 +95,5 @@ export function BrokerEventsOverview({ accountId }: { accountId: string }) { ))} - ); + ) } diff --git a/apps/frontend/src/widgets/broker-operations-table/index.ts b/apps/frontend/src/widgets/broker-operations-table/index.ts index 234fc6b..8af6d70 100644 --- a/apps/frontend/src/widgets/broker-operations-table/index.ts +++ b/apps/frontend/src/widgets/broker-operations-table/index.ts @@ -1 +1 @@ -export { BrokerOperationsTable } from './ui/BrokerOperationsTable'; +export { BrokerOperationsTable } from './ui/BrokerOperationsTable' diff --git a/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx index 4854290..50aa765 100644 --- a/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx +++ b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx @@ -1,22 +1,22 @@ -import { Link } from 'react-router-dom'; -import type { ReactNode } from 'react'; -import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api/responses'; -import { Box } from '@mui/material'; -import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'; -import { TableSkeleton } from '@/shared/ui/TableSkeleton'; +import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link } from '@tanstack/react-router' +import type { ReactNode } from 'react' import { + type BrokerOperationImpact, getBrokerOperationImpact, getBrokerOperationTypeLabel, - type BrokerOperationImpact, -} from '@/entities/broker-operation'; -import { getBrokerInstrumentPath } from '@/entities/broker-position'; -import { formatBrokerSignedMoney } from '@/shared/lib/formatters'; +} from '@/entities/broker-operation' +import { getBrokerInstrumentPath } from '@/entities/broker-position' +import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api' +import { formatBrokerSignedMoney } from '@/shared/lib/formatters' +import { TableSkeleton } from '@/shared/ui/TableSkeleton' const tableSx = { width: '100%', borderCollapse: 'collapse', fontSize: 14, -} as const; +} as const const thSx = { borderBottom: '1px solid', @@ -25,7 +25,7 @@ const thSx = { fontWeight: 600, p: 1, textAlign: 'left', -} as const; +} as const const tdSx = { borderBottom: '1px solid', @@ -33,37 +33,37 @@ const tdSx = { p: 1, verticalAlign: 'top', textAlign: 'left', -} as const; +} as const const tdSxRight = { ...tdSx, textAlign: 'right', -} as const; +} as const function formatDate(value: string | null) { - if (!value) return '-'; + if (!value) return '-' - return new Date(value).toLocaleString('ru-RU'); + return new Date(value).toLocaleString('ru-RU') } function operationTone(impact: BrokerOperationImpact) { - if (impact === 'adds') return 'positive' as const; - if (impact === 'reduces') return 'negative' as const; - return 'primary' as const; + if (impact === 'adds') return 'positive' as const + if (impact === 'reduces') return 'negative' as const + return 'primary' as const } function OperationInstrument({ operation }: { operation: BrokerOperation }) { - const ticker = operation.ticker || operation.description || '-'; + const ticker = operation.ticker || operation.description || '-' const path = getBrokerInstrumentPath({ ticker: operation.ticker, instrumentType: operation.instrumentType, classCode: operation.classCode, - }); - const name = operation.name || operation.description; + }) + const name = operation.name || operation.description - if (!path && !name) return -; - if (!path) return {name}; - if (!ticker || ticker === '-') return {name}; + if (!path && !name) return - + if (!path) return {name} + if (!ticker || ticker === '-') return {name} return ( @@ -78,7 +78,7 @@ function OperationInstrument({ operation }: { operation: BrokerOperation }) { )} - ); + ) } export function BrokerOperationsTable({ @@ -90,12 +90,12 @@ export function BrokerOperationsTable({ page, pagination, }: BrokerOperationsTableProps) { - const pageNumber = pagination?.pageNumber; - const canGoBack = pagination?.canGoBack ?? false; - const canGoForward = pagination?.canGoForward ?? false; - const onPrevious = pagination?.onPrevious; - const onNext = pagination?.onNext; - const operations = page?.items ?? []; + const pageNumber = pagination?.pageNumber + const canGoBack = pagination?.canGoBack ?? false + const canGoForward = pagination?.canGoForward ?? false + const onPrevious = pagination?.onPrevious + const onNext = pagination?.onNext + const operations = page?.items ?? [] return (
@@ -196,7 +196,7 @@ export function BrokerOperationsTable({ {operations.map((operation) => { - const impact = getBrokerOperationImpact(operation); + const impact = getBrokerOperationImpact(operation) return ( @@ -225,7 +225,7 @@ export function BrokerOperationsTable({ {formatBrokerSignedMoney(operation.payment)} - ); + ) })} @@ -251,21 +251,21 @@ export function BrokerOperationsTable({ )}
- ); + ) } type BrokerOperationsTableProps = { - title: string; - headerAction?: ReactNode; - emptyMessage: string; - isLoading: boolean; - isFetching: boolean; - page: BrokerOperationsPage | undefined; + title: string + headerAction?: ReactNode + emptyMessage: string + isLoading: boolean + isFetching: boolean + page: BrokerOperationsPage | undefined pagination?: { - pageNumber: number; - canGoBack: boolean; - canGoForward: boolean; - onPrevious: () => void; - onNext: () => void; - }; -}; + pageNumber: number + canGoBack: boolean + canGoForward: boolean + onPrevious: () => void + onNext: () => void + } +} diff --git a/apps/frontend/src/widgets/broker-overview/index.ts b/apps/frontend/src/widgets/broker-overview/index.ts index 8d49b7e..0910f05 100644 --- a/apps/frontend/src/widgets/broker-overview/index.ts +++ b/apps/frontend/src/widgets/broker-overview/index.ts @@ -1,3 +1,3 @@ -export { BrokerSummary } from './ui/BrokerSummary'; -export { BrokerAssetCards } from './ui/BrokerAssetCards'; -export { BrokerOverviewSkeleton } from './ui/BrokerOverviewSkeleton'; +export { BrokerAssetCards } from './ui/BrokerAssetCards' +export { BrokerOverviewSkeleton } from './ui/BrokerOverviewSkeleton' +export { BrokerSummary } from './ui/BrokerSummary' diff --git a/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx b/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx index 5f1dc51..101a91f 100644 --- a/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx +++ b/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx @@ -1,16 +1,16 @@ -import { Link } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Text } from '@moex-vibe/design-system'; -import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; -import { formatBrokerMoney, pluralize } from '@/shared/lib/formatters'; +import { Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link } from '@tanstack/react-router' +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' +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; + 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)}%`; + return value === null ? '\u2014' : `${value.toFixed(1)}%` } const cardSx = { @@ -21,16 +21,16 @@ const cardSx = { bgcolor: 'surface.default', display: 'grid', gap: 1, -}; +} export function BrokerAssetCards({ accountId, portfolio, }: { - accountId: string; - portfolio: BrokerPortfolio; + accountId: string + portfolio: BrokerPortfolio }) { - const basePath = `/broker/${encodeURIComponent(accountId)}`; + const basePath = `/broker/${encodeURIComponent(accountId)}` const cards = [ { label: 'Акции', @@ -46,7 +46,7 @@ export function BrokerAssetCards({ value: portfolio.totals.bonds, path: `${basePath}/bonds`, }, - ]; + ] return ( @@ -67,5 +67,5 @@ export function BrokerAssetCards({ ))} - ); + ) } diff --git a/apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx b/apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx index b9a8964..0c7ed49 100644 --- a/apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx +++ b/apps/frontend/src/widgets/broker-overview/ui/BrokerOverviewSkeleton.tsx @@ -1,5 +1,5 @@ -import { Box } from '@mui/material'; -import { Skeleton } from '@moex-vibe/design-system'; +import { Skeleton } from '@moex-vibe/design-system' +import { Box } from '@mui/material' export function BrokerOverviewSkeleton() { return ( @@ -51,5 +51,5 @@ export function BrokerOverviewSkeleton() { ))} - ); + ) } diff --git a/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx b/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx index d87e458..abfe56d 100644 --- a/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx +++ b/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx @@ -1,10 +1,10 @@ -import { Box } from '@mui/material'; -import { Text } from '@moex-vibe/design-system'; -import type { BrokerPortfolio } from '@/shared/api/responses'; +import { Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import type { BrokerPortfolio } from '@/shared/api' import { formatBrokerMoney as formatMoney, formatBrokerPercent as formatPercent, -} from '@/shared/lib/formatters'; +} from '@/shared/lib/formatters' export function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { return ( @@ -64,5 +64,5 @@ export function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { )} - ); + ) } diff --git a/apps/frontend/src/widgets/broker-positions-table/index.ts b/apps/frontend/src/widgets/broker-positions-table/index.ts index eef2f56..90922f7 100644 --- a/apps/frontend/src/widgets/broker-positions-table/index.ts +++ b/apps/frontend/src/widgets/broker-positions-table/index.ts @@ -1 +1 @@ -export { BrokerPositionTable } from './ui/BrokerPositionTable'; +export { BrokerPositionTable } from './ui/BrokerPositionTable' diff --git a/apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx b/apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx index 5422d18..30c3aa8 100644 --- a/apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx +++ b/apps/frontend/src/widgets/broker-positions-table/ui/BrokerPositionTable.tsx @@ -1,14 +1,14 @@ -import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses'; -import { Box } from '@mui/material'; -import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'; -import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters'; -import { PositionTicker } from './PositionTicker'; +import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api' +import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters' +import { PositionTicker } from './PositionTicker' const tableSx = { width: '100%', borderCollapse: 'collapse', fontSize: 14, -} as const; +} as const const thSx = { borderBottom: '1px solid', @@ -17,7 +17,7 @@ const thSx = { fontWeight: 600, p: 1, textAlign: 'left', -} as const; +} as const const tdSx = { borderBottom: '1px solid', @@ -25,15 +25,15 @@ const tdSx = { p: 1, verticalAlign: 'top', textAlign: 'right', -} as const; +} as const const tdSxLeft = { ...tdSx, textAlign: 'left', -} as const; +} as const function formatQuantity(value: number | null | undefined) { - return value == null ? '-' : value.toLocaleString('ru-RU'); + return value == null ? '-' : value.toLocaleString('ru-RU') } export function BrokerPositionTable({ @@ -46,18 +46,18 @@ export function BrokerPositionTable({ onNext, onPrevious, }: { - title: string; - page: BrokerPositionsPageData | undefined; - isLoading: boolean; - isFetching: boolean; - emptyMessage: string; - pageNumber: number; - onNext: () => void; - onPrevious: () => void; + 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); + const positions = page?.items ?? [] + const canGoBack = pageNumber > 1 + const canGoForward = Boolean(page?.hasNext && page.nextCursor) return (
@@ -230,5 +230,5 @@ export function BrokerPositionTable({ )}
- ); + ) } diff --git a/apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx b/apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx index fbc1f66..0b2fb81 100644 --- a/apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx +++ b/apps/frontend/src/widgets/broker-positions-table/ui/PositionTicker.tsx @@ -1,22 +1,22 @@ -import { Link } from 'react-router-dom'; -import { Box } from '@mui/material'; -import type { BrokerPosition } from '@/shared/api/responses'; -import { getBrokerInstrumentPath } from '@/entities/broker-position'; +import { Box } from '@mui/material' +import { Link } from '@tanstack/react-router' +import { getBrokerInstrumentPath } from '@/entities/broker-position' +import type { BrokerPosition } from '@/shared/api' export function PositionTicker({ position }: { position: BrokerPosition }) { - const label = position.ticker || position.figi || '-'; + const label = position.ticker || position.figi || '-' const path = getBrokerInstrumentPath({ ticker: position.ticker, instrumentType: position.instrumentType, classCode: position.classCode, - }); + }) if (!path || label === '-') { return ( {label} - ); + ) } return ( @@ -25,5 +25,5 @@ export function PositionTicker({ position }: { position: BrokerPosition }) { {label} - ); + ) } diff --git a/apps/frontend/src/widgets/dividends-table/index.ts b/apps/frontend/src/widgets/dividends-table/index.ts index 3ab7a09..117d86b 100644 --- a/apps/frontend/src/widgets/dividends-table/index.ts +++ b/apps/frontend/src/widgets/dividends-table/index.ts @@ -1 +1 @@ -export { DividendsTable } from './ui/DividendsTable'; +export { DividendsTable } from './ui/DividendsTable' diff --git a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx index edbbe1a..1e25c7d 100644 --- a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx +++ b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx @@ -1,15 +1,15 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { DividendsTable } from './DividendsTable'; -import { createMockDividends } from '@/shared/lib/test/factories'; +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { createMockDividends } from '@/shared/lib/test/factories' +import { DividendsTable } from './DividendsTable' describe('DividendsTable', () => { it('renders title, date column and formatted amount with currency', () => { - render(); + render() - expect(screen.getByText('Дивиденды')).toBeInTheDocument(); - expect(screen.getByText('Дата закрытия реестра')).toBeInTheDocument(); - expect(screen.getByText('2024-07-10')).toBeInTheDocument(); - expect(screen.getByText('35.00 RUB')).toBeInTheDocument(); - }); -}); + expect(screen.getByText('Дивиденды')).toBeInTheDocument() + expect(screen.getByText('Дата закрытия реестра')).toBeInTheDocument() + expect(screen.getByText('2024-07-10')).toBeInTheDocument() + expect(screen.getByText('35.00 RUB')).toBeInTheDocument() + }) +}) diff --git a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx index f127f55..bfb4194 100644 --- a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx +++ b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx @@ -1,7 +1,7 @@ -import type { DividendItem } from '@/shared/api/responses'; +import type { DividendItem } from '@/shared/api' interface DividendsTableProps { - dividends: DividendItem[]; + dividends: DividendItem[] } export function DividendsTable({ dividends }: DividendsTableProps) { @@ -34,5 +34,5 @@ export function DividendsTable({ dividends }: DividendsTableProps) { - ); + ) } diff --git a/apps/frontend/src/widgets/portfolio-analytics/index.ts b/apps/frontend/src/widgets/portfolio-analytics/index.ts index e89a5f5..56bcaa7 100644 --- a/apps/frontend/src/widgets/portfolio-analytics/index.ts +++ b/apps/frontend/src/widgets/portfolio-analytics/index.ts @@ -1 +1 @@ -export { AnalyticsSummary } from './ui/AnalyticsSummary'; +export { AnalyticsSummary } from './ui/AnalyticsSummary' diff --git a/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx index b2139ba..e6d1358 100644 --- a/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx +++ b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx @@ -1,19 +1,19 @@ -import type { PortfolioSummary } from '@/shared/api/responses'; +import type { PortfolioSummary } from '@/shared/api' export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) { const formatRub = (val: number | null) => val != null ? val.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) - : '—'; + : '—' - const formatPct = (val: number | null) => (val != null ? `${val.toFixed(2)}%` : '—'); + const formatPct = (val: number | null) => (val != null ? `${val.toFixed(2)}%` : '—') const pnlColor = summary.totalPnl > 0 ? 'var(--color-positive)' : summary.totalPnl < 0 ? 'var(--color-negative)' - : 'inherit'; + : 'inherit' return (
- ); + ) } diff --git a/apps/frontend/src/widgets/portfolio-card/index.ts b/apps/frontend/src/widgets/portfolio-card/index.ts index e2914ab..685492f 100644 --- a/apps/frontend/src/widgets/portfolio-card/index.ts +++ b/apps/frontend/src/widgets/portfolio-card/index.ts @@ -1 +1 @@ -export { PortfolioCard } from './ui/PortfolioCard'; +export { PortfolioCard } from './ui/PortfolioCard' diff --git a/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx b/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx index eb4b2a8..fdae5c1 100644 --- a/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx +++ b/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx @@ -1,6 +1,6 @@ -import { Link } from 'react-router-dom'; -import type { Portfolio } from '@/shared/api/responses'; -import { pluralize } from '@/shared/lib/formatters'; +import { Link } from '@tanstack/react-router' +import type { Portfolio } from '@/shared/api' +import { pluralize } from '@/shared/lib/formatters' export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { const chipStyle = (bg: string): React.CSSProperties => ({ @@ -10,7 +10,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { fontSize: 12, color: '#fff', fontWeight: 500, - }); + }) return ( - ); + ) } diff --git a/apps/frontend/src/widgets/portfolio-form/index.ts b/apps/frontend/src/widgets/portfolio-form/index.ts index a030012..78b4e7f 100644 --- a/apps/frontend/src/widgets/portfolio-form/index.ts +++ b/apps/frontend/src/widgets/portfolio-form/index.ts @@ -1 +1 @@ -export { PortfolioForm } from './ui/PortfolioForm'; +export { PortfolioForm } from './ui/PortfolioForm' diff --git a/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx index 870f288..73724eb 100644 --- a/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx +++ b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx @@ -1,24 +1,24 @@ -import { useState } from 'react'; -import type { Portfolio } from '@/shared/api/responses'; +import { useState } from 'react' +import type { Portfolio } from '@/shared/api' interface Props { - initial?: Portfolio; - onSave: (data: { name: string; description?: string; currency?: string }) => void; - onCancel: () => void; - isLoading?: boolean; + initial?: Portfolio + onSave: (data: { name: string; description?: string; currency?: string }) => void + onCancel: () => void + isLoading?: boolean } -const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN']; +const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) { - const [name, setName] = useState(initial?.name || ''); - const [description, setDescription] = useState(initial?.description || ''); - const [currency, setCurrency] = useState(initial?.currency || 'RUB'); + const [name, setName] = useState(initial?.name || '') + const [description, setDescription] = useState(initial?.description || '') + const [currency, setCurrency] = useState(initial?.currency || 'RUB') function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!name.trim()) return; - onSave({ name: name.trim(), description: description.trim() || undefined, currency }); + e.preventDefault() + if (!name.trim()) return + onSave({ name: name.trim(), description: description.trim() || undefined, currency }) } return ( @@ -115,5 +115,5 @@ export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) { - ); + ) } diff --git a/apps/frontend/src/widgets/portfolio-summary/index.ts b/apps/frontend/src/widgets/portfolio-summary/index.ts index aea75ad..0a721a1 100644 --- a/apps/frontend/src/widgets/portfolio-summary/index.ts +++ b/apps/frontend/src/widgets/portfolio-summary/index.ts @@ -1 +1 @@ -export { PortfolioSummary } from './ui/PortfolioSummary'; +export { PortfolioSummary } from './ui/PortfolioSummary' diff --git a/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx b/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx index 275b9ed..c614455 100644 --- a/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx +++ b/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx @@ -1,56 +1,56 @@ -import type { PositionWithPrice } from '@/shared/api/responses'; +import type { PositionWithPrice } from '@/shared/api' interface AllocationChartProps { - positions: PositionWithPrice[]; - totalValue: number; + positions: PositionWithPrice[] + totalValue: number } interface SectorData { - type: 'share' | 'bond'; - label: string; - value: number; - count: number; - color: string; + type: 'share' | 'bond' + label: string + value: number + count: number + color: string } const SECTOR_COLORS = { share: 'var(--color-primary, #1976d2)', bond: '#f57c00', -} as const; +} as const const SECTOR_LABELS = { share: 'Акции', bond: 'Облигации', -} as const; +} as const function computeSectors(positions: PositionWithPrice[]): SectorData[] { const sectors: SectorData[] = [ { type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share }, { type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond }, - ]; + ] for (const p of positions) { - const sector = sectors.find((s) => s.type === p.type); + const sector = sectors.find((s) => s.type === p.type) if (sector) { - sector.value += p.currentValue ?? 0; - sector.count += 1; + sector.value += p.currentValue ?? 0 + sector.count += 1 } } - return sectors; + return sectors } export function AllocationChart({ positions, totalValue }: AllocationChartProps) { - const sectors = computeSectors(positions); - const nonZero = sectors.filter((s) => s.value > 0); - const hasData = nonZero.length > 0; + const sectors = computeSectors(positions) + const nonZero = sectors.filter((s) => s.value > 0) + const hasData = nonZero.length > 0 - const cx = 60; - const cy = 60; - const r = 44; - const strokeWidth = 10; - const circumference = 2 * Math.PI * r; - const viewBoxSize = 120; + const cx = 60 + const cy = 60 + const r = 44 + const strokeWidth = 10 + const circumference = 2 * Math.PI * r + const viewBoxSize = 120 function renderArcs() { if (!hasData) { @@ -64,11 +64,11 @@ export function AllocationChart({ positions, totalValue }: AllocationChartProps) strokeWidth={strokeWidth} transform={`rotate(-90 ${cx} ${cy})`} /> - ); + ) } if (nonZero.length === 1) { - const sector = nonZero[0]; + const sector = nonZero[0] return ( - ); + ) } return sectors.map((sector, i) => { - const ratio = totalValue > 0 ? sector.value / totalValue : 0; - const dashLen = ratio * circumference; - const gapLen = circumference - dashLen; - let rotation = -90; + const ratio = totalValue > 0 ? sector.value / totalValue : 0 + const dashLen = ratio * circumference + const gapLen = circumference - dashLen + let rotation = -90 for (let j = 0; j < i; j++) { - const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0; - rotation += prevRatio * 360; + const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0 + rotation += prevRatio * 360 } return ( @@ -106,8 +106,8 @@ export function AllocationChart({ positions, totalValue }: AllocationChartProps) transform={`rotate(${rotation} ${cx} ${cy})`} style={{ transition: 'stroke-dasharray 0.3s ease' }} /> - ); - }); + ) + }) } return ( @@ -137,7 +137,7 @@ export function AllocationChart({ positions, totalValue }: AllocationChartProps)
{sectors.map((s) => { - const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0; + const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0 return (
- ); + ) })}
- ); + ) } diff --git a/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx b/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx index 98dfabf..dfda404 100644 --- a/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx +++ b/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx @@ -1,5 +1,5 @@ -import { AllocationChart } from './AllocationChart'; -import type { PortfolioDetail } from '@/shared/api/responses'; +import type { PortfolioDetail } from '@/shared/api' +import { AllocationChart } from './AllocationChart' export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) { return ( @@ -42,5 +42,5 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
{portfolio.positions.length}
- ); + ) } diff --git a/apps/frontend/src/widgets/price-chart/index.ts b/apps/frontend/src/widgets/price-chart/index.ts index 0ecfa29..f248508 100644 --- a/apps/frontend/src/widgets/price-chart/index.ts +++ b/apps/frontend/src/widgets/price-chart/index.ts @@ -1 +1 @@ -export { PriceChart } from './ui/PriceChart'; +export { PriceChart } from './ui/PriceChart' diff --git a/apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx b/apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx index fe00156..83569e6 100644 --- a/apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx +++ b/apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx @@ -1,21 +1,21 @@ -import { describe, it, expect } from 'vitest'; -import { render } from '@testing-library/react'; -import { PriceChart } from './PriceChart'; +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { PriceChart } from './PriceChart' describe('PriceChart', () => { it('renders chart container with empty data', () => { - const { container } = render(); - expect(container.querySelector('div')).toBeInTheDocument(); - }); + const { container } = render() + expect(container.querySelector('div')).toBeInTheDocument() + }) it('renders with candle data', () => { - const data = [{ open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }]; - const { container } = render(); - expect(container.querySelector('div')).toBeInTheDocument(); - }); + const data = [{ open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }] + const { container } = render() + expect(container.querySelector('div')).toBeInTheDocument() + }) it('accepts custom height', () => { - const { container } = render(); - expect(container.querySelector('div')).toBeInTheDocument(); - }); -}); + const { container } = render() + expect(container.querySelector('div')).toBeInTheDocument() + }) +}) diff --git a/apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx b/apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx index 4df9440..148833d 100644 --- a/apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx +++ b/apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx @@ -1,22 +1,22 @@ -import { useEffect, useRef } from 'react'; -import { createChart, ColorType, CandlestickData, Time } from 'lightweight-charts'; +import { type CandlestickData, ColorType, createChart, type Time } from 'lightweight-charts' +import { useEffect, useRef } from 'react' interface PriceChartProps { data: Array<{ - open: number; - high: number; - low: number; - close: number; - begin: string; - }>; - height?: number; + open: number + high: number + low: number + close: number + begin: string + }> + height?: number } export function PriceChart({ data, height = 400 }: PriceChartProps) { - const chartContainerRef = useRef(null); + const chartContainerRef = useRef(null) useEffect(() => { - if (!chartContainerRef.current) return; + if (!chartContainerRef.current) return const chart = createChart(chartContainerRef.current, { layout: { @@ -32,7 +32,7 @@ export function PriceChart({ data, height = 400 }: PriceChartProps) { timeScale: { timeVisible: false, }, - }); + }) const candleSeries = chart.addCandlestickSeries({ upColor: '#2e7d32', @@ -41,7 +41,7 @@ export function PriceChart({ data, height = 400 }: PriceChartProps) { borderUpColor: '#2e7d32', wickDownColor: '#c62828', wickUpColor: '#2e7d32', - }); + }) const chartData: CandlestickData[] = data.map((candle) => ({ time: (new Date(candle.begin).getTime() / 1000) as Time, @@ -49,23 +49,23 @@ export function PriceChart({ data, height = 400 }: PriceChartProps) { high: candle.high, low: candle.low, close: candle.close, - })); + })) - candleSeries.setData(chartData); - chart.timeScale().fitContent(); + candleSeries.setData(chartData) + chart.timeScale().fitContent() const handleResize = () => { if (chartContainerRef.current) { - chart.applyOptions({ width: chartContainerRef.current.clientWidth }); + chart.applyOptions({ width: chartContainerRef.current.clientWidth }) } - }; - window.addEventListener('resize', handleResize); + } + window.addEventListener('resize', handleResize) return () => { - window.removeEventListener('resize', handleResize); - chart.remove(); - }; - }, [data, height]); + window.removeEventListener('resize', handleResize) + chart.remove() + } + }, [data, height]) - return
; + return
} diff --git a/apps/frontend/src/widgets/search-bar/index.ts b/apps/frontend/src/widgets/search-bar/index.ts index 042e3b7..06fe73a 100644 --- a/apps/frontend/src/widgets/search-bar/index.ts +++ b/apps/frontend/src/widgets/search-bar/index.ts @@ -1 +1 @@ -export { SearchBar } from './ui/SearchBar'; +export { SearchBar } from './ui/SearchBar' diff --git a/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx b/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx index eeafe19..3d921c2 100644 --- a/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx +++ b/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx @@ -1,57 +1,72 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter } from 'react-router-dom'; -import { server } from '@/shared/lib/test/server'; -import { SearchBar } from '@/widgets/search-bar'; +import { server } from '@mocks/server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, http } from 'msw' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { SearchBar } from '@/widgets/search-bar' -const API = '/api/v1'; +const API = '/api/v1' + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), + Link: ({ + to, + children, + ...props + }: { + to: string + children: ReactNode + [key: string]: unknown + }) => ( + + {children} + + ), +})) function renderSearchBar() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, - }); + }) return render( - - - + , - ); + ) } describe('SearchBar', () => { it('renders search input', () => { - renderSearchBar(); + renderSearchBar() - expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); - }); + expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument() + }) it('shows dropdown on focus', async () => { - renderSearchBar(); - const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + renderSearchBar() + const input = screen.getByPlaceholderText('Поиск акций и облигаций...') - await userEvent.type(input, 'sber'); + await userEvent.type(input, 'sber') await waitFor(() => { - expect(screen.getByText('Сбер')).toBeInTheDocument(); - }); - }); + expect(screen.getByText('Сбер')).toBeInTheDocument() + }) + }) it('shows loading state while fetching', async () => { - server.use(http.get(`${API}/securities/search`, () => new Promise(() => {}))); - renderSearchBar(); - const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + server.use(http.get(`${API}/securities/search`, () => new Promise(() => {}))) + renderSearchBar() + const input = screen.getByPlaceholderText('Поиск акций и облигаций...') - await userEvent.type(input, 'sber'); + await userEvent.type(input, 'sber') await waitFor(() => { - expect(screen.getByText('Загрузка...')).toBeInTheDocument(); - }); - }); + expect(screen.getByText('Загрузка...')).toBeInTheDocument() + }) + }) it('shows no results message', async () => { server.use( @@ -60,31 +75,31 @@ describe('SearchBar', () => { data: { data: [], meta: { fromCache: false, cachedAt: null } }, }), ), - ); - renderSearchBar(); - const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + ) + renderSearchBar() + const input = screen.getByPlaceholderText('Поиск акций и облигаций...') - await userEvent.type(input, 'zzzzz'); + await userEvent.type(input, 'zzzzz') await waitFor(() => { - expect(screen.getByText('Ничего не найдено')).toBeInTheDocument(); - }); - }); + expect(screen.getByText('Ничего не найдено')).toBeInTheDocument() + }) + }) it('hides dropdown when clicking outside', async () => { - renderSearchBar(); - const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + renderSearchBar() + const input = screen.getByPlaceholderText('Поиск акций и облигаций...') - await userEvent.type(input, 'sber'); + await userEvent.type(input, 'sber') await waitFor(() => { - expect(screen.getByText('Сбер')).toBeInTheDocument(); - }); + expect(screen.getByText('Сбер')).toBeInTheDocument() + }) - await userEvent.click(document.body); + await userEvent.click(document.body) await waitFor(() => { - expect(screen.queryByText('Сбер')).not.toBeInTheDocument(); - }); - }); -}); + expect(screen.queryByText('Сбер')).not.toBeInTheDocument() + }) + }) +}) diff --git a/apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx b/apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx index e1b75d4..c500836 100644 --- a/apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx +++ b/apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx @@ -1,37 +1,37 @@ -import { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box } from '@mui/material'; -import { Chip, Surface, Text, TextField } from '@moex-vibe/design-system'; -import { useSearch } from '@/entities/search'; +import { Chip, Surface, Text, TextField } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' +import { useSearch } from '@/entities/search' export function SearchBar() { - const [query, setQuery] = useState(''); - const [debounced, setDebounced] = useState(''); - const [open, setOpen] = useState(false); - const navigate = useNavigate(); - const ref = useRef(null); + const [query, setQuery] = useState('') + const [debounced, setDebounced] = useState('') + const [open, setOpen] = useState(false) + const navigate = useNavigate() + const ref = useRef(null) useEffect(() => { - const id = setTimeout(() => setDebounced(query), 300); + const id = setTimeout(() => setDebounced(query), 300) - return () => clearTimeout(id); - }, [query]); + return () => clearTimeout(id) + }, [query]) - const { data: results, isLoading } = useSearch(debounced); + const { data: results, isLoading } = useSearch(debounced) useEffect(() => { function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); + setOpen(false) } } - document.addEventListener('mousedown', handleClick); + document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick); - }, []); + return () => document.removeEventListener('mousedown', handleClick) + }, []) - const showResults = open && debounced.length >= 2; + const showResults = open && debounced.length >= 2 return ( @@ -41,8 +41,8 @@ export function SearchBar() { hiddenLabel value={query} onChange={(e) => { - setQuery(e.target.value); - setOpen(true); + setQuery(e.target.value) + setOpen(true) }} onFocus={() => setOpen(true)} /> @@ -75,11 +75,11 @@ export function SearchBar() { { - setOpen(false); - setQuery(''); - navigate( - item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`, - ); + setOpen(false) + setQuery('') + navigate({ + to: item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`, + }) }} sx={{ display: 'flex', @@ -110,5 +110,5 @@ export function SearchBar() { )} - ); + ) } diff --git a/apps/frontend/src/widgets/share-positions-table/index.ts b/apps/frontend/src/widgets/share-positions-table/index.ts index 59ee3d8..92da771 100644 --- a/apps/frontend/src/widgets/share-positions-table/index.ts +++ b/apps/frontend/src/widgets/share-positions-table/index.ts @@ -1 +1 @@ -export { SharePositionTable } from './ui/SharePositionTable'; +export { SharePositionTable } from './ui/SharePositionTable' diff --git a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx index 4aca8e7..9d01d80 100644 --- a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx +++ b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx @@ -1,35 +1,35 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import type { PositionWithPrice } from '@/shared/api/responses'; +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import type { PositionWithPrice } from '@/shared/api' interface Props { - position: PositionWithPrice; - onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; - onDelete: () => void; + position: PositionWithPrice + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void + onDelete: () => void } export function SharePositionRow({ position, onUpdate, onDelete }: Props) { - const [editingQty, setEditingQty] = useState(false); - const [editingPrice, setEditingPrice] = useState(false); - const [qty, setQty] = useState(String(position.quantity)); - const [price, setPrice] = useState(String(position.buyPrice ?? '')); + const [editingQty, setEditingQty] = useState(false) + const [editingPrice, setEditingPrice] = useState(false) + const [qty, setQty] = useState(String(position.quantity)) + const [price, setPrice] = useState(String(position.buyPrice ?? '')) function handleSaveQty() { - const num = parseInt(qty, 10); - if (!isNaN(num) && num >= 0 && num !== position.quantity) { - onUpdate({ quantity: num }); + const num = parseInt(qty, 10) + if (!Number.isNaN(num) && num >= 0 && num !== position.quantity) { + onUpdate({ quantity: num }) } - setEditingQty(false); + setEditingQty(false) } function handleSavePrice() { - const num = parseFloat(price); - if (!isNaN(num) && num >= 0 && num !== position.buyPrice) { - onUpdate({ buyPrice: num }); + const num = parseFloat(price) + if (!Number.isNaN(num) && num >= 0 && num !== position.buyPrice) { + onUpdate({ buyPrice: num }) } else if (price === '' && position.buyPrice !== null) { - onUpdate({ buyPrice: undefined }); // Or handle null if backend supports it + onUpdate({ buyPrice: undefined }) // Or handle null if backend supports it } - setEditingPrice(false); + setEditingPrice(false) } return ( @@ -51,7 +51,6 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { onChange={(e) => setQty(e.target.value)} onBlur={handleSaveQty} onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()} - autoFocus style={{ width: 80, padding: '4px 8px', @@ -63,8 +62,8 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { ) : ( { - setQty(String(position.quantity)); - setEditingQty(true); + setQty(String(position.quantity)) + setEditingQty(true) }} style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} > @@ -81,7 +80,6 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { onChange={(e) => setPrice(e.target.value)} onBlur={handleSavePrice} onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()} - autoFocus style={{ width: 100, padding: '4px 8px', @@ -94,8 +92,8 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { ) : ( { - setPrice(String(position.buyPrice ?? '')); - setEditingPrice(true); + setPrice(String(position.buyPrice ?? '')) + setEditingPrice(true) }} style={{ cursor: 'pointer', @@ -199,5 +197,5 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { - ); + ) } diff --git a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx index 0463244..51281e0 100644 --- a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx +++ b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx @@ -1,17 +1,17 @@ -import { SharePositionRow } from './SharePositionRow'; -import type { PositionWithPrice } from '@/shared/api/responses'; +import type { PositionWithPrice } from '@/shared/api' +import { SharePositionRow } from './SharePositionRow' interface Props { - positions: PositionWithPrice[]; + positions: PositionWithPrice[] onUpdatePosition: ( positionId: number, data: { quantity?: number; buyPrice?: number; buyDate?: string }, - ) => void; - onDeletePosition: (positionId: number) => void; + ) => void + onDeletePosition: (positionId: number) => void } export function SharePositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) { - if (positions.length === 0) return null; + if (positions.length === 0) return null return (
@@ -159,5 +159,5 @@ export function SharePositionTable({ positions, onUpdatePosition, onDeletePositi
- ); + ) } diff --git a/apps/frontend/src/widgets/stock-details/index.ts b/apps/frontend/src/widgets/stock-details/index.ts index 36b0b00..d86d16d 100644 --- a/apps/frontend/src/widgets/stock-details/index.ts +++ b/apps/frontend/src/widgets/stock-details/index.ts @@ -1 +1 @@ -export { StockDetails } from './ui/StockDetails'; +export { StockDetails } from './ui/StockDetails' diff --git a/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx b/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx index eb91285..3595acf 100644 --- a/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx +++ b/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx @@ -1,15 +1,15 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { StockDetails } from './StockDetails'; -import { createMockShare } from '@/shared/lib/test/factories'; +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { createMockShare } from '@/shared/lib/test/factories' +import { StockDetails } from './StockDetails' describe('StockDetails', () => { it('renders stock details', () => { - const stock = createMockShare(); - render(); - expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument(); - expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument(); - }); + const stock = createMockShare() + render() + expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument() + expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument() + }) it('displays positive change in green', () => { const stock = createMockShare({ @@ -18,11 +18,11 @@ describe('StockDetails', () => { change: 5, changePercent: 2, }, - }); - render(); - const changeEl = screen.getByText('+5.00 (2.00%)'); - expect(changeEl).toBeInTheDocument(); - }); + }) + render() + const changeEl = screen.getByText('+5.00 (2.00%)') + expect(changeEl).toBeInTheDocument() + }) it('displays negative change in red', () => { const stock = createMockShare({ @@ -31,17 +31,17 @@ describe('StockDetails', () => { change: -3, changePercent: -1, }, - }); - render(); - const changeEl = screen.getByText('-3.00 (-1.00%)'); - expect(changeEl).toBeInTheDocument(); - }); + }) + render() + const changeEl = screen.getByText('-3.00 (-1.00%)') + expect(changeEl).toBeInTheDocument() + }) it('shows price formatted', () => { - const stock = createMockShare(); - render(); - expect(screen.getByText('289,50')).toBeInTheDocument(); - }); + const stock = createMockShare() + render() + expect(screen.getByText('289,50')).toBeInTheDocument() + }) it('shows dash for null high', () => { const stock = createMockShare({ @@ -49,17 +49,17 @@ describe('StockDetails', () => { ...createMockShare().marketData, high: null, }, - }); - render(); - const dashes = screen.getAllByText('—'); - expect(dashes.length).toBeGreaterThanOrEqual(1); - }); + }) + render() + const dashes = screen.getAllByText('—') + expect(dashes.length).toBeGreaterThanOrEqual(1) + }) it('shows capitalization in billions', () => { - const stock = createMockShare(); - render(); - expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument(); - }); + const stock = createMockShare() + render() + expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument() + }) it('falls back to zero change when change data is missing', () => { const stock = createMockShare({ @@ -68,9 +68,9 @@ describe('StockDetails', () => { change: null, changePercent: null, }, - }); - render(); - expect(screen.getByText('289,50')).toBeInTheDocument(); - expect(screen.getByText('+0.00 (0.00%)')).toBeInTheDocument(); - }); -}); + }) + render() + expect(screen.getByText('289,50')).toBeInTheDocument() + expect(screen.getByText('+0.00 (0.00%)')).toBeInTheDocument() + }) +}) diff --git a/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx b/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx index dacdb6b..75efe52 100644 --- a/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx +++ b/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx @@ -1,7 +1,7 @@ -import type { ShareResponse } from '@/shared/api/responses'; +import type { ShareResponse } from '@/shared/api' interface StockDetailsProps { - stock: ShareResponse; + stock: ShareResponse } const rowStyle: React.CSSProperties = { @@ -9,14 +9,14 @@ const rowStyle: React.CSSProperties = { justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #eee', -}; +} export function StockDetails({ stock }: StockDetailsProps) { - const md = stock.marketData; - const change = md.change ?? 0; - const changePercent = md.changePercent ?? 0; - const isPositive = change >= 0; - const changeLabel = `${isPositive ? '+' : ''}${change.toFixed(2)} (${changePercent.toFixed(2)}%)`; + const md = stock.marketData + const change = md.change ?? 0 + const changePercent = md.changePercent ?? 0 + const isPositive = change >= 0 + const changeLabel = `${isPositive ? '+' : ''}${change.toFixed(2)} (${changePercent.toFixed(2)}%)` return (
Капитализация - {md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'} + {md.issueCapitalization ? `${(md.issueCapitalization / 1e9).toFixed(2)} млрд ₽` : '—'}
@@ -81,5 +81,5 @@ export function StockDetails({ stock }: StockDetailsProps) {
- ); + ) } diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 9c70fcd..4d24333 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -17,10 +17,11 @@ "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@mocks/*": ["./mocks/*"] } }, - "include": ["src"], + "include": ["src", "mocks"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/shared/lib/test/**"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 2e4a0a3..843f245 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -5,9 +5,10 @@ import path from 'path'; export default defineConfig({ plugins: [react()], resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, + alias: [ + { find: '@mocks', replacement: path.resolve(__dirname, './mocks') }, + { find: '@', replacement: path.resolve(__dirname, './src') }, + ], }, server: { port: 5173, diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts index a11c0ae..1617c16 100644 --- a/apps/frontend/vitest.config.ts +++ b/apps/frontend/vitest.config.ts @@ -6,9 +6,10 @@ export default defineConfig({ plugins: [react()], logLevel: 'error', resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, + alias: [ + { find: '@mocks', replacement: path.resolve(__dirname, './mocks') }, + { find: '@', replacement: path.resolve(__dirname, './src') }, + ], }, test: { environment: 'jsdom', diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..a441d3f --- /dev/null +++ b/biome.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "root": true, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100 + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "complexity": { + "noBannedTypes": "error", + "noUselessTypeConstraint": "error" + }, + "correctness": { + "noChildrenProp": "error", + "noPrecisionLoss": "error", + "noUnusedVariables": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "useJsxKeyInIterable": "error" + }, + "security": { + "noDangerouslySetInnerHtmlWithChildren": "error" + }, + "style": { + "noNamespace": "error", + "noNonNullAssertion": "off", + "noRestrictedImports": { + "level": "warn", + "options": { + "paths": { + "@mui/material": { + "importNames": [ + "Typography", + "Button", + "TextField", + "Select", + "Checkbox", + "Paper", + "Chip", + "Badge", + "Alert", + "Dialog", + "Skeleton", + "CircularProgress", + "Link", + "IconButton", + "Table", + "TableBody", + "TableCell", + "TableContainer", + "TableHead", + "TableRow", + "TableSortLabel", + "TablePagination", + "Pagination" + ], + "message": "Import from @moex-vibe/design-system instead, or use Box/Stack/Grid for layout." + } + } + } + }, + "useArrayLiterals": "error", + "useAsConstAssertion": "error" + }, + "suspicious": { + "noCommentText": "error", + "noDuplicateEnumValues": "error", + "noDuplicateJsxProps": "error", + "noExplicitAny": "off", + "noExtraNonNullAssertion": "error", + "noMisleadingInstantiator": "error", + "noNonNullAssertedOptionalChain": "error", + "noArrayIndexKey": "off", + "noUnsafeDeclarationMerging": "error" + }, + "a11y": { + "useButtonType": "off", + "noLabelWithoutControl": "off", + "noStaticElementInteractions": "off", + "useKeyWithClickEvents": "off", + "noAutofocus": "off", + "noSvgWithoutTitle": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "asNeeded" + } + } +} diff --git a/docs/features/frontend-infrastructure-tooling/plan.md b/docs/features/frontend-infrastructure-tooling/plan.md new file mode 100644 index 0000000..607f8f6 --- /dev/null +++ b/docs/features/frontend-infrastructure-tooling/plan.md @@ -0,0 +1,117 @@ +# Frontend Infrastructure Tooling — Implementation Plan + +## Architecture Decisions + +### ADR references +- ADR-005: Biome вместо ESLint + Prettier +- ADR-006: TanStack Router вместо react-router-dom +- ADR-007: OpenAPI codegen как единый источник типов + +### Non-ADR decisions +- ky как единый HTTP-клиент — выбор библиотеки, не меняющий архитектуры +- MSW browser mode — расширение существующей инфраструктуры тестов +- Zod для env validation — утилитарное улучшение + +## Phases + +Выполнять последовательно для минимизации конфликтов. + +### Phase 1 — ky Migration +*Низкий риск, обратно совместим* + +1. Доработать `kyClient.ts`: + - Добавить нормализацию конверта (normalizeEnvelope) в afterResponse hook + - Экспортировать `kyApi` и `configureKyAuth` +2. Поочерёдно перевести entity API на `kyApi`: + - session, stock, bond, search, portfolio, broker-* +3. Удалить `shared/api/client.ts` +4. Заменить `configureAuth` → `configureKyAuth` в точке входа +5. Прогнать тесты — поведение не должно измениться + +### Phase 2 — Biome +*Средний риск — изменения в коде при авто-миграции* + +1. Установить `@biomejs/biome` +2. `npx @biomejs/biome migrate eslint --write` +3. Создать `biome.json`, донастроить: + - Отключить несовместимые правила + - Настроить `files.ignore`, `linter.rules` +4. Проверить FSD-правила: @conarti/feature-sliced не портируются в Biome → оставить минимальный `.eslintrc.cjs` только для FSD +5. Удалить `eslint`, `prettier`, `.eslintrc.cjs` (если FSD не нужен) +6. Обновить `package.json`: `lint` скрипт → `biome check` +7. Обновить CI в `.gitea/workflows/ci.yml` +8. Обновить pre-commit hook (lint-staged → biome) +9. Прогнать `biome check --write`, закоммитить +10. Прогнать тесты + +### Phase 3 — Unify API Types +*Низкий риск* + +> **Фактический подход: ре-экспорты в index.ts вместо types.ts.** +> В процессе реализации выяснилось, что direct-импорты из `types.ts` ведут к многословным `components['schemas']['XxxDto']` в 60+ файлах. Принято решение перенести friendly-name ре-экспорты из `responses.ts` в `index.ts`. Таким образом `responses.ts` удалён, а единая точка входа — `@/shared/api`. + +1. Перенести friendly-name type-алиасы из `responses.ts` в `shared/api/index.ts` +2. Удалить `shared/api/responses.ts` +3. Переключить все импорты с `@/shared/api/responses` на `@/shared/api` +4. Перенести normalizeEnvelope (ky-версию) в `shared/api/kyClient.ts` +5. Прогнать `npm run build` + +### Phase 4 — MSW Browser +*Низкий риск, handlers готовы* + +1. Создать `apps/frontend/mocks/browser.ts` (setupWorker) +2. Прокинуть в `public/mockServiceWorker.js` через `npx msw init public/` +3. Создать `shared/config/env.ts` с чтением `VITE_API_MOCK` +4. Подключить MSW browser в `main.tsx` по условию +5. Проверить `npm run dev VITE_API_MOCK=true` без бэкенда + +### Phase 5 — Env Validation +*Низкий риск* + +1. Расширить `shared/config/env.ts` — Zod-схема для всех VITE_* +2. Вызвать валидацию в `main.tsx` до рендера + +### Phase 6 — TanStack Router +*Крупный, высокий риск* + +> **Фактический подход: code-first вместо file-based.** +> В процессе реализации выяснилось, что `@tanstack/router-plugin` не генерирует `routeTree.gen.ts` корректно на данной версии Vite/плагина. Принято решение использовать code-first подход — все маршруты определяются вручную в `routeTree.tsx`. + +1. Установить зависимости: + - `@tanstack/react-router` + - `@tanstack/router-devtools` (devDependency) +2. Создать `src/app/routing/routeTree.tsx` — code-first дерево маршрутов: + - Все маршруты определены в одном файле через `createRootRoute`, `createRoute`, `createRouter` + - `beforeLoad` для guard'ов (`requireAuth`) + - Loaders для предзагрузки отложены +3. Создать `src/app/routing/router.ts`: + - `createRouter()` с Route Tree из `routeTree.tsx` +4. Заменить `` + `` → `` в `App.tsx` +5. Заменить все импорты `react-router-dom` по всему проекту: + - `Link` → `Link` из `@tanstack/react-router` + - `useNavigate` → `useNavigate({ to: '...' })` (объектный синтаксис) + - `useParams` → `useParams` + - `useSearchParams` → `useSearchParamsCompat` (временная обёртка, т.к. TanStack Router не экспортирует useSearchParams) + - `useLocation` → `useLocation` +6. Заменить `MemoryRouter` в тестах на `createMemoryHistory` + `RouterProvider` +7. Обновить `test-utils.tsx` — рендер-обёртка на TanStack Router +8. Настроить router-devtools в dev-режиме +9. Прогнать тесты, проверить сборку + +## Dependencies + +``` +Phase 1 (ky) → независим +Phase 2 (Biome) → независим +Phase 3 (Types) → после Phase 1 (ky меняет нормализацию) +Phase 4 (MSW) → после Phase 5 (env нужен для VITE_API_MOCK) +Phase 5 (Env) → независим +Phase 6 (Router) → после Phase 3 (типы API стабильны) +``` + +## Rollback Strategy + +- Каждый phase — отдельный коммит, можно revert по одному +- Biome: `.eslintrc.cjs` сохраняется как `.eslintrc.cjs.bak` до верификации +- Router: старый `AppRoutes.tsx` и `react-router-dom` не удаляются до полного прохождения тестов +- Каждый phase должен оставлять `npm run build` и `npm run test` зелёными diff --git a/docs/features/frontend-infrastructure-tooling/spec.md b/docs/features/frontend-infrastructure-tooling/spec.md new file mode 100644 index 0000000..9079ab1 --- /dev/null +++ b/docs/features/frontend-infrastructure-tooling/spec.md @@ -0,0 +1,87 @@ +# Frontend Infrastructure Tooling + +## Purpose + +Модернизировать инструментарий и инфраструктуру фронтенда: ускорить разработку, улучшить типобезопасность, убрать дублирование, дать возможность разрабатывать UI без запущенного бэкенда. + +## Requirements + +### 1. MSW Browser Mode + +Фронтенд должен иметь возможность запускаться без бэкенда при `VITE_API_MOCK=true`. + +- Создать browser entry для MSW (apps/frontend/mocks/browser.ts с setupWorker) +- Развернуть mockServiceWorker.js в public/ +- Переиспользовать существующие MSW handlers (apps/frontend/mocks/handlers.ts) — не дублировать +- При VITE_API_MOCK=true в dev-режиме все API-запросы перехватываются MSW +- При VITE_API_MOCK=false или отсутствии — поведение не меняется (запросы идут в реальный API) + +### 2. Biome вместо ESLint + Prettier + +Заменить ESLint 8 + Prettier на Biome как единый инструмент линтинга и форматирования. + +- Удалить .eslintrc.cjs, зависимости eslint, prettier +- Установить @biomejs/biome с конфигом biome.json +- Перенести существующие правила ESLint (no-unused-vars, no-restricted-imports, FSD layer boundaries) +- Заменить npm-скрипт lint на biome check +- Обновить pre-commit hook и CI +- Если FSD-правила @conarti/feature-sliced не переносятся в Biome — оставить минимальный ESLint только для них + +### 3. Унификация типов API + +Убрать дублирование рукописных и сгенерированных типов. + +- Удалить рукописный shared/api/responses.ts +- Перенести все entity-API файлы на типы из shared/api/types.ts (openapi-typescript) +- normalizeEnvelope перенести в kyClient.ts +- Типы пишем только через codegen, рукописные — удаляем + +### 4. Полная миграция на ky + +Заменить нативный fetch на ky во всех API-вызовах. + +- Доработать kyClient.ts: добавить normalizeEnvelope в afterResponse hook +- Перевести все entity-API файлы с request() на kyApi +- Удалить shared/api/client.ts +- Заменить configureAuth на configureKyAuth в точке входа + +### 5. TanStack Router + Lazy Loading + +Мигрировать с react-router-dom на TanStack Router. + +- Установить @tanstack/react-router, @tanstack/router-devtools, @tanstack/router-plugin +- Создать файловую структуру роутов (Route Tree generation) +- Все страницы lazy по умолчанию (built-in, без React.lazy) +- Search params с Zod-схемами для страниц с фильтрацией (screener, broker-operations) +- ProtectedRoute реализовать через beforeLoad guard +- AppLayout — как layout route (__root.tsx) +- Loaders для предзагрузки данных с TanStack Query в ключевых роутах +- Заменить все импорты react-router-dom на @tanstack/react-router + +### 6. Валидация окружения + +Создать формальную Zod-схему для VITE_* переменных с проверкой при старте. + +- Определить ожидаемые VITE_* переменные с их типами +- Валидировать при старте приложения в main.tsx или App.tsx +- При отсутствии обязательных переменных — понятная ошибка в консоли/браузере + +## Acceptance Criteria + +| # | Acceptance Criteria | Verification | +|---|-------------------|--------------| +| 1 | `npm run dev VITE_API_MOCK=true` работает без бэкенда | Ручная проверка | +| 2 | `biome check src/` проходит без ошибок | `biome check` | +| 3 | Все тесты проходят | `npm run test` | +| 4 | `npm run build` проходит | `npm run build` | +| 5 | Нет рукописных типов API в shared/api/responses.ts | grep на импорты | +| 6 | Все API-запросы идут через ky | grep на fetch в entity API | +| 7 | Роуты лениво загружаются | DevTools Network tab | +| 8 | При невалидных VITE_* — ошибка при старте | Ручная проверка | + +## Out of Scope + +- Автоматизация codegen в dev/watch mode +- Storybook +- E2E-тесты (Playwright) +- Миграция с React 18 на React 19 diff --git a/docs/features/frontend-infrastructure-tooling/tasks.md b/docs/features/frontend-infrastructure-tooling/tasks.md new file mode 100644 index 0000000..212ff54 --- /dev/null +++ b/docs/features/frontend-infrastructure-tooling/tasks.md @@ -0,0 +1,107 @@ +# Frontend Infrastructure Tooling — Tasks + +## Phase 1: ky Migration + +- [x] Доработать `shared/api/kyClient.ts`: добавить normalizeEnvelope, request, kyApi, configureKyAuth +- [x] Экспортировать `kyApi` (create экземпляр) и `configureKyAuth` из kyClient +- [x] Перевести `entities/session/api/sessionApi.ts` на kyApi +- [x] Перевести `entities/stock/api/stockApi.ts` на kyApi +- [x] Перевести `entities/bond/api/bondApi.ts` на kyApi +- [x] Перевести `entities/search/api/searchApi.ts` на kyApi +- [x] Перевести `entities/portfolio/api/portfolioApi.ts` на kyApi +- [x] Перевести `entities/broker-account/api/brokerAccountApi.ts` на kyApi +- [x] Перевести `entities/broker-position/api/brokerPositionApi.ts` на kyApi +- [x] Перевести `entities/broker-operation/api/brokerOperationApi.ts` на kyApi +- [x] Перевести `entities/broker-event/api/brokerEventApi.ts` на kyApi +- [x] Перевести `features/screener/api/screenerApi.ts` на kyApi +- [x] Удалить `shared/api/client.ts` +- [x] Заменить `configureAuth()` на `configureKyAuth()` в SessionProvider +- [x] `npm run test` — все тесты проходят +- [x] `npm run build` — сборка проходит + +## Phase 2: Biome Migration + +- [x] Research: проверить Biome plugin system на поддержку FSD/import-no-restricted-paths — не поддерживается, ESLint оставлен для FSD +- [x] Установить `@biomejs/biome` (devDependency) +- [x] Запустить `npx @biomejs/biome migrate eslint --write` — 0 правил мигрировано, FSD-правила несовместимы, конфиг откачен +- [x] Создать и настроить `biome.json` под проект +- [x] ESLint оставлен только для FSD-правил (`.eslintrc.cjs`) +- [x] Удалить зависимость prettier (ESLint core, @typescript-eslint/*, eslint-plugin-* оставлены для FSD-правил) +- [x] Удалить `.prettierrc` и `.prettierignore` — форматирование перешло к Biome +- [x] Обновить `package.json`: `lint` → `biome check src/`, удалить prettier из format/format:check +- [x] Обновить `.gitea/workflows/ci.yml`: удалить format:check (биом проверяет в lint) +- [x] Обновить pre-commit hook (lint-staged → biome + prettier) +- [x] Прогнать `biome check --write src/` +- [x] `npm run test` — все тесты проходят +- [x] `npm run build` — сборка проходит + +## Phase 3: Unify API Types + +- [x] Аудит импортов: entity API используют `types.ts` (codegen) или `responses.ts`? +- [x] Переключить все импорты с `responses.ts` на `@/shared/api` (типы перенесены в `index.ts` вместо рукописного файла) +- [x] Удалить `shared/api/responses.ts` (после переключения) +- [x] normalizeEnvelope перенесён в `shared/api/kyClient.ts` +- [x] `npm run build` — сборка проходит + +## Phase 4: MSW Browser + +- [x] Создать `apps/frontend/mocks/browser.ts` (setupWorker из msw/browser) +- [x] Установить и прокинуть mockServiceWorker.js: `npx msw init public/` +- [x] Создать `shared/config/env.ts` с чтением и экспортом VITE_API_MOCK + VITE_API_URL +- [x] В `main.tsx`: при `VITE_API_MOCK === 'true'` запускать `worker.start()` +- [x] Проверить: `VITE_API_MOCK=true npm run dev` без бэкенда — приложение работает +- [x] Проверить: `VITE_API_MOCK=false npm run dev` — запросы идут на бэкенд + +## Phase 5: Env Validation + +- [x] Разработать Zod-схему в `shared/config/env.ts` для всех VITE_* переменных +- [x] Валидация env выполняется при импорте (safeParse в модуле env.ts) +- [x] Проверить: при отсутствии обязательной переменной — понятная ошибка + +## Phase 6: TanStack Router + +### Setup +- [x] Установить `@tanstack/react-router`, `@tanstack/router-devtools` +- [ ] ~~Установить `@tanstack/router-plugin`~~ (решение: code-first, без плагина) + +### Route files +- [x] Создать `src/app/routing/routeTree.tsx` — все маршруты (code-first) + - [x] root route + AppLayout + - [x] index → HomePage + - [x] stocks/$secid → StockPage + - [x] bonds/$secid → BondPage + - [x] screener → ScreenerPage + - [x] login → LoginPage + - [x] register → RegisterPage + - [x] profile → ProfilePage (guard) + - [x] portfolios → PortfoliosListPage (guard) + - [x] portfolios/$id → PortfolioDetailPage (guard) + - [x] broker → BrokerAccountsPage (guard) + - [x] broker/$accountId → BrokerAccountOverviewPage (guard) + - [x] broker/$accountId/shares → BrokerPositionsPage (guard) + - [x] broker/$accountId/bonds → BrokerPositionsPage (guard) + - [x] broker/$accountId/operations → BrokerOperationsPage (guard) + - [x] broker/$accountId/events → BrokerEventsPage (guard) + +### Router integration +- [x] Создать `app/routing/router.ts`: `createRouter()` с Route Tree +- [x] Настроить `beforeLoad` для guard'ов (requireAuth) +- [ ] Настроить loaders для предзагрузки (TanStack Query) — отложено +- [x] Заменить `` + `` → `` в `App.tsx` + +### Replace imports +- [x] Заменить `Link` → `@tanstack/react-router` Link по всему проекту +- [x] Заменить `useNavigate` → `@tanstack/react-router` +- [x] Заменить `useParams` → `@tanstack/react-router` +- [x] Заменить `useSearchParams` → `useSearchParamsCompat` (временная обёртка) +- [x] Заменить `useLocation` → `@tanstack/react-router` + +### Tests +- [x] Заменить `MemoryRouter` в тестах на `createMemoryHistory` + `RouterProvider` +- [x] Обновить тестовые утилиты (`test-utils.tsx`) +- [x] `npm run test` — все тесты проходят (116) +- [x] `npm run build` — сборка проходит + +### Devtools +- [x] Настроить `@tanstack/router-devtools` в dev-режиме +- [x] Проверить навигацию по всем страницам вручную diff --git a/docs/research/frontend-infrastructure-tooling/biome-vs-oxlint.md b/docs/research/frontend-infrastructure-tooling/biome-vs-oxlint.md new file mode 100644 index 0000000..b851f83 --- /dev/null +++ b/docs/research/frontend-infrastructure-tooling/biome-vs-oxlint.md @@ -0,0 +1,52 @@ +# Biome vs Oxlint: Comparison for Frontend Tooling + +## Context + +Проект использует **ESLint v8** + **Prettier** для линтинга и форматирования. ESLint 8 устарел (ESLint 9 имеет полностью изменённый конфиг). Рассматриваем замену на современную Rust-тулзу, совмещающую линтинг и форматирование. + +## Candidates + +### Biome (v2.5.0) + +- **Linter + Formatter** в одном CLI (`biome check --write`) +- 500+ правил (ESLint + TypeScript ESLint + другие источники) +- Форматтер: **97% совместимость с Prettier**, ~35x быстрее +- Поддержка: JS, TS, JSX, TSX, JSON, HTML, CSS, GraphQL +- Единый конфиг `biome.json` вместо `.eslintrc` + `.prettierrc` +- Встроенный import sorting (замена `eslint-plugin-import`) +- Production usage: AWS, Google, Vercel, Microsoft, Discord, Cloudflare +- Стабильный LTS-релиз, зрелое сообщество + +### Oxlint + Oxfmt (Oxc Project) + +- **Oxlint**: 50-100x быстрее ESLint, 800+ правил +- **Oxfmt**: ~3x быстрее Biome formatter, 30x быстрее Prettier +- Type-aware linting через `tsgo` +- ESLint JS Plugin Support (alpha) +- **Два отдельных инструмента** с разными конфигами +- Oxfmt в статусе **beta** +- Под капотом: самый быстрый parser (3x SWC), resolver, minifier (alpha) +- Бэкд: VoidZero (авторы Vite, Rolldown, Rspack) + +## Comparison + +| Критерий | Biome | Oxlint + Oxfmt | +|----------|-------|----------------| +| Замена ESLint | ✅ 500+ правил | ✅ 800+ правил | +| Замена Prettier | ✅ 97%, stable | ⚠️ Oxfmt beta | +| Один CLI вместо двух | ✅ | ❌ два инструмента | +| Production-ready | ✅ LTS | ⚠️ formatter beta | +| Конфигурация | 1 файл | 2 файла | +| Скорость линтинга | ~35x vs ESLint | ~50-100x vs ESLint | +| Скорость форматирования | ~35x vs Prettier | ~3x vs Biome | +| Сообщество | mature | growing | + +## Verdict: Biome + +**Рекомендуется Biome** по трём причинам: + +1. **Одна тулза** вместо двух — меньше конфигов, меньше CI-степов, меньше точек отказа +2. **Production-ready** — formatter стабилен, LTS-релизы, тысячи проектов в продакшене +3. **Плавная миграция** — встроенная команда `biome migrate eslint --write` переносит правила автоматически; `biome format` совместим с Prettier на 97%, не требуется массовых изменений кода + +Oxlint + Oxfmt перспективны (быстрее, больше правил), но Oxfmt ещё beta, и два инструмента усложняют конфигурацию. Если Oxfmt выйдет в stable — вопрос стоит пересмотреть. diff --git a/docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md b/docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md new file mode 100644 index 0000000..7cf28d5 --- /dev/null +++ b/docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md @@ -0,0 +1,57 @@ +# react-router-dom vs TanStack Router + +## Context + +Проект использует **react-router-dom v6** для клиентской маршрутизации. Требуется route-level code splitting (lazy loading). Также стоит вопрос о типобезопасности роутов и интеграции с уже используемым TanStack Query. + +## Candidates + +### react-router-dom v6 (текущий) + +- Стабильный стандарт де-факто +- Декларативный API: ``, ``, `` +- lazy loading через `React.lazy()` + `` — добавляется вручную +- Search params: `useSearchParams()` — без типизации, строками +- Параметры: `useParams()` — без типизации +- Нет встроенных loaders / prefetching +- Нет генерации типов +- Размер: ~8KB gzip +- Нет нативной интеграции с TanStack Query + +### TanStack Router + +- **Генерация RouteTree**: полная типобезопасность путей, параметров, search params +- **Search params с Zod**: декларативная валидация и парсинг (Zod уже используется в проекте) +- **Route loaders**: prefetching данных ДО рендера компонента, кеширование +- **Code splitting built-in**: каждый роут ленивый по умолчанию, без `React.lazy` boilerplate +- **Pending/Error/NotFound boundaries** на уровне роута +- **Интеграция с TanStack Query**: loaders могут вызывать `queryClient.fetchQuery()` напрямую +- **File-based routing**: чище организация кода (опционально) +- Файл роута = route + component + loader + error/loading states +- Размер: ~3-4KB gzip +- 1.2B+ total downloads, 20M+ weekly + +## Comparison + +| Критерий | react-router-dom v6 | TanStack Router | +|----------|---------------------|-----------------| +| Типизация путей | ❌ строки | ✅ генерация | +| Типизация params | ❌ `useParams()` без типа | ✅ autocomplete | +| Search params typing | ❌ `useSearchParams()` строки | ✅ Zod-схемы | +| Lazy loading | ⚠️ React.lazy + Suspense | ✅ built-in | +| Loaders / prefetch | ❌ нет | ✅ | +| TanStack Query synergy | ❌ | ✅ native | +| Bundle size | ~8KB gzip | ~3-4KB gzip | +| Migration effort | — | средняя | +| Learning curve | низкая | средняя | + +## Verdict: TanStack Router + +**Рекомендуется TanStack Router**: + +1. **Типобезопасность** — генерация типов исключает класс багов (опечатки в путях, невалидные search params) +2. **Code splitting без boilerplate** — каждый роут грузится лениво автоматически, не нужно `React.lazy` +3. **Синергия с TanStack Query** — уже используется в проекте; loaders дают prefetching до рендера +4. **Zod** — уже используется в проекте для валидации форм; Router использует Zod для search params + +Минусы: требуется переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов (`MemoryRouter` → `createMemoryRouter`). Каждый переезд — отдельная задача с тестированием. diff --git a/package-lock.json b/package-lock.json index b28608c..06b6af7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,7 @@ ], "devDependencies": { "husky": "^9.1.7", - "lint-staged": "^16.4.0", - "prettier": "^3.0.0" + "lint-staged": "^16.4.0" } }, "apps/backend": { @@ -94,6 +93,7 @@ "@mui/icons-material": "^6.5.0", "@mui/material": "^6.5.0", "@tanstack/react-query": "^5.20.0", + "@tanstack/react-router": "^1.170.16", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", "dayjs": "^1.11.21", @@ -104,27 +104,24 @@ "react-dom": "^18.3.0", "react-hook-form": "^7.80.0", "react-is": "^18.3.1", - "react-router-dom": "^6.20.0", "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { + "@biomejs/biome": "^2.5.0", "@conarti/eslint-plugin-feature-sliced": "^1.0.5", + "@tanstack/router-devtools": "^1.167.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.9.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "@vitejs/plugin-react": "^4.2.0", "eslint": "^8.0.0", - "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-react": "^7.34.0", - "eslint-plugin-react-hooks": "^4.6.0", "jsdom": "^29.1.1", "msw": "^2.14.6", "openapi-typescript": "^7.0.0", @@ -2912,6 +2909,169 @@ "node": ">=6.9.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.0.tgz", + "integrity": "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.0", + "@biomejs/cli-darwin-x64": "2.5.0", + "@biomejs/cli-linux-arm64": "2.5.0", + "@biomejs/cli-linux-arm64-musl": "2.5.0", + "@biomejs/cli-linux-x64": "2.5.0", + "@biomejs/cli-linux-x64-musl": "2.5.0", + "@biomejs/cli-win32-arm64": "2.5.0", + "@biomejs/cli-win32-x64": "2.5.0" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz", + "integrity": "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.0.tgz", + "integrity": "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.0.tgz", + "integrity": "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.0.tgz", + "integrity": "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.0.tgz", + "integrity": "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@blazediff/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", @@ -9146,6 +9306,7 @@ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -10946,6 +11107,19 @@ "node": ">=14.16" } }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.101.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", @@ -10972,6 +11146,75 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-router": { + "version": "1.170.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.16.tgz", + "integrity": "sha512-w6eq1IJklujs1tESazaK/FxH0+H2l8vm/QPuu1cD3oRW/ubgKneQpd7b64ti/8gUyEimzimJQZDmJr6YHfP5+g==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.13", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-router-devtools": { + "version": "1.167.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.167.0.tgz", + "integrity": "sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/router-devtools-core": "1.168.0" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.170.0", + "@tanstack/router-core": "^1.170.0", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/router-core": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -10992,6 +11235,92 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/router-core": { + "version": "1.171.13", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.13.tgz", + "integrity": "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-devtools": { + "version": "1.167.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.167.0.tgz", + "integrity": "sha512-NwHy3SNoEgGraWtOstykkBPCTv7QRWBuPH49ww3prsyzQWXSSb7/2Jp7HHndpDrAIn0XNCCaxho90+6RFLSpUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/react-router-devtools": "1.167.0", + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.170.0", + "csstype": "^3.0.10", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-devtools-core": { + "version": "1.168.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.168.0.tgz", + "integrity": "sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/router-core": "^1.170.0", + "csstype": "^3.0.10" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -14970,6 +15299,12 @@ "node": ">= 0.6" } }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/cookie-parser": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", @@ -17541,19 +17876,6 @@ } } }, - "node_modules/eslint-import-resolver-alias": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz", - "integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - }, - "peerDependencies": { - "eslint-plugin-import": ">=1.4.0" - } - }, "node_modules/eslint-import-resolver-node": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", @@ -19205,6 +19527,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -21059,6 +21391,15 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "license": "MIT" }, + "node_modules/isbot": { + "version": "5.1.44", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.44.tgz", + "integrity": "sha512-PGEHtwMnKbZpeSEXW2Utx+/JWed7dp6DiH0WWg33vGSDA7RUvpUeJSVlLrVkQ1RCpvDOUc/eH9ql7VsdbBZ8pA==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -28192,6 +28533,8 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -28993,6 +29336,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.23.3" }, @@ -29016,23 +29360,6 @@ "react-router": ">=5" } }, - "node_modules/react-router-dom": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", - "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.3", - "react-router": "6.30.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -30333,6 +30660,27 @@ "randombytes": "^2.1.0" } }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/serve-handler": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", @@ -32988,7 +33336,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "devOptional": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index fb6bc5f..d7d167c 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "test:backend": "npm run test -w apps/backend", "test:frontend": "npm run test -w apps/frontend", "lint": "npm run lint -w apps/backend && npm run lint -w apps/frontend", - "format": "prettier --write \"**/*.{ts,tsx}\"", - "format:check": "prettier --check \"**/*.{ts,tsx}\"", + "format": "npm run format -w apps/frontend", + "format:check": "npm run format:check -w apps/frontend", "dev:docs": "npm run dev -w apps/docs", "build:docs": "npm run build -w apps/docs", "build:design-system": "npm run build -w packages/design-system", @@ -28,16 +28,14 @@ "prepare": "husky" }, "lint-staged": { - "apps/backend/src/**/*.ts": ["eslint --max-warnings=0"], - "apps/backend/test/**/*.ts": ["eslint --max-warnings=0"], - "apps/frontend/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"], - "packages/design-system/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"], - "**/*.{ts,tsx}": ["prettier --check"] + "apps/backend/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"], + "apps/backend/test/**/*.{ts,tsx}": ["eslint --max-warnings=0"], + "apps/frontend/src/**/*.{ts,tsx}": ["biome check --write"], + "packages/design-system/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"] }, "devDependencies": { "husky": "^9.1.7", - "lint-staged": "^16.4.0", - "prettier": "^3.0.0" + "lint-staged": "^16.4.0" }, "overrides": { "@storybook/test": {