From 203b7cbf208d5cadd7ff694ff432debf2aa765e5 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 06:04:25 +0300 Subject: [PATCH 01/13] docs: add spec for frontend infrastructure tooling --- .../frontend-infrastructure-tooling/spec.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/features/frontend-infrastructure-tooling/spec.md diff --git a/docs/features/frontend-infrastructure-tooling/spec.md b/docs/features/frontend-infrastructure-tooling/spec.md new file mode 100644 index 0000000..b32bb8e --- /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 (shared/lib/test/browser.ts с setupWorker) +- Развернуть mockServiceWorker.js в public/ +- Переиспользовать существующие MSW handlers (shared/lib/test/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 -- 2.47.2 From 6d3601a8f49d795249c9de577a08c9e1efa77006 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 06:07:23 +0300 Subject: [PATCH 02/13] docs: add plan, tasks, research, and ADRs for frontend infrastructure tooling --- .../adr/ADR-017-biome-linter-formatter.md | 67 +++++++++ apps/docs/docs/adr/ADR-018-tanstack-router.md | 72 ++++++++++ .../docs/adr/ADR-019-api-layer-unification.md | 64 +++++++++ apps/docs/docs/adr/index.md | 5 +- .../frontend-infrastructure-tooling/plan.md | 132 ++++++++++++++++++ .../frontend-infrastructure-tooling/tasks.md | 105 ++++++++++++++ .../biome-vs-oxlint.md | 52 +++++++ .../react-router-vs-tanstack-router.md | 57 ++++++++ 8 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 apps/docs/docs/adr/ADR-017-biome-linter-formatter.md create mode 100644 apps/docs/docs/adr/ADR-018-tanstack-router.md create mode 100644 apps/docs/docs/adr/ADR-019-api-layer-unification.md create mode 100644 docs/features/frontend-infrastructure-tooling/plan.md create mode 100644 docs/features/frontend-infrastructure-tooling/tasks.md create mode 100644 docs/research/frontend-infrastructure-tooling/biome-vs-oxlint.md create mode 100644 docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md 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..b1855d9 --- /dev/null +++ b/apps/docs/docs/adr/ADR-018-tanstack-router.md @@ -0,0 +1,72 @@ +# 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**. + +Причины: +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 + +## Последствия + +### Положительные +- Каждая страница — отдельный chunk, грузится по требованию +- Search params валидируются Zod-схемами (screener, broker-operations) +- Loaders предзагружают данные, уменьшая время до первого контента +- Guard'ы (ProtectedRoute) реализуются через `beforeLoad`, единый подход + +### Риски +- Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов +- `MemoryRouter` в тестах заменяется на `createMemoryRouter` из TanStack Router +- Файловая структура роутов меняется — `src/app/routes/` с Route Tree generation +- Learning curve для команды + +### Миграция +- Каждый роут переносится по одному +- Старый `AppRoutes.tsx` сохраняется до полного прохождения тестов +- `react-router-dom` удаляется только после верификации + +## Связанные документы +- `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/docs/features/frontend-infrastructure-tooling/plan.md b/docs/features/frontend-infrastructure-tooling/plan.md new file mode 100644 index 0000000..52b535b --- /dev/null +++ b/docs/features/frontend-infrastructure-tooling/plan.md @@ -0,0 +1,132 @@ +# 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 +*Низкий риск* + +1. Убедиться, что все entity API импортируют из `types.ts` (codegen) +2. Удалить `shared/api/responses.ts` +3. Перенести normalizeEnvelope (ky-версию) в `shared/api/kyClient.ts` +4. Прогнать `npm run build` + +### Phase 4 — MSW Browser +*Низкий риск, handlers готовы* + +1. Создать `shared/lib/test/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 +*Крупный, высокий риск* + +1. Установить зависимости: + - `@tanstack/react-router` + - `@tanstack/router-devtools` (devDependency) + - `@tanstack/router-plugin` (vite plugin) +2. Настроить Vite plugin в `vite.config.ts` +3. Создать файловую структуру роутов: + ``` + src/app/routes/ + __root.tsx — AppLayout + ErrorBoundary + index.tsx — HomePage + stocks.$secid.tsx — StockPage + bonds.$secid.tsx — BondPage + screener.tsx — ScreenerPage + Zod search params + login.tsx — LoginPage + register.tsx — RegisterPage + profile.tsx — ProfilePage (guard: beforeLoad) + portfolios.tsx — PortfoliosListPage (guard) + portfolios.$id.tsx — PortfolioDetailPage (guard) + broker/ + index.tsx — BrokerAccountsPage (guard) + $accountId/ + index.tsx — BrokerAccountOverviewPage (guard) + shares.tsx — BrokerPositionsPage (guard) + bonds.tsx — BrokerPositionsPage (guard) + operations.tsx — BrokerOperationsPage + Zod search params (guard) + events.tsx — BrokerEventsPage (guard) + ``` +4. Перенести каждый роут из `AppRoutes.tsx` — каждый файл создаёт lazy route +5. Создать роутер в `app/routing/router.ts`: + - `createRouter()` с Route Tree + - `beforeLoad` для guard'ов + - Loaders для предзагрузки (TanStack Query integration) +6. Заменить `` + `` → `` в `App.tsx` +7. Заменить все импорты `react-router-dom` по всему проекту: + - `Link` → `Link` из `@tanstack/react-router` + - `useNavigate` → `useNavigate` + - `useParams` → `useParams` + - `useSearchParams` → `useSearch` + `useNavigate` + - `useLocation` → `useLocation` +8. Заменить `MemoryRouter` в тестах на `createMemoryRouter` из TanStack Router +9. Настроить router-devtools в dev-режиме +10. Прогнать тесты + +## 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/tasks.md b/docs/features/frontend-infrastructure-tooling/tasks.md new file mode 100644 index 0000000..6b07edb --- /dev/null +++ b/docs/features/frontend-infrastructure-tooling/tasks.md @@ -0,0 +1,105 @@ +# Frontend Infrastructure Tooling — Tasks + +## Phase 1: ky Migration + +- [ ] Доработать `shared/api/kyClient.ts`: добавить normalizeEnvelope в afterResponse hook +- [ ] Экспортировать `kyApi` (create экземпляр) и `configureKyAuth` из kyClient +- [ ] Перевести `entities/session/api/sessionApi.ts` на kyApi +- [ ] Перевести `entities/stock/api/stockApi.ts` на kyApi +- [ ] Перевести `entities/bond/api/bondApi.ts` на kyApi +- [ ] Перевести `entities/search/api/searchApi.ts` на kyApi +- [ ] Перевести `entities/portfolio/api/portfolioApi.ts` на kyApi +- [ ] Перевести `entities/broker-account/api/brokerAccountApi.ts` на kyApi +- [ ] Перевести `entities/broker-position/api/brokerPositionApi.ts` на kyApi +- [ ] Перевести `entities/broker-operation/api/brokerOperationApi.ts` на kyApi +- [ ] Перевести `entities/broker-event/api/brokerEventApi.ts` на kyApi +- [ ] Перевести `features/screener/api/screenerApi.ts` на kyApi +- [ ] Удалить `shared/api/client.ts` +- [ ] Заменить `configureAuth()` на `configureKyAuth()` в точке входа (AppProviders) +- [ ] `npm run test` — все тесты проходят +- [ ] `npm run build` — сборка проходит + +## Phase 2: Biome Migration + +- [ ] Research: проверить Biome plugin system на поддержку FSD/import-no-restricted-paths +- [ ] Установить `@biomejs/biome` (devDependency) +- [ ] Запустить `npx @biomejs/biome migrate eslint --write` +- [ ] Создать `biome.json` с донастройкой под проект +- [ ] Если FSD-правила не портируются — создать минимальный `.eslintrc.cjs` только для FSD +- [ ] Удалить зависимости: eslint, prettier, @typescript-eslint/*, eslint-plugin-* +- [ ] Удалить `.eslintrc.cjs` (если FSD не нужен) +- [ ] Обновить `package.json`: `lint` → `biome check src/` +- [ ] Обновить `.gitea/workflows/ci.yml`: заменить eslint на biome +- [ ] Обновить pre-commit hook (lint-staged → biome) +- [ ] Прогнать `biome check --write src/` +- [ ] `npm run test` — все тесты проходят + +## Phase 3: Unify API Types + +- [ ] Проверить все импорты в entity API — должны быть из `types.ts` (codegen), не из `responses.ts` +- [ ] Если кто-то импортирует из `responses.ts` — переключить на `types.ts` +- [ ] Удалить `shared/api/responses.ts` +- [ ] Перенести normalizeEnvelope (ky-версия) в `shared/api/kyClient.ts` +- [ ] `npm run build` — сборка проходит + +## Phase 4: MSW Browser + +- [ ] Создать `shared/lib/test/browser.ts` (setupWorker из msw/browser) +- [ ] Установить и прокинуть mockServiceWorker.js: `npx msw init public/` +- [ ] Создать `shared/config/env.ts` с чтением и экспортом VITE_API_MOCK +- [ ] В `main.tsx`: при `VITE_API_MOCK === 'true'` запускать `worker.start()` +- [ ] Проверить: `VITE_API_MOCK=true npm run dev` без бэкенда — приложение работает +- [ ] Проверить: `VITE_API_MOCK=false npm run dev` — запросы идут на бэкенд + +## Phase 5: Env Validation + +- [ ] Разработать Zod-схему в `shared/config/env.ts` для всех VITE_* переменных +- [ ] Вызвать `validateEnv()` в `main.tsx` до `ReactDOM.createRoot` +- [ ] Проверить: при отсутствии обязательной переменной — понятная ошибка + +## Phase 6: TanStack Router + +### Setup +- [ ] Установить `@tanstack/react-router`, `@tanstack/router-devtools`, `@tanstack/router-plugin` +- [ ] Настроить Vite plugin для генерации RouteTree в `vite.config.ts` + +### Route files +- [ ] Создать `src/app/routes/__root.tsx` — AppLayout + ErrorBoundary +- [ ] Создать `src/app/routes/index.tsx` — HomePage +- [ ] Создать `src/app/routes/stocks.$secid.tsx` — StockPage +- [ ] Создать `src/app/routes/bonds.$secid.tsx` — BondPage +- [ ] Создать `src/app/routes/screener.tsx` — ScreenerPage + Zod search params +- [ ] Создать `src/app/routes/login.tsx` — LoginPage +- [ ] Создать `src/app/routes/register.tsx` — RegisterPage +- [ ] Создать `src/app/routes/profile.tsx` — ProfilePage (guard: beforeLoad) +- [ ] Создать `src/app/routes/portfolios.tsx` — PortfoliosListPage (guard) +- [ ] Создать `src/app/routes/portfolios.$id.tsx` — PortfolioDetailPage (guard) +- [ ] Создать `src/app/routes/broker/index.tsx` — BrokerAccountsPage (guard) +- [ ] Создать `src/app/routes/broker.$accountId/index.tsx` — BrokerAccountOverviewPage (guard) +- [ ] Создать `src/app/routes/broker.$accountId/shares.tsx` — BrokerPositionsPage (guard) +- [ ] Создать `src/app/routes/broker.$accountId/bonds.tsx` — BrokerPositionsPage (guard) +- [ ] Создать `src/app/routes/broker.$accountId/operations.tsx` — BrokerOperationsPage (guard) +- [ ] Создать `src/app/routes/broker.$accountId/events.tsx` — BrokerEventsPage (guard) + +### Router integration +- [ ] Создать `app/routing/router.ts`: `createRouter()` с Route Tree +- [ ] Настроить `beforeLoad` для guard'ов +- [ ] Настроить loaders для предзагрузки (TanStack Query) +- [ ] Заменить `` + `` → `` в `App.tsx` + +### Replace imports +- [ ] Заменить `Link` → `@tanstack/react-router` Link по всему проекту +- [ ] Заменить `useNavigate` → `@tanstack/react-router` +- [ ] Заменить `useParams` → `@tanstack/react-router` +- [ ] Заменить `useSearchParams` → `useSearch` + `useNavigate` +- [ ] Заменить `useLocation` → `@tanstack/react-router` + +### Tests +- [ ] Заменить `MemoryRouter` в тестах на `createMemoryRouter` из TanStack Router +- [ ] Обновить тестовые утилиты (`test-utils.tsx`) +- [ ] `npm run test` — все тесты проходят +- [ ] `npm run build` — сборка проходит + +### Devtools +- [ ] Настроить `@tanstack/router-devtools` в dev-режиме +- [ ] Проверить навигацию по всем страницам вручную 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`). Каждый переезд — отдельная задача с тестированием. -- 2.47.2 From cfadb2adbe149b4bf7b7414e251f7bf6d6d168e8 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 06:51:48 +0300 Subject: [PATCH 03/13] feat: migrate from react-router-dom to @tanstack/react-router (code-first) - Create code-first route tree in src/app/routing/routeTree.tsx - Replace ProtectedRoute with beforeLoad auth guards - Add useSearchParamsCompat for URLSearchParams access - Update App.tsx, layouts, and all page/widget imports - Add frontend tooling: biome, prettier, env config - Update all tests for TanStack Router compatibility - Remove react-router-dom dependency, @tanstack/router-plugin - Consolidate biome config at root level --- .prettierignore | 1 + apps/frontend/.eslintrc.cjs | 41 +- apps/frontend/package.json | 19 +- apps/frontend/public/mockServiceWorker.js | 349 +++ apps/frontend/src/app/App.tsx | 10 +- apps/frontend/src/app/index.ts | 2 +- apps/frontend/src/app/layouts/AppLayout.tsx | 16 +- apps/frontend/src/app/layouts/index.ts | 2 +- .../src/app/providers/AppProviders.tsx | 20 +- .../app/providers/SessionProvider.test.tsx | 82 +- .../src/app/providers/SessionProvider.tsx | 92 +- apps/frontend/src/app/providers/index.ts | 4 +- apps/frontend/src/app/routing/AppRoutes.tsx | 78 - .../src/app/routing/ProtectedRoute.tsx | 29 - apps/frontend/src/app/routing/index.ts | 3 +- apps/frontend/src/app/routing/routeTree.tsx | 169 ++ apps/frontend/src/app/routing/router.ts | 1 + .../frontend/src/entities/bond/api/bondApi.ts | 18 +- apps/frontend/src/entities/bond/index.ts | 6 +- .../src/entities/bond/model/useBond.test.tsx | 34 +- .../src/entities/bond/model/useBond.ts | 12 +- .../bond/model/useBondCandles.test.tsx | 44 +- .../src/entities/bond/model/useBondCandles.ts | 12 +- .../broker-account/api/brokerAccountApi.ts | 32 +- .../src/entities/broker-account/index.ts | 18 +- .../model/brokerAccountsOverview.test.ts | 68 +- .../model/brokerAccountsOverview.ts | 114 +- .../model/useBrokerAccountPortfolios.ts | 10 +- .../broker-account/model/useBrokerAccounts.ts | 8 +- .../model/useBrokerPortfolio.ts | 8 +- .../broker-event/api/brokerEventApi.ts | 14 +- .../src/entities/broker-event/index.ts | 4 +- .../model/useBrokerEvents.test.tsx | 62 +- .../broker-event/model/useBrokerEvents.ts | 10 +- .../api/brokerOperationApi.ts | 22 +- .../src/entities/broker-operation/index.ts | 8 +- .../model/operationFilters.test.ts | 22 +- .../model/operationFilters.ts | 52 +- .../model/useBrokerOperations.test.tsx | 52 +- .../model/useBrokerOperations.ts | 8 +- .../broker-position/api/brokerPositionApi.ts | 6 +- .../src/entities/broker-position/index.ts | 12 +- .../model/brokerAllocation.test.ts | 70 +- .../broker-position/model/brokerAllocation.ts | 62 +- .../model/brokerDisplay.test.ts | 108 +- .../broker-position/model/brokerDisplay.ts | 44 +- .../model/useBrokerPositions.ts | 8 +- .../entities/portfolio/api/portfolioApi.ts | 54 +- apps/frontend/src/entities/portfolio/index.ts | 26 +- .../entities/portfolio/model/usePortfolio.ts | 12 +- .../portfolio/model/usePortfolioAnalytics.ts | 12 +- .../portfolio/model/usePortfolioMutations.ts | 40 +- .../entities/portfolio/model/usePortfolios.ts | 12 +- .../portfolio/model/usePositionMutations.ts | 62 +- .../src/entities/search/api/searchApi.ts | 6 +- apps/frontend/src/entities/search/index.ts | 4 +- .../entities/search/model/useSearch.test.tsx | 74 +- .../src/entities/search/model/useSearch.ts | 12 +- .../entities/session/api/sessionApi.test.ts | 80 +- .../src/entities/session/api/sessionApi.ts | 38 +- .../src/entities/session/api/tokenManager.ts | 48 +- apps/frontend/src/entities/session/index.ts | 7 +- .../entities/session/model/sessionContext.ts | 2 +- .../session/model/useSession.test.tsx | 66 +- .../src/entities/session/model/useSession.ts | 10 +- .../entities/session/model/useSessionStore.ts | 26 +- .../src/entities/stock/api/stockApi.ts | 22 +- apps/frontend/src/entities/stock/index.ts | 6 +- .../entities/stock/model/useStock.test.tsx | 42 +- .../src/entities/stock/model/useStock.ts | 12 +- .../stock/model/useStockCandles.test.tsx | 46 +- .../entities/stock/model/useStockCandles.ts | 12 +- .../stock/model/useStockDividends.test.tsx | 46 +- .../entities/stock/model/useStockDividends.ts | 12 +- .../add-position/api/useAddPosition.ts | 6 +- .../src/features/add-position/index.ts | 2 +- .../add-position/model/useAddPositionForm.ts | 22 +- .../add-position/ui/AddPositionForm.tsx | 20 +- .../src/features/screener/api/screenerApi.ts | 58 +- apps/frontend/src/features/screener/index.ts | 6 +- .../src/features/screener/model/index.ts | 2 +- .../features/screener/model/useScreener.ts | 72 +- .../src/features/screener/ui/FilterPanel.tsx | 36 +- .../features/screener/ui/FilterPanelBond.tsx | 10 +- .../features/screener/ui/FilterPanelShare.tsx | 10 +- .../features/screener/ui/ScreenerTable.tsx | 38 +- .../src/features/screener/ui/index.ts | 8 +- apps/frontend/src/main.tsx | 34 +- apps/frontend/src/pages/bond/index.ts | 2 +- apps/frontend/src/pages/bond/ui/BondPage.tsx | 24 +- .../src/pages/broker-account/index.ts | 2 +- .../ui/BrokerAccountOverviewPage.tsx | 28 +- .../src/pages/broker-accounts/index.ts | 2 +- .../broker-accounts/ui/BrokerAccountsPage.tsx | 38 +- .../frontend/src/pages/broker-events/index.ts | 2 +- .../ui/BrokerEventsPage.test.tsx | 161 +- .../broker-events/ui/BrokerEventsPage.tsx | 118 +- .../src/pages/broker-operations/index.ts | 2 +- .../ui/BrokerOperationsPage.tsx | 40 +- .../src/pages/broker-positions/index.ts | 2 +- .../ui/BrokerPositionsPage.tsx | 28 +- apps/frontend/src/pages/home/index.ts | 2 +- apps/frontend/src/pages/home/ui/HomePage.tsx | 6 +- .../src/pages/login/LoginPage.test.tsx | 129 +- apps/frontend/src/pages/login/index.ts | 2 +- .../frontend/src/pages/login/ui/LoginPage.tsx | 43 +- apps/frontend/src/pages/portfolios/index.ts | 4 +- .../portfolios/ui/PortfolioDetailPage.tsx | 44 +- .../portfolios/ui/PortfoliosListPage.tsx | 20 +- .../src/pages/profile/ProfilePage.test.tsx | 90 +- apps/frontend/src/pages/profile/index.ts | 2 +- .../src/pages/profile/ui/ProfilePage.tsx | 34 +- .../src/pages/register/RegisterPage.test.tsx | 133 +- apps/frontend/src/pages/register/index.ts | 2 +- .../src/pages/register/ui/RegisterPage.tsx | 51 +- apps/frontend/src/pages/screener/index.ts | 2 +- .../src/pages/screener/ui/ScreenerPage.tsx | 6 +- apps/frontend/src/pages/stock/index.ts | 2 +- .../frontend/src/pages/stock/ui/StockPage.tsx | 28 +- apps/frontend/src/shared/api/client.test.ts | 150 +- apps/frontend/src/shared/api/client.ts | 96 - apps/frontend/src/shared/api/index.ts | 44 +- apps/frontend/src/shared/api/kyClient.ts | 142 +- apps/frontend/src/shared/api/responses.ts | 584 ++-- apps/frontend/src/shared/api/types.ts | 2342 ++++++++--------- apps/frontend/src/shared/config/env.ts | 19 + apps/frontend/src/shared/lib/dates/index.ts | 18 +- apps/frontend/src/shared/lib/formatters.ts | 54 +- .../src/shared/lib/router/useSearchParams.ts | 27 + .../src/shared/lib/session-context.ts | 22 +- apps/frontend/src/shared/lib/styles/clsx.ts | 4 +- .../shared/lib/test/TestSessionProvider.tsx | 64 +- apps/frontend/src/shared/lib/test/browser.ts | 4 + .../frontend/src/shared/lib/test/factories.ts | 28 +- apps/frontend/src/shared/lib/test/handlers.ts | 48 +- apps/frontend/src/shared/lib/test/server.ts | 6 +- apps/frontend/src/shared/lib/test/setup.ts | 22 +- .../src/shared/lib/test/test-utils.tsx | 35 +- .../src/shared/lib/useCursorPagination.ts | 38 +- apps/frontend/src/shared/ui/Table/Table.tsx | 6 +- apps/frontend/src/shared/ui/Table/index.ts | 2 +- apps/frontend/src/shared/ui/TableSkeleton.tsx | 8 +- .../BrokerAllocationBar.tsx | 26 +- .../shared/ui/broker-allocation-bar/index.ts | 2 +- apps/frontend/src/shared/ui/index.ts | 2 +- apps/frontend/src/styles.css | 4 +- .../src/widgets/bond-details/index.ts | 2 +- .../bond-details/ui/BondDetails.test.tsx | 118 +- .../widgets/bond-details/ui/BondDetails.tsx | 12 +- .../src/widgets/bond-positions-table/index.ts | 2 +- .../ui/BondPositionRow.tsx | 60 +- .../ui/BondPositionTable.tsx | 14 +- .../src/widgets/broker-account-card/index.ts | 2 +- .../ui/BrokerAccountCard.tsx | 58 +- .../widgets/broker-account-layout/index.ts | 5 +- .../lib/useBrokerAccountContext.ts | 6 + .../ui/BrokerAccountLayout.tsx | 74 +- .../widgets/broker-accounts-summary/index.ts | 2 +- .../ui/BrokerAccountsSummary.tsx | 28 +- .../widgets/broker-allocation-chart/index.ts | 2 +- .../ui/BrokerAllocationChart.tsx | 36 +- .../widgets/broker-events-overview/index.ts | 2 +- .../ui/BrokerEventsOverview.test.tsx | 103 +- .../ui/BrokerEventsOverview.tsx | 50 +- .../widgets/broker-operations-table/index.ts | 2 +- .../ui/BrokerOperationsTable.tsx | 96 +- .../src/widgets/broker-overview/index.ts | 6 +- .../broker-overview/ui/BrokerAssetCards.tsx | 28 +- .../ui/BrokerOverviewSkeleton.tsx | 6 +- .../broker-overview/ui/BrokerSummary.tsx | 10 +- .../widgets/broker-positions-table/index.ts | 2 +- .../ui/BrokerPositionTable.tsx | 44 +- .../ui/PositionTicker.tsx | 16 +- .../src/widgets/dividends-table/index.ts | 2 +- .../ui/DividendsTable.test.tsx | 22 +- .../dividends-table/ui/DividendsTable.tsx | 6 +- .../src/widgets/portfolio-analytics/index.ts | 2 +- .../ui/AnalyticsSummary.tsx | 10 +- .../src/widgets/portfolio-card/index.ts | 2 +- .../portfolio-card/ui/PortfolioCard.tsx | 10 +- .../src/widgets/portfolio-form/index.ts | 2 +- .../portfolio-form/ui/PortfolioForm.tsx | 28 +- .../src/widgets/portfolio-summary/index.ts | 2 +- .../portfolio-summary/ui/AllocationChart.tsx | 76 +- .../portfolio-summary/ui/PortfolioSummary.tsx | 6 +- .../frontend/src/widgets/price-chart/index.ts | 2 +- .../price-chart/ui/PriceChart.test.tsx | 28 +- .../src/widgets/price-chart/ui/PriceChart.tsx | 48 +- apps/frontend/src/widgets/search-bar/index.ts | 2 +- .../widgets/search-bar/ui/SearchBar.test.tsx | 109 +- .../src/widgets/search-bar/ui/SearchBar.tsx | 54 +- .../widgets/share-positions-table/index.ts | 2 +- .../ui/SharePositionRow.tsx | 50 +- .../ui/SharePositionTable.tsx | 14 +- .../src/widgets/stock-details/index.ts | 2 +- .../stock-details/ui/StockDetails.test.tsx | 76 +- .../widgets/stock-details/ui/StockDetails.tsx | 20 +- biome.json | 115 + package-lock.json | 418 ++- package.json | 10 +- 200 files changed, 5345 insertions(+), 4384 deletions(-) create mode 100644 .prettierignore create mode 100644 apps/frontend/public/mockServiceWorker.js delete mode 100644 apps/frontend/src/app/routing/AppRoutes.tsx delete mode 100644 apps/frontend/src/app/routing/ProtectedRoute.tsx create mode 100644 apps/frontend/src/app/routing/routeTree.tsx create mode 100644 apps/frontend/src/app/routing/router.ts delete mode 100644 apps/frontend/src/shared/api/client.ts create mode 100644 apps/frontend/src/shared/config/env.ts create mode 100644 apps/frontend/src/shared/lib/router/useSearchParams.ts create mode 100644 apps/frontend/src/shared/lib/test/browser.ts create mode 100644 apps/frontend/src/widgets/broker-account-layout/lib/useBrokerAccountContext.ts create mode 100644 biome.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..dc8582f --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +apps/frontend/ 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/package.json b/apps/frontend/package.json index cef656f..753f13f 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}\"", + "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..6f7c307 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 { 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 { server } from '@/shared/lib/test/server' +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..dc68d31 100644 --- a/apps/frontend/src/app/providers/SessionProvider.tsx +++ b/apps/frontend/src/app/providers/SessionProvider.tsx @@ -1,97 +1,97 @@ -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 } 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 { configureKyAuth } from '@/shared/api/kyClient' +import type { UserResponse } from '@/shared/api/responses' 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) + }, []) const clearSession = useCallback(() => { - setUser(null); - setAccessTokenState(null); - }, []); + setUser(null) + setAccessTokenState(null) + }, []) 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 +106,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { > Загрузка... - ); + ) } const value: SessionContextValue = { @@ -118,7 +118,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..2357a1f 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 { request } from '@/shared/api/kyClient' import type { ApiResponseMeta, - BondResponse, - BondMarketData, BondHistoryItem, + BondMarketData, + BondResponse, CandleItem, -} from '@/shared/api/responses'; +} from '@/shared/api/responses' 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..1ca08ee 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/responses' +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..1e1fa2f 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 { 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 { server } from '@/shared/lib/test/server' +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..ebec92e 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/responses' +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..814c017 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 { request } from '@/shared/api/kyClient' +import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses' 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..8cebcee 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/responses' +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..2990d89 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/responses' 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..1caad5f 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/responses' +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..ff10939 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/responses' +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..b86ac98 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/responses' +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..cba3d1e 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 { request } from '@/shared/api/kyClient' +import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses' 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..7f1c74d 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/responses' +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..f0f1606 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 { request } from '@/shared/api/kyClient' +import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses' 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..b2c2d09 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/responses' -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..a02ba05 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/responses' +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..858f6a0 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 { request } from '@/shared/api/kyClient' +import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses' 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..7715884 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/responses' +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..51f670c 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/responses' -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..3c978a3 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/responses' +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..7618b23 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/responses' -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..6ad870b 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/responses' +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..4ddea5c 100644 --- a/apps/frontend/src/entities/portfolio/api/portfolioApi.ts +++ b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts @@ -1,33 +1,33 @@ -import { request } from '@/shared/api/client'; +import { request } from '@/shared/api/kyClient' import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position, -} from '@/shared/api/responses'; +} from '@/shared/api/responses' 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 +37,7 @@ export function updatePortfolio( return request(`/api/v1/portfolios/${id}`, undefined, { method: 'PATCH', body: data, - }); + }) } export function deletePortfolio( @@ -45,41 +45,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 +88,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..3ceb9ed 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/responses' +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..9560ac2 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/responses' +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..3bd8cc6 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/responses' +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..639e121 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/responses' +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..b901d41 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 { request } from '@/shared/api/kyClient' +import type { SearchResultItem } from '@/shared/api/responses' 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..a1c61e3 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 { 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' +import { server } from '@/shared/lib/test/server' -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..62b9e2d 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/responses' +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..d0947cd 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 { HttpResponse, http } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' +import { server } from '@/shared/lib/test/server' +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..1a86cbc 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 { request } from '@/shared/api/kyClient' +import type { AuthResponse, UserResponse } from '@/shared/api/responses' +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..c671f36 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 { normalizeEnvelope } from '@/shared/api/kyClient' +import type { AuthResponse } from '@/shared/api/responses' -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..9593101 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/responses' 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..b0ee9a5 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 { request } from '@/shared/api/kyClient' import type { ApiResponseMeta, - ShareResponse, - StockMarketData, + CandleItem, DividendItem, ShareHistoryItem, - CandleItem, -} from '@/shared/api/responses'; + ShareResponse, + StockMarketData, +} from '@/shared/api/responses' 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..a3bca2d 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/responses' +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..e771e24 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 { 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 { server } from '@/shared/lib/test/server' +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..66912b4 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/responses' +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..509f77e 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 { 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 { server } from '@/shared/lib/test/server' +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..0b0a5b8 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/responses' +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..76faed9 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 { request } from '@/shared/api/kyClient' +import type { ScreenerResult } from '@/shared/api/responses' 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..280b2ed 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/responses' 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..9abcc46 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('./shared/lib/test/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..a1271c6 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 { 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 { server } from '@/shared/lib/test/server' +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..7a87fa9 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<{ id: string }>() + 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..b6365a8 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 { 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 { server } from '@/shared/lib/test/server' +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..edeb893 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 { 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 { server } from '@/shared/lib/test/server' +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..eab1f85 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<{ 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!) - 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 index fc6bf61..67c411c 100644 --- a/apps/frontend/src/shared/api/client.test.ts +++ b/apps/frontend/src/shared/api/client.test.ts @@ -1,31 +1,31 @@ -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 { HttpResponse, http } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' import { - setAccessToken, getAccessToken, - setOnUnauthorized, handleUnauthorized, -} from '@/entities/session/api/tokenManager'; + setAccessToken, + setOnUnauthorized, +} from '@/entities/session/api/tokenManager' +import { server } from '@/shared/lib/test/server' +import { configureKyAuth, request } from './kyClient' -const API = '/api/v1'; +const API = '/api/v1' beforeEach(() => { - setAccessToken(null); - configureAuth({ + setAccessToken(null) + configureKyAuth({ 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'); - }); + ) + expect(result.data.status).toBe('ok') + }) it('supports the single API envelope shape documented by Swagger', async () => { server.use( @@ -35,44 +35,44 @@ describe('request', () => { meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' }, }), ), - ); + ) - const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope'); + 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; + setAccessToken('test-token') + let capturedAuth: string | null = null server.use( http.get(`${API}/test-auth`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); + 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'); - }); + ) + 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; + setAccessToken('expired-token') + let attempts = 0 server.use( http.get(`${API}/test-retry`, ({ request }) => { - attempts++; - const auth = request.headers.get('Authorization'); + attempts++ + const auth = request.headers.get('Authorization') if (auth === 'Bearer expired-token') { - return new HttpResponse(null, { status: 401 }); + 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({ @@ -85,27 +85,27 @@ describe('request', () => { }, }), ), - ); - 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'); - }); + ) + 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; + setAccessToken('expired-token') + let unauthorizedCalled = false setOnUnauthorized(() => { - unauthorizedCalled = true; - }); + 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); - }); + ) + 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( @@ -113,51 +113,51 @@ describe('request', () => { `${API}/test-error`, () => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' }), ), - ); - await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404'); - }); + ) + await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404') + }) it('sends JSON body for POST requests', async () => { - let capturedBody: string | null = null; + let capturedBody: string | null = null server.use( http.post(`${API}/test-post`, async ({ request }) => { - capturedBody = await request.text(); + 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' })); - }); + ) + 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; + setAccessToken('test-token') + let capturedAuth: string | null = null server.use( http.get(`${API}/test-skip`, ({ request }) => { - capturedAuth = request.headers.get('Authorization'); + 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(); - }); + ) + await request('/api/v1/test-skip', undefined, { skipAuth: true }) + expect(capturedAuth).toBeNull() + }) it('sets query params correctly', async () => { - let capturedUrl = ''; + let capturedUrl = '' server.use( http.get(`${API}/test-params`, ({ request }) => { - capturedUrl = request.url; + 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'); - }); -}); + ) + 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..223b873 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -1,33 +1,33 @@ -export { request, configureAuth, getHealth } from './client'; +export { configureKyAuth, getHealth, request } from './kyClient' export type { - ApiResponseMeta, + AnalyticsResponse, ApiEnvelope, - StockMarketData, - ShareResponse, - DividendItem, - ShareHistoryItem, + ApiResponseMeta, + AuthResponse, + BondHistoryItem, BondMarketData, BondResponse, - BondHistoryItem, + BrokerAccount, + BrokerMoney, + BrokerOperation, + BrokerOperationCategory, + BrokerOperationsPage, + BrokerPortfolio, + BrokerPosition, + BrokerPositionsPage, CandleItem, - SearchResultItem, + DividendItem, HealthResponse, - UserResponse, - AuthResponse, Portfolio, - PositionWithPrice, PortfolioDetail, - Position, PortfolioSummary, - AnalyticsResponse, + Position, + PositionWithPrice, ScreenerItem, ScreenerResult, - BrokerMoney, - BrokerAccount, - BrokerPosition, - BrokerPortfolio, - BrokerOperationCategory, - BrokerOperation, - BrokerOperationsPage, - BrokerPositionsPage, -} from './responses'; + SearchResultItem, + ShareHistoryItem, + ShareResponse, + StockMarketData, + UserResponse, +} from './responses' 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 index 539214a..3392725 100644 --- a/apps/frontend/src/shared/api/responses.ts +++ b/apps/frontend/src/shared/api/responses.ts @@ -1,391 +1,391 @@ export interface ApiResponseMeta { - cachedAt: string | null; - fromCache: boolean; + cachedAt: string | null + fromCache: boolean } export interface ApiEnvelope { - data: T; - meta: ApiResponseMeta; + 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; + 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; + 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; + registryCloseDate: string + value: number + currency: string } export interface ShareHistoryItem { - date: string; - open: number; - high: number; - low: number; - close: number; - volume: number; - value: number; + 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; + 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; + 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; + 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; + 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; + 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; + status: string + timestamp: string + uptime: number } export interface UserResponse { - id: number; - email: string; - name: string | null; - role: string; + id: number + email: string + name: string | null + role: string } export interface AuthResponse { - user: UserResponse; - accessToken: string; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + items: ScreenerItem[] + total: number + page: number + pageSize: number + totalPages: number } export interface BrokerMoney { - currency: string; - units: string; - nano: number; - value: number; + 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; + 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; + 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; + account: BrokerAccount positionCounts: { - shares: number; - bonds: number; - etf: number; - other: number; - }; + 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; - }; + 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; + 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 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; + 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; + 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; + 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; + 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; + 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; + 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..f4774db 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -6,708 +6,708 @@ 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}/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: { 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 + } 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'] + } 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 +723,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 +764,874 @@ 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'] + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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?: never + } + } + } 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_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..6fff43d 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/responses' 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..450e824 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/responses' 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/browser.ts b/apps/frontend/src/shared/lib/test/browser.ts new file mode 100644 index 0000000..4dd03f0 --- /dev/null +++ b/apps/frontend/src/shared/lib/test/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/src/shared/lib/test/factories.ts b/apps/frontend/src/shared/lib/test/factories.ts index af91dde..e95c281 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/responses' 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 index bceb62e..b938b87 100644 --- a/apps/frontend/src/shared/lib/test/handlers.ts +++ b/apps/frontend/src/shared/lib/test/handlers.ts @@ -1,12 +1,12 @@ -import { http, HttpResponse } from 'msw'; +import { HttpResponse, http } from 'msw' import type { - ShareResponse, BondResponse, CandleItem, SearchResultItem, -} from '@/shared/api/responses'; + ShareResponse, +} from '@/shared/api/responses' -const API = '/api/v1'; +const API = '/api/v1' const mockShare: ShareResponse = { secid: 'SBER', @@ -31,7 +31,7 @@ const mockShare: ShareResponse = { issueCapitalization: 6250000000000, updatedAt: '2024-01-15T10:00:00Z', }, -}; +} const mockBond: BondResponse = { secid: 'SU26238RMFS5', @@ -67,7 +67,7 @@ const mockBond: BondResponse = { volume: 1000000, updatedAt: '2024-01-15T10:00:00Z', }, -}; +} const mockCandles: CandleItem[] = [ { @@ -90,12 +90,12 @@ const mockCandles: CandleItem[] = [ 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[] = [ { @@ -116,43 +116,43 @@ const mockSearchResults: SearchResultItem[] = [ 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') || ''; + const url = new URL(request.url) + const q = url.searchParams.get('q') || '' if (q.length < 2) { - return HttpResponse.json(envelope([])); + 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)); + ) + 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)); + 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`, () => @@ -164,9 +164,9 @@ export const handlers = [ ), 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)); + 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`, () => @@ -192,4 +192,4 @@ export const handlers = [ 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 index e52fee0..86f7d61 100644 --- a/apps/frontend/src/shared/lib/test/server.ts +++ b/apps/frontend/src/shared/lib/test/server.ts @@ -1,4 +1,4 @@ -import { setupServer } from 'msw/node'; -import { handlers } from './handlers'; +import { setupServer } from 'msw/node' +import { handlers } from './handlers' -export const server = setupServer(...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..f47cb6d 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 './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..ebed043 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/responses' 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..d7c973a 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/responses' 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..5cf7c68 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/responses' +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..552bb30 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/responses' 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..1c833dc 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 { BrokerAccountContext } 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..680a26c --- /dev/null +++ b/apps/frontend/src/widgets/broker-account-layout/lib/useBrokerAccountContext.ts @@ -0,0 +1,6 @@ +import { useParams } from '@tanstack/react-router' + +export function useBrokerAccountContext() { + const { accountId = '' } = useParams({ from: '/broker/$accountId' }) + return { accountId } +} 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..88a7b65 100644 --- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -1,30 +1,33 @@ -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 { Link, useParams } from '@tanstack/react-router' +import type { ReactNode } from 'react' +import { useBrokerPortfolio } from '@/entities/broker-account' -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 function BrokerAccountLayout({ children }: { children: ReactNode }) { + const { accountId = '' } = useParams({ from: '/broker/$accountId' }) + const portfolio = useBrokerPortfolio(accountId) + const basePath = `/broker/${encodeURIComponent(accountId)}` return ( @@ -58,42 +61,25 @@ export function BrokerAccountLayout() { }} > {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..71add71 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/responses' +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..c0ae185 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/responses' +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..fa93d3a 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/responses' +import { formatBrokerMoney, pluralize } from '@/shared/lib/formatters' function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { - if (!value || !total || total.value <= 0) return null; - return (value.value / total.value) * 100; + 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..8cfbbee 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/responses' 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..e2220e1 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/responses' +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..af3dd0c 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/responses' 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..581754b 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/responses' 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..50f9fc2 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/responses' 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..380a4f1 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/responses' +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..d960283 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/responses' 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..f3e166b 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/responses' 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..609bb36 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/responses' +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..93d4e22 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 { 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 { server } from '@/shared/lib/test/server' +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..60b703f 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/responses' 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..291429c 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/responses' +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..0c956c0 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/responses' 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/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/package-lock.json b/package-lock.json index b28608c..1cac322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,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 +105,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 +2910,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 +9307,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 +11108,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 +11147,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 +11236,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 +15300,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 +17877,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 +19528,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 +21392,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", @@ -28993,6 +29335,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 +29359,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 +30659,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 +33335,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..487ea19 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 && prettier --write \"**/*.{ts,tsx}\"", + "format:check": "npm run format:check -w apps/frontend && prettier --check \"**/*.{ts,tsx}\"", "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,9 +28,9 @@ "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"], + "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"], "**/*.{ts,tsx}": ["prettier --check"] }, -- 2.47.2 From 7e10b4b8aa6e627de1c6f0b20a8266fef5da2869 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 06:54:29 +0300 Subject: [PATCH 04/13] docs: update tasks and plan to reflect actual implementation progress Mark completed tasks across all 6 phases, document code-first router approach deviation, update Phase 6 plan with actual steps --- .../frontend-infrastructure-tooling/plan.md | 53 +++---- .../frontend-infrastructure-tooling/tasks.md | 140 +++++++++--------- 2 files changed, 88 insertions(+), 105 deletions(-) diff --git a/docs/features/frontend-infrastructure-tooling/plan.md b/docs/features/frontend-infrastructure-tooling/plan.md index 52b535b..f9fc584 100644 --- a/docs/features/frontend-infrastructure-tooling/plan.md +++ b/docs/features/frontend-infrastructure-tooling/plan.md @@ -70,48 +70,29 @@ ### 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) - - `@tanstack/router-plugin` (vite plugin) -2. Настроить Vite plugin в `vite.config.ts` -3. Создать файловую структуру роутов: - ``` - src/app/routes/ - __root.tsx — AppLayout + ErrorBoundary - index.tsx — HomePage - stocks.$secid.tsx — StockPage - bonds.$secid.tsx — BondPage - screener.tsx — ScreenerPage + Zod search params - login.tsx — LoginPage - register.tsx — RegisterPage - profile.tsx — ProfilePage (guard: beforeLoad) - portfolios.tsx — PortfoliosListPage (guard) - portfolios.$id.tsx — PortfolioDetailPage (guard) - broker/ - index.tsx — BrokerAccountsPage (guard) - $accountId/ - index.tsx — BrokerAccountOverviewPage (guard) - shares.tsx — BrokerPositionsPage (guard) - bonds.tsx — BrokerPositionsPage (guard) - operations.tsx — BrokerOperationsPage + Zod search params (guard) - events.tsx — BrokerEventsPage (guard) - ``` -4. Перенести каждый роут из `AppRoutes.tsx` — каждый файл создаёт lazy route -5. Создать роутер в `app/routing/router.ts`: - - `createRouter()` с Route Tree - - `beforeLoad` для guard'ов - - Loaders для предзагрузки (TanStack Query integration) -6. Заменить `` + `` → `` в `App.tsx` -7. Заменить все импорты `react-router-dom` по всему проекту: +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` + - `useNavigate` → `useNavigate({ to: '...' })` (объектный синтаксис) - `useParams` → `useParams` - - `useSearchParams` → `useSearch` + `useNavigate` + - `useSearchParams` → `useSearchParamsCompat` (временная обёртка, т.к. TanStack Router не экспортирует useSearchParams) - `useLocation` → `useLocation` -8. Заменить `MemoryRouter` в тестах на `createMemoryRouter` из TanStack Router -9. Настроить router-devtools в dev-режиме -10. Прогнать тесты +6. Заменить `MemoryRouter` в тестах на `createMemoryHistory` + `RouterProvider` +7. Обновить `test-utils.tsx` — рендер-обёртка на TanStack Router +8. Настроить router-devtools в dev-режиме +9. Прогнать тесты, проверить сборку ## Dependencies diff --git a/docs/features/frontend-infrastructure-tooling/tasks.md b/docs/features/frontend-infrastructure-tooling/tasks.md index 6b07edb..a5b80d3 100644 --- a/docs/features/frontend-infrastructure-tooling/tasks.md +++ b/docs/features/frontend-infrastructure-tooling/tasks.md @@ -2,104 +2,106 @@ ## Phase 1: ky Migration -- [ ] Доработать `shared/api/kyClient.ts`: добавить normalizeEnvelope в afterResponse hook -- [ ] Экспортировать `kyApi` (create экземпляр) и `configureKyAuth` из kyClient -- [ ] Перевести `entities/session/api/sessionApi.ts` на kyApi -- [ ] Перевести `entities/stock/api/stockApi.ts` на kyApi -- [ ] Перевести `entities/bond/api/bondApi.ts` на kyApi -- [ ] Перевести `entities/search/api/searchApi.ts` на kyApi -- [ ] Перевести `entities/portfolio/api/portfolioApi.ts` на kyApi -- [ ] Перевести `entities/broker-account/api/brokerAccountApi.ts` на kyApi -- [ ] Перевести `entities/broker-position/api/brokerPositionApi.ts` на kyApi -- [ ] Перевести `entities/broker-operation/api/brokerOperationApi.ts` на kyApi -- [ ] Перевести `entities/broker-event/api/brokerEventApi.ts` на kyApi -- [ ] Перевести `features/screener/api/screenerApi.ts` на kyApi -- [ ] Удалить `shared/api/client.ts` -- [ ] Заменить `configureAuth()` на `configureKyAuth()` в точке входа (AppProviders) -- [ ] `npm run test` — все тесты проходят -- [ ] `npm run build` — сборка проходит +- [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 - [ ] Research: проверить Biome plugin system на поддержку FSD/import-no-restricted-paths -- [ ] Установить `@biomejs/biome` (devDependency) +- [x] Установить `@biomejs/biome` (devDependency) - [ ] Запустить `npx @biomejs/biome migrate eslint --write` -- [ ] Создать `biome.json` с донастройкой под проект -- [ ] Если FSD-правила не портируются — создать минимальный `.eslintrc.cjs` только для FSD -- [ ] Удалить зависимости: eslint, prettier, @typescript-eslint/*, eslint-plugin-* -- [ ] Удалить `.eslintrc.cjs` (если FSD не нужен) -- [ ] Обновить `package.json`: `lint` → `biome check src/` -- [ ] Обновить `.gitea/workflows/ci.yml`: заменить eslint на biome -- [ ] Обновить pre-commit hook (lint-staged → biome) -- [ ] Прогнать `biome check --write src/` -- [ ] `npm run test` — все тесты проходят +- [x] Создать и настроить `biome.json` под проект +- [x] ESLint оставлен только для FSD-правил (`.eslintrc.cjs`) +- [ ] Удалить зависимости: prettier, @typescript-eslint/*, eslint-plugin-* (ESLint core пока нужен для FSD) +- [ ] Удалить `.prettierrc` и `.prettierignore` — форматирование перешло к Biome +- [x] Обновить `package.json`: `lint` → `biome check src/` +- [ ] Обновить `.gitea/workflows/ci.yml`: заменить eslint на biome для frontend +- [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 -- [ ] Проверить все импорты в entity API — должны быть из `types.ts` (codegen), не из `responses.ts` -- [ ] Если кто-то импортирует из `responses.ts` — переключить на `types.ts` -- [ ] Удалить `shared/api/responses.ts` -- [ ] Перенести normalizeEnvelope (ky-версия) в `shared/api/kyClient.ts` +- [ ] Аудит импортов: entity API используют `types.ts` (codegen) или `responses.ts`? +- [ ] Переключить все импорты с `responses.ts` на `types.ts` (если типы есть в codegen) +- [ ] Удалить `shared/api/responses.ts` (после переключения) +- [x] normalizeEnvelope перенесён в `shared/api/kyClient.ts` - [ ] `npm run build` — сборка проходит ## Phase 4: MSW Browser -- [ ] Создать `shared/lib/test/browser.ts` (setupWorker из msw/browser) -- [ ] Установить и прокинуть mockServiceWorker.js: `npx msw init public/` -- [ ] Создать `shared/config/env.ts` с чтением и экспортом VITE_API_MOCK -- [ ] В `main.tsx`: при `VITE_API_MOCK === 'true'` запускать `worker.start()` +- [x] Создать `shared/lib/test/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()` - [ ] Проверить: `VITE_API_MOCK=true npm run dev` без бэкенда — приложение работает - [ ] Проверить: `VITE_API_MOCK=false npm run dev` — запросы идут на бэкенд ## Phase 5: Env Validation -- [ ] Разработать Zod-схему в `shared/config/env.ts` для всех VITE_* переменных -- [ ] Вызвать `validateEnv()` в `main.tsx` до `ReactDOM.createRoot` +- [x] Разработать Zod-схему в `shared/config/env.ts` для всех VITE_* переменных +- [x] Валидация env выполняется при импорте (safeParse в модуле env.ts) - [ ] Проверить: при отсутствии обязательной переменной — понятная ошибка ## Phase 6: TanStack Router ### Setup -- [ ] Установить `@tanstack/react-router`, `@tanstack/router-devtools`, `@tanstack/router-plugin` -- [ ] Настроить Vite plugin для генерации RouteTree в `vite.config.ts` +- [x] Установить `@tanstack/react-router`, `@tanstack/router-devtools` +- [ ] ~~Установить `@tanstack/router-plugin`~~ (решение: code-first, без плагина) ### Route files -- [ ] Создать `src/app/routes/__root.tsx` — AppLayout + ErrorBoundary -- [ ] Создать `src/app/routes/index.tsx` — HomePage -- [ ] Создать `src/app/routes/stocks.$secid.tsx` — StockPage -- [ ] Создать `src/app/routes/bonds.$secid.tsx` — BondPage -- [ ] Создать `src/app/routes/screener.tsx` — ScreenerPage + Zod search params -- [ ] Создать `src/app/routes/login.tsx` — LoginPage -- [ ] Создать `src/app/routes/register.tsx` — RegisterPage -- [ ] Создать `src/app/routes/profile.tsx` — ProfilePage (guard: beforeLoad) -- [ ] Создать `src/app/routes/portfolios.tsx` — PortfoliosListPage (guard) -- [ ] Создать `src/app/routes/portfolios.$id.tsx` — PortfolioDetailPage (guard) -- [ ] Создать `src/app/routes/broker/index.tsx` — BrokerAccountsPage (guard) -- [ ] Создать `src/app/routes/broker.$accountId/index.tsx` — BrokerAccountOverviewPage (guard) -- [ ] Создать `src/app/routes/broker.$accountId/shares.tsx` — BrokerPositionsPage (guard) -- [ ] Создать `src/app/routes/broker.$accountId/bonds.tsx` — BrokerPositionsPage (guard) -- [ ] Создать `src/app/routes/broker.$accountId/operations.tsx` — BrokerOperationsPage (guard) -- [ ] Создать `src/app/routes/broker.$accountId/events.tsx` — BrokerEventsPage (guard) +- [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 -- [ ] Создать `app/routing/router.ts`: `createRouter()` с Route Tree -- [ ] Настроить `beforeLoad` для guard'ов -- [ ] Настроить loaders для предзагрузки (TanStack Query) -- [ ] Заменить `` + `` → `` в `App.tsx` +- [x] Создать `app/routing/router.ts`: `createRouter()` с Route Tree +- [x] Настроить `beforeLoad` для guard'ов (requireAuth) +- [ ] Настроить loaders для предзагрузки (TanStack Query) — отложено +- [x] Заменить `` + `` → `` в `App.tsx` ### Replace imports -- [ ] Заменить `Link` → `@tanstack/react-router` Link по всему проекту -- [ ] Заменить `useNavigate` → `@tanstack/react-router` -- [ ] Заменить `useParams` → `@tanstack/react-router` -- [ ] Заменить `useSearchParams` → `useSearch` + `useNavigate` -- [ ] Заменить `useLocation` → `@tanstack/react-router` +- [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 -- [ ] Заменить `MemoryRouter` в тестах на `createMemoryRouter` из TanStack Router -- [ ] Обновить тестовые утилиты (`test-utils.tsx`) -- [ ] `npm run test` — все тесты проходят -- [ ] `npm run build` — сборка проходит +- [x] Заменить `MemoryRouter` в тестах на `createMemoryHistory` + `RouterProvider` +- [x] Обновить тестовые утилиты (`test-utils.tsx`) +- [x] `npm run test` — все тесты проходят (125) +- [x] `npm run build` — сборка проходит ### Devtools -- [ ] Настроить `@tanstack/router-devtools` в dev-режиме -- [ ] Проверить навигацию по всем страницам вручную +- [x] Настроить `@tanstack/router-devtools` в dev-режиме +- [ ] Проверить навигацию по всем страницам вручную — отложено -- 2.47.2 From c7a8993b4a1ed9ba9a48138737640bfc2720e302 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 06:58:35 +0300 Subject: [PATCH 05/13] chore: remove prettier, consolidate formatting under biome - Remove prettier dependency and config files (.prettierrc, .prettierignore) - Update root format/format:check scripts to use biome only (via frontend) - Update lint-staged: remove prettier --check, keep biome + eslint - Update CI: remove redundant format:check step - Update ADR-018 with code-first TanStack Router approach note --- .gitea/workflows/ci.yml | 3 --- .prettierignore | 1 - .prettierrc | 6 ------ apps/docs/docs/adr/ADR-018-tanstack-router.md | 17 +++++++++++------ package-lock.json | 5 +++-- package.json | 10 ++++------ 6 files changed, 18 insertions(+), 24 deletions(-) delete mode 100644 .prettierignore delete mode 100644 .prettierrc 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/.prettierignore b/.prettierignore deleted file mode 100644 index dc8582f..0000000 --- a/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -apps/frontend/ 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/docs/docs/adr/ADR-018-tanstack-router.md b/apps/docs/docs/adr/ADR-018-tanstack-router.md index b1855d9..99759bd 100644 --- a/apps/docs/docs/adr/ADR-018-tanstack-router.md +++ b/apps/docs/docs/adr/ADR-018-tanstack-router.md @@ -38,7 +38,7 @@ ## Решение -Мигрировать на **TanStack Router**. +Мигрировать на **TanStack Router** (code-first approach). Причины: 1. **Типобезопасность** — RouteTree generation исключает опечатки в путях и невалидные search params @@ -48,6 +48,8 @@ 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-плагина. + ## Последствия ### Положительные @@ -58,14 +60,17 @@ ### Риски - Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов -- `MemoryRouter` в тестах заменяется на `createMemoryRouter` из TanStack Router -- Файловая структура роутов меняется — `src/app/routes/` с Route Tree generation +- `MemoryRouter` в тестах заменяется на `createMemoryHistory` + `RouterProvider` из TanStack Router +- Файловая структура роутов меняется — `src/app/routing/` с code-first определением в `routeTree.tsx` +- TanStack Router не экспортирует `useSearchParams` — потребовалась обёртка `useSearchParamsCompat` +- `useNavigate` использует объектный синтаксис: `navigate({ to: '...' })` вместо строкового `navigate('...')` - Learning curve для команды ### Миграция -- Каждый роут переносится по одному -- Старый `AppRoutes.tsx` сохраняется до полного прохождения тестов -- `react-router-dom` удаляется только после верификации +- Все маршруты определены в одном `routeTree.tsx` (code-first) +- Старый `AppRoutes.tsx` удалён после прохождения тестов +- `react-router-dom` удалён из зависимостей после верификации +- Все 125 тестов проходят, сборка зелёная ## Связанные документы - `docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md` diff --git a/package-lock.json b/package-lock.json index 1cac322..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": { @@ -28534,6 +28533,8 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/package.json b/package.json index 487ea19..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": "npm run format -w apps/frontend && prettier --write \"**/*.{ts,tsx}\"", - "format:check": "npm run format:check -w apps/frontend && 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", @@ -31,13 +31,11 @@ "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"], - "**/*.{ts,tsx}": ["prettier --check"] + "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": { -- 2.47.2 From cbb5d09bc33e252da331e509b9c10983826f8a0c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 06:58:55 +0300 Subject: [PATCH 06/13] =?UTF-8?q?docs:=20update=20tasks.md=20=E2=80=94=20m?= =?UTF-8?q?ark=20Phase=202=20prettier=20removal=20as=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/features/frontend-infrastructure-tooling/tasks.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/frontend-infrastructure-tooling/tasks.md b/docs/features/frontend-infrastructure-tooling/tasks.md index a5b80d3..7ba9b03 100644 --- a/docs/features/frontend-infrastructure-tooling/tasks.md +++ b/docs/features/frontend-infrastructure-tooling/tasks.md @@ -26,10 +26,10 @@ - [ ] Запустить `npx @biomejs/biome migrate eslint --write` - [x] Создать и настроить `biome.json` под проект - [x] ESLint оставлен только для FSD-правил (`.eslintrc.cjs`) -- [ ] Удалить зависимости: prettier, @typescript-eslint/*, eslint-plugin-* (ESLint core пока нужен для FSD) -- [ ] Удалить `.prettierrc` и `.prettierignore` — форматирование перешло к Biome -- [x] Обновить `package.json`: `lint` → `biome check src/` -- [ ] Обновить `.gitea/workflows/ci.yml`: заменить eslint на biome для frontend +- [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` — все тесты проходят -- 2.47.2 From 5b794c0419a537b99f4c7164deed22d205acddec Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 07:17:30 +0300 Subject: [PATCH 07/13] feat: add Swagger response DTOs for all missing endpoints - Shares: ShareEnvelopeDto, ShareMarketDataEnvelopeDto, DividendsEnvelopeDto, ShareHistoryEnvelopeDto, DividendItemDto, HistoryItemDto - Bonds: BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto, BondHistoryItemDto - Candles: CandleItemDto, CandleEnvelopeDto - Securities: SearchResultItemDto, SearchEnvelopeDto - Health: HealthResponseDto, HealthEnvelopeDto - Add @ApiOkResponse decorators to all previously undocumented endpoints - Reuse ApiResponseMeta from common for all envelope DTOs --- .../src/modules/bonds/bonds.controller.ts | 8 +++- .../modules/bonds/dto/bonds-envelope.dto.ts | 28 ++++++++++++++ .../src/modules/bonds/dto/history-item.dto.ts | 15 ++++++++ .../src/modules/candles/candles.controller.ts | 7 +++- .../modules/candles/dto/candle-item.dto.ts | 27 +++++++++++++ .../candles/dto/candles-envelope.dto.ts | 11 ++++++ .../modules/health/dto/health-envelope.dto.ts | 11 ++++++ .../modules/health/dto/health-response.dto.ts | 12 ++++++ .../src/modules/health/health.controller.ts | 6 ++- .../securities/dto/search-response.dto.ts | 33 ++++++++++++++++ .../securities/securities.controller.ts | 6 ++- .../modules/shares/dto/dividend-item.dto.ts | 12 ++++++ .../modules/shares/dto/history-item.dto.ts | 24 ++++++++++++ .../modules/shares/dto/shares-envelope.dto.ts | 38 +++++++++++++++++++ .../src/modules/shares/shares.controller.ts | 14 ++++++- 15 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 apps/backend/src/modules/bonds/dto/bonds-envelope.dto.ts create mode 100644 apps/backend/src/modules/bonds/dto/history-item.dto.ts create mode 100644 apps/backend/src/modules/candles/dto/candle-item.dto.ts create mode 100644 apps/backend/src/modules/candles/dto/candles-envelope.dto.ts create mode 100644 apps/backend/src/modules/health/dto/health-envelope.dto.ts create mode 100644 apps/backend/src/modules/health/dto/health-response.dto.ts create mode 100644 apps/backend/src/modules/securities/dto/search-response.dto.ts create mode 100644 apps/backend/src/modules/shares/dto/dividend-item.dto.ts create mode 100644 apps/backend/src/modules/shares/dto/history-item.dto.ts create mode 100644 apps/backend/src/modules/shares/dto/shares-envelope.dto.ts 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, -- 2.47.2 From 063e80c375b777c3a5434c79f1e2eb0903830919 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 19:58:23 +0300 Subject: [PATCH 08/13] feat(openapi): unify frontend types with codegen, fix backend nullable DTOs - Fix ApiResponseMeta nullable property (add type: String) to prevent Record in codegen - Fix broker events DTO nullable fields with proper type annotations - Regenerate frontend types.ts from updated Swagger schema - Replace all hand-written types in responses.ts with codegen aliases - Remove stale BrokerPortfolioEvent/BrokerEventsSummary/BrokerEventsData interfaces - Fix codegen output path in frontend package.json --- .../src/common/dto/api-response.dto.ts | 2 +- .../tbank/dto/broker-events-response.dto.ts | 22 +- apps/frontend/package.json | 2 +- apps/frontend/src/shared/api/index.ts | 3 + apps/frontend/src/shared/api/responses.ts | 423 ++---------------- apps/frontend/src/shared/api/types.ts | 379 +++++++++++++++- 6 files changed, 431 insertions(+), 400 deletions(-) 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/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/frontend/package.json b/apps/frontend/package.json index 753f13f..2f85446 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -8,7 +8,7 @@ "build": "vite build", "typecheck": "tsc -b", "preview": "vite preview", - "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts", + "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/", diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts index 223b873..f1c7620 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -8,6 +8,9 @@ export type { BondMarketData, BondResponse, BrokerAccount, + BrokerEventItem, + BrokerEventsData, + BrokerEventsSummary, BrokerMoney, BrokerOperation, BrokerOperationCategory, diff --git a/apps/frontend/src/shared/api/responses.ts b/apps/frontend/src/shared/api/responses.ts index 3392725..7841ed0 100644 --- a/apps/frontend/src/shared/api/responses.ts +++ b/apps/frontend/src/shared/api/responses.ts @@ -1,391 +1,62 @@ -export interface ApiResponseMeta { - cachedAt: string | null - fromCache: boolean -} +import type { components } from './types' + +// Re-exported from openapi-typescript codegen with friendly names + +export type ApiResponseMeta = components['schemas']['ApiResponseMeta'] 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 -} +// Auth +export type AuthResponse = components['schemas']['AuthTokenDataDto'] +export type UserResponse = components['schemas']['AuthUserDto'] -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 -} +// 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'] -export interface DividendItem { - registryCloseDate: string - value: number - currency: string -} +// Bonds +export type BondResponse = components['schemas']['BondResponseDto'] +export type BondMarketData = components['schemas']['BondMarketDataDto'] +export type BondHistoryItem = components['schemas']['BondHistoryItemDto'] -export interface ShareHistoryItem { - date: string - open: number - high: number - low: number - close: number - volume: number - value: number -} +// Candles +export type CandleItem = components['schemas']['CandleItemDto'] -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 -} +// Search +export type SearchResultItem = components['schemas']['SearchResultItemDto'] -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 -} +// Screener +export type ScreenerResult = components['schemas']['ScreenerResultDto'] +export type ScreenerItem = components['schemas']['ScreenerItemDto'] -export interface BondHistoryItem { - date: string - closePrice: number - yieldClose: number | null - duration: number | null -} +// 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'] -export interface CandleItem { - open: number - high: number - low: number - close: number - volume: number - value: number - begin: string - end: string -} +// Health +export type HealthResponse = components['schemas']['HealthResponseDto'] -export interface SearchResultItem { - secid: string - isin: string - shortName: string - type: 'share' | 'bond' - listLevel: number - currency: string | null - price: number | null -} +// Broker — some nullable fields still have Record from backend +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'] -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 -} +// 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/types.ts b/apps/frontend/src/shared/api/types.ts index f4774db..33b54c3 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -451,6 +451,23 @@ export interface paths { 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 @@ -472,6 +489,22 @@ export interface paths { 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 @@ -519,6 +552,26 @@ export interface components { /** @example John Doe */ 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 @@ -570,6 +623,215 @@ export interface components { 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 @@ -895,6 +1157,51 @@ export interface components { 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 @@ -925,7 +1232,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['HealthEnvelopeDto'] + } } } } @@ -1073,7 +1382,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['SearchEnvelopeDto'] + } } } } @@ -1135,7 +1446,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['ShareEnvelopeDto'] + } } } } @@ -1154,7 +1467,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['ShareMarketDataEnvelopeDto'] + } } } } @@ -1173,7 +1488,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['DividendsEnvelopeDto'] + } } } } @@ -1195,7 +1512,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['ShareHistoryEnvelopeDto'] + } } } } @@ -1214,7 +1533,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['BondEnvelopeDto'] + } } } } @@ -1233,7 +1554,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['BondMarketDataEnvelopeDto'] + } } } } @@ -1255,7 +1578,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['BondHistoryEnvelopeDto'] + } } } } @@ -1278,7 +1603,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['CandleEnvelopeDto'] + } } } } @@ -1301,7 +1628,9 @@ export interface operations { headers: { [name: string]: unknown } - content?: never + content: { + 'application/json': components['schemas']['CandleEnvelopeDto'] + } } } } @@ -1610,6 +1939,34 @@ export interface operations { } } } + 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: { -- 2.47.2 From 9932278b640bc5f8cf9b0a2d347163b4d12d627e Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 20:24:16 +0300 Subject: [PATCH 09/13] feat: complete Phase 3 API type unification, delete stale test file - Delete shared/api/responses.ts, move type re-exports to index.ts - Update all 55+ imports from shared/api/responses to shared/api - Delete stale client.test.ts (tested deleted client.ts) - Run biome checks and fix import ordering - Update tasks.md and plan.md to reflect actual approach --- .../src/app/providers/SessionProvider.tsx | 2 +- .../frontend/src/entities/bond/api/bondApi.ts | 4 +- .../src/entities/bond/model/useBond.ts | 2 +- .../src/entities/bond/model/useBondCandles.ts | 2 +- .../broker-account/api/brokerAccountApi.ts | 2 +- .../model/brokerAccountsOverview.test.ts | 2 +- .../model/brokerAccountsOverview.ts | 2 +- .../model/useBrokerAccountPortfolios.ts | 2 +- .../broker-account/model/useBrokerAccounts.ts | 2 +- .../model/useBrokerPortfolio.ts | 2 +- .../broker-event/api/brokerEventApi.ts | 2 +- .../broker-event/model/useBrokerEvents.ts | 2 +- .../api/brokerOperationApi.ts | 2 +- .../model/operationFilters.ts | 2 +- .../model/useBrokerOperations.ts | 2 +- .../broker-position/api/brokerPositionApi.ts | 2 +- .../model/brokerAllocation.test.ts | 2 +- .../broker-position/model/brokerAllocation.ts | 2 +- .../model/brokerDisplay.test.ts | 2 +- .../broker-position/model/brokerDisplay.ts | 2 +- .../model/useBrokerPositions.ts | 2 +- .../entities/portfolio/api/portfolioApi.ts | 7 +- .../entities/portfolio/model/usePortfolio.ts | 2 +- .../portfolio/model/usePortfolioAnalytics.ts | 2 +- .../entities/portfolio/model/usePortfolios.ts | 2 +- .../portfolio/model/usePositionMutations.ts | 2 +- .../src/entities/search/api/searchApi.ts | 2 +- .../src/entities/search/model/useSearch.ts | 2 +- .../src/entities/session/api/sessionApi.ts | 2 +- .../src/entities/session/api/tokenManager.ts | 2 +- .../entities/session/model/useSessionStore.ts | 2 +- .../src/entities/stock/api/stockApi.ts | 4 +- .../src/entities/stock/model/useStock.ts | 2 +- .../entities/stock/model/useStockCandles.ts | 2 +- .../entities/stock/model/useStockDividends.ts | 2 +- .../src/features/screener/api/screenerApi.ts | 2 +- .../features/screener/ui/ScreenerTable.tsx | 2 +- apps/frontend/src/shared/api/client.test.ts | 163 ------------------ apps/frontend/src/shared/api/index.ts | 96 +++++++---- apps/frontend/src/shared/api/responses.ts | 62 ------- apps/frontend/src/shared/lib/formatters.ts | 2 +- .../src/shared/lib/session-context.ts | 2 +- .../frontend/src/shared/lib/test/factories.ts | 2 +- apps/frontend/src/shared/lib/test/handlers.ts | 7 +- .../widgets/bond-details/ui/BondDetails.tsx | 2 +- .../ui/BondPositionRow.tsx | 2 +- .../ui/BondPositionTable.tsx | 2 +- .../ui/BrokerAccountCard.tsx | 2 +- .../ui/BrokerAllocationChart.tsx | 2 +- .../ui/BrokerOperationsTable.tsx | 2 +- .../broker-overview/ui/BrokerAssetCards.tsx | 2 +- .../broker-overview/ui/BrokerSummary.tsx | 2 +- .../ui/BrokerPositionTable.tsx | 2 +- .../ui/PositionTicker.tsx | 2 +- .../dividends-table/ui/DividendsTable.tsx | 2 +- .../ui/AnalyticsSummary.tsx | 2 +- .../portfolio-card/ui/PortfolioCard.tsx | 2 +- .../portfolio-form/ui/PortfolioForm.tsx | 2 +- .../portfolio-summary/ui/AllocationChart.tsx | 2 +- .../portfolio-summary/ui/PortfolioSummary.tsx | 2 +- .../ui/SharePositionRow.tsx | 2 +- .../ui/SharePositionTable.tsx | 2 +- .../widgets/stock-details/ui/StockDetails.tsx | 2 +- .../frontend-infrastructure-tooling/plan.md | 10 +- .../frontend-infrastructure-tooling/tasks.md | 12 +- 65 files changed, 136 insertions(+), 341 deletions(-) delete mode 100644 apps/frontend/src/shared/api/client.test.ts delete mode 100644 apps/frontend/src/shared/api/responses.ts diff --git a/apps/frontend/src/app/providers/SessionProvider.tsx b/apps/frontend/src/app/providers/SessionProvider.tsx index dc68d31..a4cbd2c 100644 --- a/apps/frontend/src/app/providers/SessionProvider.tsx +++ b/apps/frontend/src/app/providers/SessionProvider.tsx @@ -6,8 +6,8 @@ import { handleUnauthorized, setOnUnauthorized, } from '@/entities/session/api/tokenManager' +import type { UserResponse } from '@/shared/api' import { configureKyAuth } from '@/shared/api/kyClient' -import type { UserResponse } from '@/shared/api/responses' export function SessionProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null) diff --git a/apps/frontend/src/entities/bond/api/bondApi.ts b/apps/frontend/src/entities/bond/api/bondApi.ts index 2357a1f..4d505ce 100644 --- a/apps/frontend/src/entities/bond/api/bondApi.ts +++ b/apps/frontend/src/entities/bond/api/bondApi.ts @@ -1,11 +1,11 @@ -import { request } from '@/shared/api/kyClient' import type { ApiResponseMeta, 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)}`) diff --git a/apps/frontend/src/entities/bond/model/useBond.ts b/apps/frontend/src/entities/bond/model/useBond.ts index 1ca08ee..60ead24 100644 --- a/apps/frontend/src/entities/bond/model/useBond.ts +++ b/apps/frontend/src/entities/bond/model/useBond.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { BondResponse } from '@/shared/api/responses' +import type { BondResponse } from '@/shared/api' import { getBond } from '../api/bondApi' export function useBond(secid: string) { diff --git a/apps/frontend/src/entities/bond/model/useBondCandles.ts b/apps/frontend/src/entities/bond/model/useBondCandles.ts index ebec92e..931b636 100644 --- a/apps/frontend/src/entities/bond/model/useBondCandles.ts +++ b/apps/frontend/src/entities/bond/model/useBondCandles.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { CandleItem } from '@/shared/api/responses' +import type { CandleItem } from '@/shared/api' import { getBondCandles } from '../api/bondApi' export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { diff --git a/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts b/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts index 814c017..67b9f4b 100644 --- a/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts +++ b/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts @@ -1,5 +1,5 @@ +import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api' import { request } from '@/shared/api/kyClient' -import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses' export type BrokerOperationQuery = { from?: string 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 8cebcee..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,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { BrokerPortfolio } from '@/shared/api/responses' +import type { BrokerPortfolio } from '@/shared/api' import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview' function portfolio( diff --git a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts index 2990d89..209246b 100644 --- a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts +++ b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts @@ -1,4 +1,4 @@ -import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses' +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' export interface BrokerCurrencyAllocationSummary { shares: number diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts b/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts index 1caad5f..bfca33a 100644 --- a/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts +++ b/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts @@ -1,5 +1,5 @@ import { useQueries } from '@tanstack/react-query' -import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses' +import type { BrokerAccount, BrokerPortfolio } from '@/shared/api' import { getBrokerPortfolio } from '../api/brokerAccountApi' export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts b/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts index ff10939..ff4a3b6 100644 --- a/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts +++ b/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { BrokerAccount } from '@/shared/api/responses' +import type { BrokerAccount } from '@/shared/api' import { getBrokerAccounts } from '../api/brokerAccountApi' export function useBrokerAccounts() { diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts index b86ac98..f7a9a5b 100644 --- a/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts +++ b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { BrokerPortfolio } from '@/shared/api/responses' +import type { BrokerPortfolio } from '@/shared/api' import { getBrokerPortfolio } from '../api/brokerAccountApi' export function useBrokerPortfolio(accountId: string | undefined) { diff --git a/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts b/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts index cba3d1e..6180d85 100644 --- a/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts +++ b/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts @@ -1,5 +1,5 @@ +import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api' import { request } from '@/shared/api/kyClient' -import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses' export type BrokerEventsQuery = { from: string diff --git a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts index 7f1c74d..5efa7fe 100644 --- a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts +++ b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { BrokerEventsData } from '@/shared/api/responses' +import type { BrokerEventsData } from '@/shared/api' import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi' export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { diff --git a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts index f0f1606..e5d5b2f 100644 --- a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts +++ b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts @@ -1,5 +1,5 @@ +import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api' import { request } from '@/shared/api/kyClient' -import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses' export type BrokerOperationQuery = { from?: string diff --git a/apps/frontend/src/entities/broker-operation/model/operationFilters.ts b/apps/frontend/src/entities/broker-operation/model/operationFilters.ts index b2c2d09..7510ed2 100644 --- a/apps/frontend/src/entities/broker-operation/model/operationFilters.ts +++ b/apps/frontend/src/entities/broker-operation/model/operationFilters.ts @@ -1,4 +1,4 @@ -import type { BrokerOperation } from '@/shared/api/responses' +import type { BrokerOperation } from '@/shared/api' export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown' diff --git a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts index a02ba05..20335c4 100644 --- a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts +++ b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts @@ -1,5 +1,5 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' -import type { BrokerOperationsPage } from '@/shared/api/responses' +import type { BrokerOperationsPage } from '@/shared/api' import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi' export function useBrokerOperations( diff --git a/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts b/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts index 858f6a0..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 type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api' import { request } from '@/shared/api/kyClient' -import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses' export function getBrokerPositions( accountId: string, 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 7715884..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,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses' +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' import { buildBrokerAllocation } from './brokerAllocation' function money(value: number): BrokerMoney { diff --git a/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts b/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts index 51f670c..2e20a2a 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts @@ -1,4 +1,4 @@ -import type { BrokerPortfolio } from '@/shared/api/responses' +import type { BrokerPortfolio } from '@/shared/api' export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other' 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 3c978a3..59480f2 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts @@ -5,7 +5,7 @@ import { getBrokerOperationTypeLabel, isBrokerOperationType, } from '@/entities/broker-operation' -import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses' +import type { BrokerOperation, BrokerPosition } from '@/shared/api' import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay' function position(input: Partial): BrokerPosition { diff --git a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts index 7618b23..174fad4 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts @@ -1,4 +1,4 @@ -import type { BrokerPosition } from '@/shared/api/responses' +import type { BrokerPosition } from '@/shared/api' export type BrokerPositionGroup = 'shares' | 'bonds' | 'other' diff --git a/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts b/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts index 6ad870b..1911d63 100644 --- a/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts +++ b/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts @@ -1,5 +1,5 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' -import type { BrokerPositionsPage } from '@/shared/api/responses' +import type { BrokerPositionsPage } from '@/shared/api' import { getBrokerPositions } from '../api/brokerPositionApi' export function useBrokerPositions( diff --git a/apps/frontend/src/entities/portfolio/api/portfolioApi.ts b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts index 4ddea5c..26634c9 100644 --- a/apps/frontend/src/entities/portfolio/api/portfolioApi.ts +++ b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts @@ -1,10 +1,5 @@ +import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from '@/shared/api' import { request } from '@/shared/api/kyClient' -import type { - AnalyticsResponse, - Portfolio, - PortfolioDetail, - Position, -} from '@/shared/api/responses' export function getPortfolios(): Promise<{ data: Portfolio[] diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolio.ts b/apps/frontend/src/entities/portfolio/model/usePortfolio.ts index 3ceb9ed..547f4ac 100644 --- a/apps/frontend/src/entities/portfolio/model/usePortfolio.ts +++ b/apps/frontend/src/entities/portfolio/model/usePortfolio.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { PortfolioDetail } from '@/shared/api/responses' +import type { PortfolioDetail } from '@/shared/api' import { getPortfolio } from '../api/portfolioApi' export function usePortfolio(id: number) { diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts b/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts index 9560ac2..3d0b8e1 100644 --- a/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts +++ b/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { AnalyticsResponse } from '@/shared/api/responses' +import type { AnalyticsResponse } from '@/shared/api' import { getPortfolioAnalytics } from '../api/portfolioApi' export function usePortfolioAnalytics(portfolioId: number) { diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolios.ts b/apps/frontend/src/entities/portfolio/model/usePortfolios.ts index 3bd8cc6..d5c7050 100644 --- a/apps/frontend/src/entities/portfolio/model/usePortfolios.ts +++ b/apps/frontend/src/entities/portfolio/model/usePortfolios.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { Portfolio } from '@/shared/api/responses' +import type { Portfolio } from '@/shared/api' import { getPortfolios } from '../api/portfolioApi' export function usePortfolios() { diff --git a/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts b/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts index 639e121..25bea73 100644 --- a/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts +++ b/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import type { PortfolioDetail } from '@/shared/api/responses' +import type { PortfolioDetail } from '@/shared/api' import { addPosition, removePosition, updatePosition } from '../api/portfolioApi' export function usePositionMutations(portfolioId: number) { diff --git a/apps/frontend/src/entities/search/api/searchApi.ts b/apps/frontend/src/entities/search/api/searchApi.ts index b901d41..327e891 100644 --- a/apps/frontend/src/entities/search/api/searchApi.ts +++ b/apps/frontend/src/entities/search/api/searchApi.ts @@ -1,5 +1,5 @@ +import type { SearchResultItem } from '@/shared/api' import { request } from '@/shared/api/kyClient' -import type { SearchResultItem } from '@/shared/api/responses' export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) { return request('/api/v1/securities/search', { diff --git a/apps/frontend/src/entities/search/model/useSearch.ts b/apps/frontend/src/entities/search/model/useSearch.ts index 62b9e2d..b6c57a9 100644 --- a/apps/frontend/src/entities/search/model/useSearch.ts +++ b/apps/frontend/src/entities/search/model/useSearch.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { SearchResultItem } from '@/shared/api/responses' +import type { SearchResultItem } from '@/shared/api' import { searchSecurities } from '../api/searchApi' export function useSearch(query: string) { diff --git a/apps/frontend/src/entities/session/api/sessionApi.ts b/apps/frontend/src/entities/session/api/sessionApi.ts index 1a86cbc..07fdd97 100644 --- a/apps/frontend/src/entities/session/api/sessionApi.ts +++ b/apps/frontend/src/entities/session/api/sessionApi.ts @@ -1,5 +1,5 @@ +import type { AuthResponse, UserResponse } from '@/shared/api' import { request } from '@/shared/api/kyClient' -import type { AuthResponse, UserResponse } from '@/shared/api/responses' import { setAccessToken } from './tokenManager' export async function login(email: string, password: string) { diff --git a/apps/frontend/src/entities/session/api/tokenManager.ts b/apps/frontend/src/entities/session/api/tokenManager.ts index c671f36..a77df65 100644 --- a/apps/frontend/src/entities/session/api/tokenManager.ts +++ b/apps/frontend/src/entities/session/api/tokenManager.ts @@ -1,5 +1,5 @@ +import type { AuthResponse } from '@/shared/api' import { normalizeEnvelope } from '@/shared/api/kyClient' -import type { AuthResponse } from '@/shared/api/responses' let accessToken: string | null = null let onUnauthorized: (() => void) | null = null diff --git a/apps/frontend/src/entities/session/model/useSessionStore.ts b/apps/frontend/src/entities/session/model/useSessionStore.ts index 9593101..d2f6c5c 100644 --- a/apps/frontend/src/entities/session/model/useSessionStore.ts +++ b/apps/frontend/src/entities/session/model/useSessionStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { UserResponse } from '@/shared/api/responses' +import type { UserResponse } from '@/shared/api' interface SessionState { user: UserResponse | null diff --git a/apps/frontend/src/entities/stock/api/stockApi.ts b/apps/frontend/src/entities/stock/api/stockApi.ts index b0ee9a5..8debd80 100644 --- a/apps/frontend/src/entities/stock/api/stockApi.ts +++ b/apps/frontend/src/entities/stock/api/stockApi.ts @@ -1,4 +1,3 @@ -import { request } from '@/shared/api/kyClient' import type { ApiResponseMeta, CandleItem, @@ -6,7 +5,8 @@ import type { ShareHistoryItem, ShareResponse, StockMarketData, -} from '@/shared/api/responses' +} 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)}`) diff --git a/apps/frontend/src/entities/stock/model/useStock.ts b/apps/frontend/src/entities/stock/model/useStock.ts index a3bca2d..7d88e61 100644 --- a/apps/frontend/src/entities/stock/model/useStock.ts +++ b/apps/frontend/src/entities/stock/model/useStock.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { ShareResponse } from '@/shared/api/responses' +import type { ShareResponse } from '@/shared/api' import { getShare } from '../api/stockApi' export function useStock(secid: string) { diff --git a/apps/frontend/src/entities/stock/model/useStockCandles.ts b/apps/frontend/src/entities/stock/model/useStockCandles.ts index 66912b4..d2887b5 100644 --- a/apps/frontend/src/entities/stock/model/useStockCandles.ts +++ b/apps/frontend/src/entities/stock/model/useStockCandles.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { CandleItem } from '@/shared/api/responses' +import type { CandleItem } from '@/shared/api' import { getShareCandles } from '../api/stockApi' export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { diff --git a/apps/frontend/src/entities/stock/model/useStockDividends.ts b/apps/frontend/src/entities/stock/model/useStockDividends.ts index 0b0a5b8..2bb4f92 100644 --- a/apps/frontend/src/entities/stock/model/useStockDividends.ts +++ b/apps/frontend/src/entities/stock/model/useStockDividends.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import type { DividendItem } from '@/shared/api/responses' +import type { DividendItem } from '@/shared/api' import { getShareDividends } from '../api/stockApi' export function useStockDividends(secid: string) { diff --git a/apps/frontend/src/features/screener/api/screenerApi.ts b/apps/frontend/src/features/screener/api/screenerApi.ts index 76faed9..e0dd3d0 100644 --- a/apps/frontend/src/features/screener/api/screenerApi.ts +++ b/apps/frontend/src/features/screener/api/screenerApi.ts @@ -1,5 +1,5 @@ +import type { ScreenerResult } from '@/shared/api' import { request } from '@/shared/api/kyClient' -import type { ScreenerResult } from '@/shared/api/responses' export interface ScreenerQuery { type: 'share' | 'bond' diff --git a/apps/frontend/src/features/screener/ui/ScreenerTable.tsx b/apps/frontend/src/features/screener/ui/ScreenerTable.tsx index 280b2ed..d5394ab 100644 --- a/apps/frontend/src/features/screener/ui/ScreenerTable.tsx +++ b/apps/frontend/src/features/screener/ui/ScreenerTable.tsx @@ -1,5 +1,5 @@ import { Link } from '@tanstack/react-router' -import type { ScreenerResult } from '@/shared/api/responses' +import type { ScreenerResult } from '@/shared/api' interface Props { result: ScreenerResult 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 67c411c..0000000 --- a/apps/frontend/src/shared/api/client.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { HttpResponse, http } from 'msw' -import { beforeEach, describe, expect, it } from 'vitest' -import { - getAccessToken, - handleUnauthorized, - setAccessToken, - setOnUnauthorized, -} from '@/entities/session/api/tokenManager' -import { server } from '@/shared/lib/test/server' -import { configureKyAuth, request } from './kyClient' - -const API = '/api/v1' - -beforeEach(() => { - setAccessToken(null) - configureKyAuth({ - 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/index.ts b/apps/frontend/src/shared/api/index.ts index f1c7620..566da60 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -1,36 +1,62 @@ +import type { components } from './types' + export { configureKyAuth, getHealth, request } from './kyClient' -export type { - AnalyticsResponse, - ApiEnvelope, - ApiResponseMeta, - AuthResponse, - BondHistoryItem, - BondMarketData, - BondResponse, - BrokerAccount, - BrokerEventItem, - BrokerEventsData, - BrokerEventsSummary, - BrokerMoney, - BrokerOperation, - BrokerOperationCategory, - BrokerOperationsPage, - BrokerPortfolio, - BrokerPosition, - BrokerPositionsPage, - CandleItem, - DividendItem, - HealthResponse, - Portfolio, - PortfolioDetail, - PortfolioSummary, - Position, - PositionWithPrice, - ScreenerItem, - ScreenerResult, - SearchResultItem, - ShareHistoryItem, - ShareResponse, - StockMarketData, - UserResponse, -} from './responses' + +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/responses.ts b/apps/frontend/src/shared/api/responses.ts deleted file mode 100644 index 7841ed0..0000000 --- a/apps/frontend/src/shared/api/responses.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { components } from './types' - -// Re-exported from openapi-typescript codegen with friendly names - -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 — some nullable fields still have Record from backend -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/lib/formatters.ts b/apps/frontend/src/shared/lib/formatters.ts index 6fff43d..f528fcf 100644 --- a/apps/frontend/src/shared/lib/formatters.ts +++ b/apps/frontend/src/shared/lib/formatters.ts @@ -1,4 +1,4 @@ -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', { diff --git a/apps/frontend/src/shared/lib/session-context.ts b/apps/frontend/src/shared/lib/session-context.ts index 450e824..f9a5179 100644 --- a/apps/frontend/src/shared/lib/session-context.ts +++ b/apps/frontend/src/shared/lib/session-context.ts @@ -1,5 +1,5 @@ import { createContext } from 'react' -import type { UserResponse } from '@/shared/api/responses' +import type { UserResponse } from '@/shared/api' export interface SessionContextValue { user: UserResponse | null diff --git a/apps/frontend/src/shared/lib/test/factories.ts b/apps/frontend/src/shared/lib/test/factories.ts index e95c281..82e474d 100644 --- a/apps/frontend/src/shared/lib/test/factories.ts +++ b/apps/frontend/src/shared/lib/test/factories.ts @@ -8,7 +8,7 @@ import type { ShareResponse, StockMarketData, UserResponse, -} from '@/shared/api/responses' +} from '@/shared/api' export function createMockMarketData(overrides: Partial = {}): StockMarketData { return { diff --git a/apps/frontend/src/shared/lib/test/handlers.ts b/apps/frontend/src/shared/lib/test/handlers.ts index b938b87..029a4d4 100644 --- a/apps/frontend/src/shared/lib/test/handlers.ts +++ b/apps/frontend/src/shared/lib/test/handlers.ts @@ -1,10 +1,5 @@ import { HttpResponse, http } from 'msw' -import type { - BondResponse, - CandleItem, - SearchResultItem, - ShareResponse, -} from '@/shared/api/responses' +import type { BondResponse, CandleItem, SearchResultItem, ShareResponse } from '@/shared/api' const API = '/api/v1' diff --git a/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx b/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx index ebed043..9a7b19d 100644 --- a/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx +++ b/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx @@ -1,4 +1,4 @@ -import type { BondResponse } from '@/shared/api/responses' +import type { BondResponse } from '@/shared/api' interface BondDetailsProps { bond: BondResponse 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 d7c973a..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,6 +1,6 @@ import { Link } from '@tanstack/react-router' import { useState } from 'react' -import type { PositionWithPrice } from '@/shared/api/responses' +import type { PositionWithPrice } from '@/shared/api' interface Props { position: PositionWithPrice 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 5cf7c68..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,4 +1,4 @@ -import type { PositionWithPrice } from '@/shared/api/responses' +import type { PositionWithPrice } from '@/shared/api' import { BondPositionRow } from './BondPositionRow' interface Props { 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 552bb30..efb58a2 100644 --- a/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx +++ b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx @@ -2,7 +2,7 @@ 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/responses' +import type { BrokerAccount, BrokerPortfolio } from '@/shared/api' import { formatBrokerDate, formatBrokerMoney, 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 71add71..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,7 +1,7 @@ import { Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' import { buildBrokerAllocation } from '@/entities/broker-position' -import type { BrokerPortfolio } from '@/shared/api/responses' +import type { BrokerPortfolio } from '@/shared/api' import { formatBrokerCurrencyValue } from '@/shared/lib/formatters' const RADIUS = 44 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 c0ae185..50aa765 100644 --- a/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx +++ b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx @@ -8,7 +8,7 @@ import { getBrokerOperationTypeLabel, } from '@/entities/broker-operation' import { getBrokerInstrumentPath } from '@/entities/broker-position' -import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api/responses' +import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api' import { formatBrokerSignedMoney } from '@/shared/lib/formatters' import { TableSkeleton } from '@/shared/ui/TableSkeleton' diff --git a/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx b/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx index fa93d3a..101a91f 100644 --- a/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx +++ b/apps/frontend/src/widgets/broker-overview/ui/BrokerAssetCards.tsx @@ -1,7 +1,7 @@ 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/responses' +import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' import { formatBrokerMoney, pluralize } from '@/shared/lib/formatters' function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { diff --git a/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx b/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx index 8cfbbee..abfe56d 100644 --- a/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx +++ b/apps/frontend/src/widgets/broker-overview/ui/BrokerSummary.tsx @@ -1,6 +1,6 @@ import { Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' -import type { BrokerPortfolio } from '@/shared/api/responses' +import type { BrokerPortfolio } from '@/shared/api' import { formatBrokerMoney as formatMoney, formatBrokerPercent as formatPercent, 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 e2220e1..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,6 +1,6 @@ import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' -import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses' +import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api' import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters' import { PositionTicker } from './PositionTicker' 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 af3dd0c..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,7 +1,7 @@ import { Box } from '@mui/material' import { Link } from '@tanstack/react-router' import { getBrokerInstrumentPath } from '@/entities/broker-position' -import type { BrokerPosition } from '@/shared/api/responses' +import type { BrokerPosition } from '@/shared/api' export function PositionTicker({ position }: { position: BrokerPosition }) { const label = position.ticker || position.figi || '-' diff --git a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx index 581754b..bfb4194 100644 --- a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx +++ b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx @@ -1,4 +1,4 @@ -import type { DividendItem } from '@/shared/api/responses' +import type { DividendItem } from '@/shared/api' interface DividendsTableProps { dividends: DividendItem[] diff --git a/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx index 50f9fc2..e6d1358 100644 --- a/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx +++ b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx @@ -1,4 +1,4 @@ -import type { PortfolioSummary } from '@/shared/api/responses' +import type { PortfolioSummary } from '@/shared/api' export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) { const formatRub = (val: number | null) => diff --git a/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx b/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx index 380a4f1..fdae5c1 100644 --- a/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx +++ b/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx @@ -1,5 +1,5 @@ import { Link } from '@tanstack/react-router' -import type { Portfolio } from '@/shared/api/responses' +import type { Portfolio } from '@/shared/api' import { pluralize } from '@/shared/lib/formatters' export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { diff --git a/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx index d960283..73724eb 100644 --- a/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx +++ b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { Portfolio } from '@/shared/api/responses' +import type { Portfolio } from '@/shared/api' interface Props { initial?: Portfolio diff --git a/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx b/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx index f3e166b..c614455 100644 --- a/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx +++ b/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx @@ -1,4 +1,4 @@ -import type { PositionWithPrice } from '@/shared/api/responses' +import type { PositionWithPrice } from '@/shared/api' interface AllocationChartProps { positions: PositionWithPrice[] diff --git a/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx b/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx index 609bb36..dfda404 100644 --- a/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx +++ b/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx @@ -1,4 +1,4 @@ -import type { PortfolioDetail } from '@/shared/api/responses' +import type { PortfolioDetail } from '@/shared/api' import { AllocationChart } from './AllocationChart' export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) { 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 60b703f..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,6 +1,6 @@ import { Link } from '@tanstack/react-router' import { useState } from 'react' -import type { PositionWithPrice } from '@/shared/api/responses' +import type { PositionWithPrice } from '@/shared/api' interface Props { position: PositionWithPrice 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 291429c..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,4 +1,4 @@ -import type { PositionWithPrice } from '@/shared/api/responses' +import type { PositionWithPrice } from '@/shared/api' import { SharePositionRow } from './SharePositionRow' interface Props { diff --git a/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx b/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx index 0c956c0..75efe52 100644 --- a/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx +++ b/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx @@ -1,4 +1,4 @@ -import type { ShareResponse } from '@/shared/api/responses' +import type { ShareResponse } from '@/shared/api' interface StockDetailsProps { stock: ShareResponse diff --git a/docs/features/frontend-infrastructure-tooling/plan.md b/docs/features/frontend-infrastructure-tooling/plan.md index f9fc584..f903639 100644 --- a/docs/features/frontend-infrastructure-tooling/plan.md +++ b/docs/features/frontend-infrastructure-tooling/plan.md @@ -47,10 +47,14 @@ ### Phase 3 — Unify API Types *Низкий риск* -1. Убедиться, что все entity API импортируют из `types.ts` (codegen) +> **Фактический подход: ре-экспорты в 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. Перенести normalizeEnvelope (ky-версию) в `shared/api/kyClient.ts` -4. Прогнать `npm run build` +3. Переключить все импорты с `@/shared/api/responses` на `@/shared/api` +4. Перенести normalizeEnvelope (ky-версию) в `shared/api/kyClient.ts` +5. Прогнать `npm run build` ### Phase 4 — MSW Browser *Низкий риск, handlers готовы* diff --git a/docs/features/frontend-infrastructure-tooling/tasks.md b/docs/features/frontend-infrastructure-tooling/tasks.md index 7ba9b03..3b9d748 100644 --- a/docs/features/frontend-infrastructure-tooling/tasks.md +++ b/docs/features/frontend-infrastructure-tooling/tasks.md @@ -21,9 +21,9 @@ ## Phase 2: Biome Migration -- [ ] Research: проверить Biome plugin system на поддержку FSD/import-no-restricted-paths +- [x] Research: проверить Biome plugin system на поддержку FSD/import-no-restricted-paths — не поддерживается, ESLint оставлен для FSD - [x] Установить `@biomejs/biome` (devDependency) -- [ ] Запустить `npx @biomejs/biome migrate eslint --write` +- [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-правил) @@ -37,11 +37,11 @@ ## Phase 3: Unify API Types -- [ ] Аудит импортов: entity API используют `types.ts` (codegen) или `responses.ts`? -- [ ] Переключить все импорты с `responses.ts` на `types.ts` (если типы есть в codegen) -- [ ] Удалить `shared/api/responses.ts` (после переключения) +- [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` -- [ ] `npm run build` — сборка проходит +- [x] `npm run build` — сборка проходит ## Phase 4: MSW Browser -- 2.47.2 From aea74bda3bd2f87d9a1b292da41a36da3ef7cd42 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 20:27:50 +0300 Subject: [PATCH 10/13] fix: sync SessionProvider auth state to Zustand store for router guard requireAuth() in routeTree.tsx reads isAuthenticated from useSessionStore (Zustand), but SessionProvider only managed state via React context. After login, the Zustand store stayed false, causing protected route guards to redirect to /login even when authenticated. --- apps/frontend/src/app/providers/SessionProvider.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/app/providers/SessionProvider.tsx b/apps/frontend/src/app/providers/SessionProvider.tsx index a4cbd2c..725c894 100644 --- a/apps/frontend/src/app/providers/SessionProvider.tsx +++ b/apps/frontend/src/app/providers/SessionProvider.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useCallback, useEffect, useState } from 'react' import * as sessionApi from '@/entities/session' -import { SessionContext, type SessionContextValue } from '@/entities/session' +import { SessionContext, type SessionContextValue, useSessionStore } from '@/entities/session' import { getAccessToken, handleUnauthorized, @@ -18,11 +18,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => { setUser(authData.user) setAccessTokenState(authData.accessToken) + useSessionStore.getState().setSession(authData) }, []) const clearSession = useCallback(() => { setUser(null) setAccessTokenState(null) + useSessionStore.getState().clearSession() }, []) const login = useCallback( -- 2.47.2 From 76cffc061dce3099f9b19ae54c7cdb141d248f12 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 20:41:23 +0300 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20provide=20portfolio=20via=20Broker?= =?UTF-8?q?AccountContext,=20fix=20=D0=9E=D0=B1=D0=B7=D0=BE=D1=80=20link?= =?UTF-8?q?=20adding=20trailing=20dot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/broker-account-layout/index.ts | 2 +- .../lib/useBrokerAccountContext.ts | 10 +- .../ui/BrokerAccountLayout.tsx | 102 ++++++++++-------- 3 files changed, 65 insertions(+), 49 deletions(-) diff --git a/apps/frontend/src/widgets/broker-account-layout/index.ts b/apps/frontend/src/widgets/broker-account-layout/index.ts index 1c833dc..004ed7c 100644 --- a/apps/frontend/src/widgets/broker-account-layout/index.ts +++ b/apps/frontend/src/widgets/broker-account-layout/index.ts @@ -1,3 +1,3 @@ export { useBrokerAccountContext } from './lib/useBrokerAccountContext' -export type { BrokerAccountContext } from './ui/BrokerAccountLayout' +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 index 680a26c..2df2875 100644 --- a/apps/frontend/src/widgets/broker-account-layout/lib/useBrokerAccountContext.ts +++ b/apps/frontend/src/widgets/broker-account-layout/lib/useBrokerAccountContext.ts @@ -1,6 +1,10 @@ -import { useParams } from '@tanstack/react-router' +import { useContext } from 'react' +import { BrokerAccountContext } from '../ui/BrokerAccountLayout' export function useBrokerAccountContext() { - const { accountId = '' } = useParams({ from: '/broker/$accountId' }) - return { accountId } + 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 88a7b65..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,8 +1,10 @@ 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 type { ReactNode } from 'react' +import { createContext, type ReactNode } from 'react' import { useBrokerPortfolio } from '@/entities/broker-account' +import type { BrokerPortfolio } from '@/shared/api' const baseLinkStyle: React.CSSProperties = { padding: '10px 12px', @@ -17,69 +19,79 @@ const baseLinkStyle: React.CSSProperties = { } const links = [ - { to: '.', label: 'Обзор' }, + { to: '', label: 'Обзор' }, { to: '/shares', label: 'Акции' }, { to: '/bonds', label: 'Облигации' }, { to: '/operations', label: 'Операции' }, { to: '/events', label: 'События' }, ] +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} + {children} + - + ) } -- 2.47.2 From b26021016cce9e01d5b918a1738880f009eb3313 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 21:22:37 +0300 Subject: [PATCH 12/13] fix(frontend): clean up mock setup and route params --- .../{src/shared/lib/test => mocks}/browser.ts | 0 apps/frontend/mocks/data/auth.ts | 11 + apps/frontend/mocks/data/bonds.ts | 40 ++++ apps/frontend/mocks/data/broker.ts | 189 +++++++++++++++++ apps/frontend/mocks/data/candles.ts | 22 ++ apps/frontend/mocks/data/index.ts | 15 ++ apps/frontend/mocks/data/portfolios.ts | 138 +++++++++++++ apps/frontend/mocks/data/screener.ts | 38 ++++ apps/frontend/mocks/data/search.ts | 20 ++ apps/frontend/mocks/data/shares.ts | 50 +++++ apps/frontend/mocks/handlers.ts | 151 ++++++++++++++ .../{src/shared/lib/test => mocks}/server.ts | 0 apps/frontend/mocks/utils.ts | 5 + .../app/providers/SessionProvider.test.tsx | 2 +- .../bond/model/useBondCandles.test.tsx | 2 +- .../entities/search/model/useSearch.test.tsx | 2 +- .../entities/session/api/sessionApi.test.ts | 2 +- .../stock/model/useStockCandles.test.tsx | 2 +- .../stock/model/useStockDividends.test.tsx | 2 +- apps/frontend/src/main.tsx | 2 +- .../src/pages/login/LoginPage.test.tsx | 2 +- .../portfolios/ui/PortfolioDetailPage.tsx | 2 +- .../src/pages/profile/ProfilePage.test.tsx | 2 +- .../src/pages/register/RegisterPage.test.tsx | 2 +- .../frontend/src/pages/stock/ui/StockPage.tsx | 2 +- apps/frontend/src/shared/lib/test/handlers.ts | 190 ------------------ apps/frontend/src/shared/lib/test/setup.ts | 2 +- .../widgets/search-bar/ui/SearchBar.test.tsx | 2 +- apps/frontend/tsconfig.json | 5 +- apps/frontend/vite.config.ts | 7 +- apps/frontend/vitest.config.ts | 7 +- 31 files changed, 704 insertions(+), 212 deletions(-) rename apps/frontend/{src/shared/lib/test => mocks}/browser.ts (100%) create mode 100644 apps/frontend/mocks/data/auth.ts create mode 100644 apps/frontend/mocks/data/bonds.ts create mode 100644 apps/frontend/mocks/data/broker.ts create mode 100644 apps/frontend/mocks/data/candles.ts create mode 100644 apps/frontend/mocks/data/index.ts create mode 100644 apps/frontend/mocks/data/portfolios.ts create mode 100644 apps/frontend/mocks/data/screener.ts create mode 100644 apps/frontend/mocks/data/search.ts create mode 100644 apps/frontend/mocks/data/shares.ts create mode 100644 apps/frontend/mocks/handlers.ts rename apps/frontend/{src/shared/lib/test => mocks}/server.ts (100%) create mode 100644 apps/frontend/mocks/utils.ts delete mode 100644 apps/frontend/src/shared/lib/test/handlers.ts diff --git a/apps/frontend/src/shared/lib/test/browser.ts b/apps/frontend/mocks/browser.ts similarity index 100% rename from apps/frontend/src/shared/lib/test/browser.ts rename to apps/frontend/mocks/browser.ts 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/src/shared/lib/test/server.ts b/apps/frontend/mocks/server.ts similarity index 100% rename from apps/frontend/src/shared/lib/test/server.ts rename to apps/frontend/mocks/server.ts 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/src/app/providers/SessionProvider.test.tsx b/apps/frontend/src/app/providers/SessionProvider.test.tsx index 6f7c307..c29813e 100644 --- a/apps/frontend/src/app/providers/SessionProvider.test.tsx +++ b/apps/frontend/src/app/providers/SessionProvider.test.tsx @@ -1,3 +1,4 @@ +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' @@ -5,7 +6,6 @@ import { HttpResponse, http } from 'msw' import { useContext } from 'react' import { describe, expect, it } from 'vitest' import { SessionContext } from '@/entities/session' -import { server } from '@/shared/lib/test/server' import { SessionProvider } from './SessionProvider' const API = '/api/v1' diff --git a/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx b/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx index 1e1fa2f..b0aa23f 100644 --- a/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx +++ b/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx @@ -1,9 +1,9 @@ +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 { server } from '@/shared/lib/test/server' import { useBondCandles } from './useBondCandles' const API = '/api/v1' diff --git a/apps/frontend/src/entities/search/model/useSearch.test.tsx b/apps/frontend/src/entities/search/model/useSearch.test.tsx index a1c61e3..371f89d 100644 --- a/apps/frontend/src/entities/search/model/useSearch.test.tsx +++ b/apps/frontend/src/entities/search/model/useSearch.test.tsx @@ -1,10 +1,10 @@ +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' -import { server } from '@/shared/lib/test/server' const API = '/api/v1' diff --git a/apps/frontend/src/entities/session/api/sessionApi.test.ts b/apps/frontend/src/entities/session/api/sessionApi.test.ts index d0947cd..94d7f95 100644 --- a/apps/frontend/src/entities/session/api/sessionApi.test.ts +++ b/apps/frontend/src/entities/session/api/sessionApi.test.ts @@ -1,6 +1,6 @@ +import { server } from '@mocks/server' import { HttpResponse, http } from 'msw' import { beforeEach, describe, expect, it } from 'vitest' -import { server } from '@/shared/lib/test/server' import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi' import { getAccessToken, setAccessToken } from './tokenManager' diff --git a/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx b/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx index e771e24..9bb2cd3 100644 --- a/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx @@ -1,9 +1,9 @@ +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 { server } from '@/shared/lib/test/server' import { useStockCandles } from './useStockCandles' const API = '/api/v1' diff --git a/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx b/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx index 509f77e..7d8415c 100644 --- a/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx @@ -1,9 +1,9 @@ +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 { server } from '@/shared/lib/test/server' import { useStockDividends } from './useStockDividends' const API = '/api/v1' diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 9abcc46..78ab8cf 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -7,7 +7,7 @@ import { env } from './shared/config/env' async function startApp() { if (env.VITE_API_MOCK) { - const { worker } = await import('./shared/lib/test/browser') + const { worker } = await import('../mocks/browser') await worker.start({ onUnhandledRequest: 'bypass' }) } diff --git a/apps/frontend/src/pages/login/LoginPage.test.tsx b/apps/frontend/src/pages/login/LoginPage.test.tsx index a1271c6..84d7cb2 100644 --- a/apps/frontend/src/pages/login/LoginPage.test.tsx +++ b/apps/frontend/src/pages/login/LoginPage.test.tsx @@ -1,3 +1,4 @@ +import { server } from '@mocks/server' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createMemoryHistory, @@ -10,7 +11,6 @@ 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 { server } from '@/shared/lib/test/server' import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider' import { LoginPage } from './ui/LoginPage' diff --git a/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx b/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx index 7a87fa9..ee88d50 100644 --- a/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx +++ b/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx @@ -9,7 +9,7 @@ import { PortfolioSummary } from '@/widgets/portfolio-summary' import { SharePositionTable } from '@/widgets/share-positions-table' export function PortfolioDetailPage() { - const { id } = useParams<{ id: string }>() + const { id } = useParams({ from: '/portfolios/$id' }) const portfolioId = parseInt(id!, 10) const { data: portfolio, isLoading, error } = usePortfolio(portfolioId) diff --git a/apps/frontend/src/pages/profile/ProfilePage.test.tsx b/apps/frontend/src/pages/profile/ProfilePage.test.tsx index b6365a8..1c9323a 100644 --- a/apps/frontend/src/pages/profile/ProfilePage.test.tsx +++ b/apps/frontend/src/pages/profile/ProfilePage.test.tsx @@ -1,8 +1,8 @@ +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 { server } from '@/shared/lib/test/server' import { renderWithProviders } from '@/shared/lib/test/test-utils' import { ProfilePage } from './ui/ProfilePage' diff --git a/apps/frontend/src/pages/register/RegisterPage.test.tsx b/apps/frontend/src/pages/register/RegisterPage.test.tsx index edeb893..c5126b8 100644 --- a/apps/frontend/src/pages/register/RegisterPage.test.tsx +++ b/apps/frontend/src/pages/register/RegisterPage.test.tsx @@ -1,3 +1,4 @@ +import { server } from '@mocks/server' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createMemoryHistory, @@ -10,7 +11,6 @@ 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 { server } from '@/shared/lib/test/server' import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider' import { RegisterPage } from './ui/RegisterPage' diff --git a/apps/frontend/src/pages/stock/ui/StockPage.tsx b/apps/frontend/src/pages/stock/ui/StockPage.tsx index eab1f85..1ee32db 100644 --- a/apps/frontend/src/pages/stock/ui/StockPage.tsx +++ b/apps/frontend/src/pages/stock/ui/StockPage.tsx @@ -5,7 +5,7 @@ import { PriceChart } from '@/widgets/price-chart' import { StockDetails } from '@/widgets/stock-details' export function StockPage() { - const { secid } = useParams<{ secid: string }>() + 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] 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 029a4d4..0000000 --- a/apps/frontend/src/shared/lib/test/handlers.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { HttpResponse, http } from 'msw' -import type { BondResponse, CandleItem, SearchResultItem, ShareResponse } from '@/shared/api' - -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/setup.ts b/apps/frontend/src/shared/lib/test/setup.ts index f47cb6d..d36df91 100644 --- a/apps/frontend/src/shared/lib/test/setup.ts +++ b/apps/frontend/src/shared/lib/test/setup.ts @@ -1,5 +1,5 @@ import '@testing-library/jest-dom' -import { server } from './server' +import { server } from '@mocks/server' beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterEach(() => server.resetHandlers()) 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 93d4e22..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,10 +1,10 @@ +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 { server } from '@/shared/lib/test/server' import { SearchBar } from '@/widgets/search-bar' const API = '/api/v1' 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', -- 2.47.2 From a13b5145f70c8bf3c0ef0173447625d781b0dbd3 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 23 Jun 2026 21:30:11 +0300 Subject: [PATCH 13/13] docs(frontend): align tooling spec with implementation --- .../features/frontend-infrastructure-tooling/plan.md | 2 +- .../features/frontend-infrastructure-tooling/spec.md | 4 ++-- .../frontend-infrastructure-tooling/tasks.md | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/features/frontend-infrastructure-tooling/plan.md b/docs/features/frontend-infrastructure-tooling/plan.md index f903639..607f8f6 100644 --- a/docs/features/frontend-infrastructure-tooling/plan.md +++ b/docs/features/frontend-infrastructure-tooling/plan.md @@ -59,7 +59,7 @@ ### Phase 4 — MSW Browser *Низкий риск, handlers готовы* -1. Создать `shared/lib/test/browser.ts` (setupWorker) +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` по условию diff --git a/docs/features/frontend-infrastructure-tooling/spec.md b/docs/features/frontend-infrastructure-tooling/spec.md index b32bb8e..9079ab1 100644 --- a/docs/features/frontend-infrastructure-tooling/spec.md +++ b/docs/features/frontend-infrastructure-tooling/spec.md @@ -10,9 +10,9 @@ Фронтенд должен иметь возможность запускаться без бэкенда при `VITE_API_MOCK=true`. -- Создать browser entry для MSW (shared/lib/test/browser.ts с setupWorker) +- Создать browser entry для MSW (apps/frontend/mocks/browser.ts с setupWorker) - Развернуть mockServiceWorker.js в public/ -- Переиспользовать существующие MSW handlers (shared/lib/test/handlers.ts) — не дублировать +- Переиспользовать существующие MSW handlers (apps/frontend/mocks/handlers.ts) — не дублировать - При VITE_API_MOCK=true в dev-режиме все API-запросы перехватываются MSW - При VITE_API_MOCK=false или отсутствии — поведение не меняется (запросы идут в реальный API) diff --git a/docs/features/frontend-infrastructure-tooling/tasks.md b/docs/features/frontend-infrastructure-tooling/tasks.md index 3b9d748..212ff54 100644 --- a/docs/features/frontend-infrastructure-tooling/tasks.md +++ b/docs/features/frontend-infrastructure-tooling/tasks.md @@ -45,18 +45,18 @@ ## Phase 4: MSW Browser -- [x] Создать `shared/lib/test/browser.ts` (setupWorker из 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()` -- [ ] Проверить: `VITE_API_MOCK=true npm run dev` без бэкенда — приложение работает -- [ ] Проверить: `VITE_API_MOCK=false npm run dev` — запросы идут на бэкенд +- [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 @@ -99,9 +99,9 @@ ### Tests - [x] Заменить `MemoryRouter` в тестах на `createMemoryHistory` + `RouterProvider` - [x] Обновить тестовые утилиты (`test-utils.tsx`) -- [x] `npm run test` — все тесты проходят (125) +- [x] `npm run test` — все тесты проходят (116) - [x] `npm run build` — сборка проходит ### Devtools - [x] Настроить `@tanstack/router-devtools` в dev-режиме -- [ ] Проверить навигацию по всем страницам вручную — отложено +- [x] Проверить навигацию по всем страницам вручную -- 2.47.2