From b7b627a0f391f32421ed8806b4bddda7f369eb1e Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 19 Jun 2026 13:55:35 +0300 Subject: [PATCH 1/3] docs: define broker accounts overview --- ...012-frontend-broker-account-aggregation.md | 92 +++++++++++ apps/docs/docs/adr/index.md | 27 +-- docs/epics/BrokerPortfolio.md | 1 + .../features/broker-accounts-overview/spec.md | 155 ++++++++++++++++++ docs/roadmap.md | 1 + 5 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 apps/docs/docs/adr/ADR-012-frontend-broker-account-aggregation.md create mode 100644 docs/features/broker-accounts-overview/spec.md diff --git a/apps/docs/docs/adr/ADR-012-frontend-broker-account-aggregation.md b/apps/docs/docs/adr/ADR-012-frontend-broker-account-aggregation.md new file mode 100644 index 0000000..02027b7 --- /dev/null +++ b/apps/docs/docs/adr/ADR-012-frontend-broker-account-aggregation.md @@ -0,0 +1,92 @@ +# ADR-012: Сводка брокерских счетов агрегируется на frontend + +**Статус:** Accepted + +**Дата:** 2026-06-19 + +## Контекст + +Страница списка брокерских счетов должна показывать общую стоимость, дневной результат, свободные +деньги и распределение активов, а также подробные показатели каждого счёта. Endpoint +`GET /api/v1/broker/accounts` возвращает только метаданные счетов. Все необходимые финансовые данные +уже доступны через `GET /api/v1/broker/accounts/:accountId/portfolio`. + +Текущий продуктовый сценарий предполагает один–три открытых счёта. Backend кеширует ответы портфеля +и ограничивает запросы к T-Bank через общую очередь. + +## Рассмотренные варианты + +### 1. Агрегация существующих ответов на frontend + +Frontend получает список счетов, параллельно запрашивает портфель каждого счёта и строит общую +сводку из успешно загруженных ответов. + +Преимущества: + +- не появляется новый API-контракт с данными, уже доступными в portfolio endpoint; +- каждый счёт может загружаться и восстанавливаться после ошибки независимо; +- при одном–трёх счетах число запросов остаётся ограниченным; +- используются существующие backend cache и rate limiting. + +Недостатки: + +- страница выполняет `1 + N` HTTP-запросов; +- ответы могут иметь разные значения `asOf` и не образуют атомарный снимок; +- правила презентационной агрегации находятся на frontend. + +### 2. Новый aggregate endpoint + +Добавить endpoint, который возвращает список счетов, их портфели и общую сводку одним ответом. + +Преимущества: + +- один HTTP-запрос с frontend; +- единый серверный контракт агрегации; +- проще переиспользовать сводку другими клиентами. + +Недостатки: + +- новый контракт дублирует существующие portfolio-данные; +- нужно определить семантику частичных ошибок и кеширования составного ответа; +- endpoint всё равно получает портфели нескольких счетов и не гарантирует атомарность данных T-Bank. + +### 3. Расширение endpoint списка счетов + +Добавить финансовую сводку каждого счёта в `GET /api/v1/broker/accounts`. + +Преимущества: + +- frontend получает всё одним запросом; +- карточки становятся простыми потребителями ответа. + +Недостатки: + +- лёгкий endpoint метаданных превращается в дорогой составной запрос; +- меняются его кеширование, время ответа и семантика ошибок; +- потребители списка счетов вынужденно получают финансовые данные. + +## Решение + +Использовать вариант 1: агрегировать существующие portfolio-ответы на frontend. + +Frontend параллельно запрашивает портфели после получения списка счетов. Общая сводка строится только +из успешно загруженных ответов и явно помечается как частичная, если часть счетов недоступна. Ошибка +одного портфеля не блокирует остальные карточки. + +Денежные значения группируются по валюте. Frontend не выполняет неявную валютную конвертацию и не +складывает несопоставимые суммы. Значения `asOf` отдельных ответов сохраняют свой смысл; сводка не +считается транзакционно согласованным снимком. + +## Последствия + +- Страница выполняет один запрос списка и до трёх параллельных запросов портфелей. +- Backend остаётся источником финансовых данных отдельного счёта, а frontend владеет только их + представлением и агрегацией для этой страницы. +- Частичные ошибки становятся штатным состоянием интерфейса и должны быть покрыты тестами. +- API, OpenAPI и frontend codegen для этой фичи не меняются. +- Решение следует пересмотреть, если выполняется хотя бы одно условие: + - продукт поддерживает более трёх одновременно отображаемых счетов; + - измерения показывают неприемлемую задержку или нагрузку от `1 + N` запросов; + - нескольким клиентам нужна одинаковая серверная сводка; + - появляется требование к согласованному серверному снимку или серверной валютной конвертации; + - правила агрегации становятся самостоятельной бизнес-логикой, а не логикой представления. diff --git a/apps/docs/docs/adr/index.md b/apps/docs/docs/adr/index.md index 58d9185..f2921a9 100644 --- a/apps/docs/docs/adr/index.md +++ b/apps/docs/docs/adr/index.md @@ -1,17 +1,18 @@ # Архитектурные решения (ADR) -| ADR | Статус | Описание | -|---|---|---| -| [ADR-001](ADR-001-backend-single-point-of-access) | Accepted | Backend — единственная точка доступа к MOEX | -| [ADR-002](ADR-002-in-memory-cache) | Accepted | Стратегия in-memory cache | -| [ADR-003](ADR-003-rate-limiting-strategy) | Accepted | Стратегия rate limiting | -| [ADR-004](ADR-004-feature-modules) | Accepted | Архитектура feature-модулей | -| [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI codegen для frontend | -| [ADR-006](ADR-006-no-cci) | Deprecated | CCI вынесен за пределы MVP | -| [ADR-007](ADR-007-two-level-caching) | Draft | Двухуровневый cache: backend + frontend | -| [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization | -| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля | -| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend | -| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC | +| ADR | Статус | Описание | +| ------------------------------------------------------ | ---------- | ---------------------------------------------- | +| [ADR-001](ADR-001-backend-single-point-of-access) | Accepted | Backend — единственная точка доступа к MOEX | +| [ADR-002](ADR-002-in-memory-cache) | Accepted | Стратегия in-memory cache | +| [ADR-003](ADR-003-rate-limiting-strategy) | Accepted | Стратегия rate limiting | +| [ADR-004](ADR-004-feature-modules) | Accepted | Архитектура feature-модулей | +| [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI codegen для frontend | +| [ADR-006](ADR-006-no-cci) | Deprecated | CCI вынесен за пределы MVP | +| [ADR-007](ADR-007-two-level-caching) | Draft | Двухуровневый cache: backend + frontend | +| [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization | +| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля | +| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend | +| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC | +| [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend | Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе. diff --git a/docs/epics/BrokerPortfolio.md b/docs/epics/BrokerPortfolio.md index 62078e0..ba3fff0 100644 --- a/docs/epics/BrokerPortfolio.md +++ b/docs/epics/BrokerPortfolio.md @@ -27,6 +27,7 @@ - [T-Bank broker portfolios](../features/tbank-broker-portfolios/spec.md) - [Отображение брокерского портфеля](../features/broker-portfolio-display/spec.md) - [x] [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — реализовано +- [Информативный обзор брокерских счетов](../features/broker-accounts-overview/spec.md) - [Улучшение UI операций](../features/broker-operations-ui-improvements/spec.md) - [Пагинация и загрузка позиций](../features/broker-positions-pagination-and-loading/spec.md) - [Исправление deadline и очереди T-Bank](../features/tbank-deadline-queue-fix/spec.md) diff --git a/docs/features/broker-accounts-overview/spec.md b/docs/features/broker-accounts-overview/spec.md new file mode 100644 index 0000000..67ffe0a --- /dev/null +++ b/docs/features/broker-accounts-overview/spec.md @@ -0,0 +1,155 @@ +# Информативный обзор брокерских счетов + +Дата: 2026-06-19 +Статус: согласовано к планированию +Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) + +## Контекст + +Страница `/broker` показывает открытые брокерские счета и ИИС карточками, но полезная информация в +них ограничена названием, типом, техническим статусом и идентификатором. Пользователь не может на +одном экране оценить совокупный капитал, сравнить счета и понять структуру активов. + +Страница предназначена преимущественно для одного–трёх счетов. + +## Цель + +Превратить список брокерских счетов в компактный финансовый обзор, который с первого взгляда +отвечает на вопросы: + +- сколько средств находится на всех доступных счетах; +- как изменилась их стоимость за день; +- сколько свободных денег доступно; +- как капитал распределён между счетами и основными классами активов; +- какой счёт нужно открыть для подробного анализа. + +## Область изменений + +Фича изменяет страницу списка брокерских счетов и включает: + +- общую сводку по успешно загруженным счетам; +- информативные карточки отдельных счетов; +- независимую загрузку данных каждого счёта; +- состояния загрузки, частичной ошибки и отсутствия счетов; +- адаптивное отображение на широких и узких экранах. + +Детальные страницы счёта, позиции, операции, торговые действия и правила расчёта показателей, +приходящих от T-Bank, не изменяются. + +## Требования + +### 1. Общая сводка + +Над списком счетов отображается сводка по портфелям, данные которых успешно загружены: + +- количество доступных счетов; +- совокупная стоимость портфелей; +- совокупный дневной результат в деньгах и процентах; +- свободные деньги; +- распределение стоимости по акциям, облигациям, фондам, деньгам и прочим активам. + +Денежные значения разных валют не складываются и показываются отдельными суммами. Процентный +дневной результат и единое распределение активов показываются только для сопоставимых денежных +значений одной валюты. Интерфейс не выполняет неявную конвертацию валют. + +Для каждой валюты совокупный дневной процент рассчитывается как отношение суммы дневных изменений +к суммарной стоимости портфелей на начало дня: + +```text +sum(daily) / (sum(portfolio) - sum(daily)) * 100 +``` + +Процент не показывается, если хотя бы у одного включённого в валютную группу счёта отсутствует +стоимость или дневное изменение либо если стоимость на начало дня неположительна. + +Если загружены не все счета, сводка явно сообщает, по скольким счетам рассчитаны показатели. +Недоступный счёт не включается в агрегированные значения. + +### 2. Карточка счёта + +Для каждого счёта показываются: + +- название; +- понятный тип: `Брокерский счёт` или `ИИС`; +- дата открытия, если она доступна; +- текущая стоимость; +- дневной результат в деньгах и процентах; +- ожидаемая доходность; +- распределение стоимости по основным классам активов. + +Технический идентификатор, сырой enum статуса и сырой enum уровня доступа в карточке не +отображаются. + +Вся карточка является доступной с клавиатуры ссылкой на обзор выбранного счёта. Цвет доходности +используется только как дополнительный признак: знак и числовое значение остаются видимыми. + +### 3. Загрузка + +После получения списка счетов данные их портфелей загружаются независимо. До появления значений +общая сводка и карточки показывают skeleton-состояние с устойчивыми размерами, чтобы содержимое не +скакало при загрузке. + +Появление данных одного счёта не должно ждать завершения остальных запросов. + +### 4. Ошибки + +Ошибка загрузки списка счетов показывает ошибку всей страницы. + +Ошибка загрузки портфеля отдельного счёта: + +- не скрывает другие счета; +- не блокирует переход в доступные счета; +- показывает локальное сообщение в карточке проблемного счёта; +- позволяет повторить загрузку этого счёта; +- исключает этот счёт из общей сводки и маркирует сводку как частичную. + +### 5. Пустое состояние + +Если открытых брокерских счетов и ИИС нет, страница показывает отдельное пустое состояние вместо +пустой сетки карточек. Текст объясняет, какие счета появятся на странице после подключения T-Bank. + +### 6. Адаптивность + +При одном–трёх счетах карточки располагаются вертикально и сохраняют единый порядок показателей для +быстрого сравнения. + +На узком экране показатели переносятся на несколько строк, диаграмма распределения остаётся +читаемой, а интерактивные области не требуют горизонтальной прокрутки. + +## Ограничения + +- Страница остаётся read-only. +- Frontend не обращается к T-Bank напрямую. +- Агрегация не пересчитывает показатели отдельного счёта: исходные стоимости и доходность берутся + из существующих ответов портфеля. Единственный новый производный показатель — совокупный дневной + процент по явно заданной в этой спецификации формуле. +- Данные разных счетов могут иметь разные моменты `asOf`; интерфейс не представляет сводку как + транзакционно согласованный снимок. +- Решение рассчитано на один–три счёта. Расширение этого предположения требует пересмотра + [ADR-012](../../../apps/docs/docs/adr/ADR-012-frontend-broker-account-aggregation.md). + +## Acceptance Criteria + +- Над счетами показана общая стоимость, дневной результат, свободные деньги, число счетов и + распределение активов. +- Значения разных валют не складываются без курса конвертации. +- Каждая успешно загруженная карточка показывает стоимость, доходность, тип и распределение активов. +- Название и дата открытия показываются, когда доступны. +- Технический ID и сырые T-Bank enum-значения не отображаются. +- Вся карточка ведёт на обзор соответствующего счёта и доступна с клавиатуры. +- Во время загрузки отображаются skeleton-состояния без заметного изменения геометрии страницы. +- Ошибка одного портфеля не скрывает остальные счета и не включает недоступные данные в сводку. +- Пользователь может повторить запрос проблемного счёта. +- При отсутствии счетов отображается объясняющее пустое состояние. +- Страница не требует горизонтальной прокрутки на поддерживаемых мобильных ширинах. +- Unit-тесты покрывают агрегацию, валютные ограничения и расчёт распределения. +- Component-тесты покрывают успешную загрузку, skeleton, пустое состояние, частичную ошибку, + повторный запрос и переход в счёт. + +## Не цели + +- Добавление aggregate endpoint на backend. +- Конвертация валют и получение валютных курсов. +- Поддержка более трёх счетов как отдельного плотного режима. +- Изменение детальной страницы брокерского счёта. +- Изменение T-Bank DTO, OpenAPI-контракта или сгенерированных frontend-типов. diff --git a/docs/roadmap.md b/docs/roadmap.md index b621c47..16f19db 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,6 +11,7 @@ Roadmap отражает порядок продуктовой работы, н Цель: сделать реальные брокерские счета понятными на уровне обзора, позиций и операций. - [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано. +- [ ] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — согласовано к планированию. ## Следующие этапы для активной фичи -- 2.47.2 From 706bb12b6c910a4d8f3c7771d6de101e7a76796c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 19 Jun 2026 14:01:57 +0300 Subject: [PATCH 2/3] docs: plan broker accounts overview --- .../features/broker-accounts-overview/plan.md | 439 ++++++++++++++++++ .../broker-accounts-overview/tasks.md | 40 ++ 2 files changed, 479 insertions(+) create mode 100644 docs/features/broker-accounts-overview/plan.md create mode 100644 docs/features/broker-accounts-overview/tasks.md diff --git a/docs/features/broker-accounts-overview/plan.md b/docs/features/broker-accounts-overview/plan.md new file mode 100644 index 0000000..ce931e9 --- /dev/null +++ b/docs/features/broker-accounts-overview/plan.md @@ -0,0 +1,439 @@ +# Broker Accounts Overview Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Превратить `/broker` в информативный обзор одного–трёх брокерских счетов с общей сводкой, независимой загрузкой карточек и устойчивыми состояниями ошибок. + +**Architecture:** `BrokerAccountsPage` получает список счетов и передаёт его в новый hook на основе TanStack Query `useQueries`; query key совпадает с `useBrokerPortfolio`, поэтому детальная страница переиспользует кеш. Чистый модуль агрегации группирует денежные значения по валютам и рассчитывает единственный новый финансовый показатель по формуле из spec. UI разбит на общую сводку, карточку счёта и компактную полосу распределения; backend и OpenAPI не меняются согласно ADR-012. + +**Tech Stack:** React 18, TypeScript, TanStack Query v5, React Router v6, Vitest, Testing Library, CSS custom properties. + +--- + +## Карта файлов + +- Create `apps/frontend/src/pages/broker/brokerAccountsOverview.ts` — чистые типы, агрегация валютных сумм и форматирование дат/денег/процентов. +- Create `apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts` — unit-тесты финансовой агрегации и edge cases. +- Create `apps/frontend/src/hooks/useBrokerAccountPortfolios.ts` — параллельные portfolio queries с общими query keys. +- Create `apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx` — проверка независимых query-состояний и кеша. +- Create `apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx` — общая сводка и частичное состояние. +- Create `apps/frontend/src/pages/broker/BrokerAccountCard.tsx` — успешная карточка, skeleton и локальная ошибка с retry. +- Create `apps/frontend/src/pages/broker/BrokerAllocationBar.tsx` — доступная горизонтальная полоса распределения. +- Create `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx` — page/component tests. +- Modify `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` — orchestration новой страницы. +- Modify `apps/frontend/src/pages/broker/BrokerPages.test.tsx` — удалить старый поверхностный тест списка счетов. +- Modify `apps/frontend/src/styles.css` — визуальная система overview, focus/hover, skeleton и responsive rules. +- Modify `docs/features/broker-accounts-overview/tasks.md` — отмечать выполненные задачи. +- Modify `docs/roadmap.md` — отметить фичу реализованной только после всех проверок. + +### Task 1: Чистая модель агрегации + +**Files:** + +- Create: `apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts` +- Create: `apps/frontend/src/pages/broker/brokerAccountsOverview.ts` + +- [ ] **Step 1: Написать падающие unit-тесты** + +Покрыть одним набором тестов: + +```ts +import { describe, expect, it } from 'vitest'; +import type { BrokerPortfolio } from '../../api/responses'; +import { aggregateBrokerAccounts } from './brokerAccountsOverview'; + +function portfolio( + id: string, + currency: string, + total: number, + daily: number | null, + cash: number, +): BrokerPortfolio { + return { + account: { + id, + type: 'brokerage', + name: id, + status: 'ACCOUNT_STATUS_OPEN', + openedAt: '2022-06-16T00:00:00.000Z', + accessLevel: null, + }, + positionCounts: { shares: 1, bonds: 1, etf: 0, other: 0 }, + totals: { + shares: { currency, units: '0', nano: 0, value: total * 0.5 }, + bonds: { currency, units: '0', nano: 0, value: total * 0.3 }, + etf: null, + currencies: { currency, units: '0', nano: 0, value: total * 0.2 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency, units: '0', nano: 0, value: total }, + }, + yields: { + expectedPercent: 10, + daily: daily === null ? null : { currency, units: '0', nano: 0, value: daily }, + dailyPercent: null, + }, + cash: [{ currency, units: '0', nano: 0, value: cash }], + blockedCash: [], + asOf: '2026-06-19T10:00:00.000Z', + }; +} + +describe('aggregateBrokerAccounts', () => { + it('sums comparable portfolios and uses the specified daily percent formula', () => { + const result = aggregateBrokerAccounts([ + portfolio('a', 'RUB', 1_100, 100, 200), + portfolio('b', 'RUB', 2_200, 200, 300), + ]); + + expect(result.portfolios).toEqual([ + expect.objectContaining({ + currency: 'RUB', + total: 3_300, + daily: 300, + dailyPercent: 10, + allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 }, + }), + ]); + 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 }); + }); +}); +``` + +- [ ] **Step 2: Запустить unit-тест и подтвердить RED** + +Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend` + +Expected: FAIL с ошибкой импорта `./brokerAccountsOverview`. + +- [ ] **Step 3: Реализовать минимальную чистую модель** + +Создать публичные типы `BrokerAccountsAggregate`, `BrokerCurrencyPortfolioSummary`, +`BrokerCurrencyCashSummary` и функцию: + +```ts +export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate; +``` + +Правила реализации: + +- пропускать портфель без `totals.portfolio` или без currency; +- группировать `totals.portfolio`, `yields.daily` и классы активов по currency портфеля; +- считать `other` как `max(0, portfolio - shares - bonds - etf - currencies)`; +- группировать `portfolio.cash` независимо по валюте; +- если хотя бы в одном портфеле валютной группы нет сопоставимого `yields.daily`, возвращать для + группы `daily: null` и `dailyPercent: null`; +- иначе считать `dailyPercent = daily / (total - daily) * 100`, только если знаменатель положителен; +- сортировать валютные группы по первому появлению во входном массиве, не по алфавиту. + +- [ ] **Step 4: Запустить unit-тест и подтвердить GREEN** + +Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend` + +Expected: PASS, 3 tests. + +- [ ] **Step 5: Добавить edge cases** + +Добавить тесты для неположительной стоимости начала дня, отрицательного residual `other`, пустого +массива, отсутствующего `totals.portfolio` и cash в нескольких валютах. Реализация должна возвращать +конечные числа и никогда не смешивать валюты. + +- [ ] **Step 6: Запустить unit-тесты и commit** + +Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend` + +Expected: PASS. + +```bash +git add apps/frontend/src/pages/broker/brokerAccountsOverview.ts apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts +git commit -m "feat: aggregate broker account summaries" +``` + +### Task 2: Независимые portfolio queries + +**Files:** + +- Create: `apps/frontend/src/hooks/useBrokerAccountPortfolios.ts` +- Create: `apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx` + +- [ ] **Step 1: Написать падающий hook-тест** + +Mock `getBrokerPortfolio`, отрендерить hook с двумя счетами и проверить, что вызываются `acc-1` и +`acc-2`, а результат сохраняет соответствие `account → query` независимо от порядка завершения +Promise. Второй тест должен создать `QueryClient`, заранее положить портфель в key +`['broker', 'portfolio', 'acc-1']` и подтвердить, что hook использует то же кешированное значение. + +- [ ] **Step 2: Запустить hook-тест и подтвердить RED** + +Run: `npx vitest run src/hooks/useBrokerAccountPortfolios.test.tsx -w apps/frontend` + +Expected: FAIL с ошибкой импорта `useBrokerAccountPortfolios`. + +- [ ] **Step 3: Реализовать hook через `useQueries`** + +```ts +import { useQueries } from '@tanstack/react-query'; +import { getBrokerPortfolio } from '../api/broker'; +import type { BrokerAccount, BrokerPortfolio } from '../api/responses'; + +export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { + const queries = useQueries({ + queries: accounts.map((account) => ({ + queryKey: ['broker', 'portfolio', account.id], + queryFn: async (): Promise => (await getBrokerPortfolio(account.id)).data, + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + })), + }); + + return accounts.map((account, index) => ({ account, query: queries[index] })); +} +``` + +- [ ] **Step 4: Запустить hook-тест и подтвердить GREEN** + +Run: `npx vitest run src/hooks/useBrokerAccountPortfolios.test.tsx -w apps/frontend` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/hooks/useBrokerAccountPortfolios.ts apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx +git commit -m "feat: load broker account portfolios in parallel" +``` + +### Task 3: Компоненты и состояния страницы + +**Files:** + +- Create: `apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx` +- Create: `apps/frontend/src/pages/broker/BrokerAllocationBar.tsx` +- Create: `apps/frontend/src/pages/broker/BrokerAccountCard.tsx` +- Create: `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx` +- Modify: `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` +- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` + +- [ ] **Step 1: Написать page/component tests до реализации** + +Mock `useBrokerAccounts` и `useBrokerAccountPortfolios`. Проверить отдельными тестами: + +- heading, агрегированную сумму, дневной результат и две карточки; +- подписи `Брокерский счёт` и `ИИС`, форматированную дату открытия и отсутствие ID/raw enum; +- href всей карточки `/broker/:encodedAccountId`; +- skeleton при загрузке списка; +- пустое состояние при `accounts: []`; +- частичную сводку `Доступно по 1 из 2 счетов`; +- локальный alert и кнопку `Повторить` для ошибочного query; +- вызов `query.refetch()` по кнопке retry; +- раздельное отображение RUB и USD без суммирования. + +Для денежных assertions использовать regexp с обычным и non-breaking space, например: + +```ts +expect(screen.getByText(/3[\s\u00a0]?300[\s\u00a0]?₽/)).toBeInTheDocument(); +``` + +- [ ] **Step 2: Запустить page-тест и подтвердить RED** + +Run: `npx vitest run src/pages/broker/BrokerAccountsPage.test.tsx -w apps/frontend` + +Expected: FAIL, потому что новые компоненты и состояния отсутствуют. + +- [ ] **Step 3: Реализовать `BrokerAllocationBar`** + +Компонент получает `BrokerAllocationItem[]`, строит сегменты с inline `width: percent%`, добавляет +`role="img"`, осмысленный `aria-label` со всеми долями и текстовую легенду. Нулевые сегменты не +рендерятся; цвет не является единственным способом различить классы. + +- [ ] **Step 4: Реализовать `BrokerAccountsSummary`** + +Компонент получает aggregate, `loadedCount` и `totalCount`. Он показывает: + +- статус `Совокупный капитал · N счетов` либо `Доступно по N из M счетов`; +- по одному блоку стоимости/дневного результата на валюту; +- свободные деньги отдельным списком валют; +- allocation bar только когда доступна одна portfolio currency; +- placeholder `—`, если ни один портфель ещё не загружен. + +- [ ] **Step 5: Реализовать `BrokerAccountCard`** + +Компонент получает `account` и query result. Три ветки должны иметь стабильную геометрию: + +```tsx +if (query.isPending) return ; +if (query.error || !query.data) { + return query.refetch()} />; +} +return ; +``` + +Успешная карточка — одна `Link` на `/broker/${encodeURIComponent(account.id)}`. Внутри показать +название, тип, дату открытия, стоимость, `yields.daily`, `dailyPercent`, `expectedPercent` и allocation +bar из существующего `buildBrokerAllocation(portfolio).sectors`. + +- [ ] **Step 6: Переписать orchestration `BrokerAccountsPage`** + +Страница должна: + +1. вызвать `useBrokerAccounts()`; +2. передать `accounts ?? []` в `useBrokerAccountPortfolios` без условного вызова hooks; +3. построить aggregate только из `query.data` успешных записей; +4. показать page error только при ошибке списка; +5. показать отдельное empty state для пустого списка; +6. отрендерить summary и вертикальный список карточек. + +- [ ] **Step 7: Удалить устаревший тест из `BrokerPages.test.tsx`** + +Удалить test case `renders broker and IIS accounts` и неиспользуемые imports `accountHook` и +`BrokerAccountsPage`; покрытие новой страницы живёт в `BrokerAccountsPage.test.tsx`. + +- [ ] **Step 8: Запустить component tests и подтвердить GREEN** + +Run: `npx vitest run src/pages/broker/BrokerAccountsPage.test.tsx src/pages/broker/BrokerPages.test.tsx -w apps/frontend` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerAccountsPage.tsx apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx apps/frontend/src/pages/broker/BrokerAccountCard.tsx apps/frontend/src/pages/broker/BrokerAllocationBar.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx +git commit -m "feat: add informative broker accounts overview" +``` + +### Task 4: Визуальная система и responsive QA + +**Files:** + +- Modify: `apps/frontend/src/styles.css` +- Modify: `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx` + +- [ ] **Step 1: Добавить семантические CSS-классы** + +Добавить блоки `.broker-accounts`, `__header`, `__summary`, `__summary-metrics`, `__list`, +`.broker-account-card`, `__topline`, `__value`, `__metrics`, `.broker-allocation-bar`, `__track`, +`__legend`, `__error` и `__empty`. + +Визуальное направление: + +- тёплый нейтральный фон страницы и глубокий зелёный summary без градиента; +- serif-акцент только для крупных денежных значений, основной текст наследует текущую гарнитуру; +- тонкие границы и мягкая тень карточек, без вложенных «карточек в карточке»; +- один заметный hover карточки: небольшой подъём и усиление тени; +- `:focus-visible` с контрастным outline; +- positive/negative цвета всегда сопровождаются знаком и текстом; +- `prefers-reduced-motion: reduce` отключает transform/transition. + +- [ ] **Step 2: Добавить responsive rules** + +При `max-width: 720px` summary metrics и card metrics переходят в одну колонку, легенда allocation +переносится, денежные значения уменьшаются через `clamp()`, а карточка и кнопка retry сохраняют +минимальную интерактивную высоту 44px. Горизонтальный overflow на `.broker-accounts` запрещён. + +- [ ] **Step 3: Запустить frontend проверки** + +Run: `npm run test -w apps/frontend` + +Expected: PASS. + +Run: `npm run lint -w apps/frontend` + +Expected: PASS без warnings. + +Run: `npm run build -w apps/frontend` + +Expected: успешный TypeScript и Vite build. + +- [ ] **Step 4: Проверить страницу в локальном браузере** + +Запустить frontend и backend по README. Проверить `/broker` при ширинах 1280px и 390px: + +- нет горизонтального overflow; +- summary визуально доминирует, но карточки остаются читаемыми; +- карточки и retry доступны с клавиатуры; +- loading не меняет геометрию страницы; +- partial error не скрывает успешные счета; +- названия, крупные суммы и allocation legend не перекрываются. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/styles.css apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx +git commit -m "style: polish broker accounts overview" +``` + +### Task 5: Финальная документация и Definition of Done + +**Files:** + +- Modify: `docs/features/broker-accounts-overview/tasks.md` +- Modify: `docs/roadmap.md` + +- [ ] **Step 1: Отметить выполненные tasks** + +Поставить `[x]` только после соответствующих commit и проверок. Не менять ADR-012: решение уже +Accepted и реализация ему соответствует. + +- [ ] **Step 2: Обновить roadmap** + +Изменить строку фичи на: + +```md +- [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано. +``` + +- [ ] **Step 3: Выполнить полный verification gate** + +Run: `npm run test -w apps/frontend && npm run lint -w apps/frontend && npm run build -w apps/frontend` + +Expected: все команды завершаются с exit code 0. + +Run: `npm run build -w apps/docs` + +Expected: Docusaurus build succeeds. + +- [ ] **Step 4: Провести code review** + +Использовать `superpowers:requesting-code-review`. Исправить замечания только после технической +проверки; при изменении поведения сначала синхронизировать spec/plan. + +- [ ] **Step 5: Финальный commit документации** + +```bash +git add docs/features/broker-accounts-overview/tasks.md docs/roadmap.md +git commit -m "docs: complete broker accounts overview" +``` + +## Definition of Done + +- Все Acceptance Criteria из `spec.md` имеют component или unit coverage. +- Frontend tests, lint и build проходят. +- Docusaurus build проходит. +- Desktop и mobile UI проверены в браузере. +- `tasks.md` и roadmap соответствуют реализации. +- Нет изменений backend, OpenAPI или `apps/frontend/src/api/types.ts`. +- Нет незавершённых маркеров или отложенных требований. diff --git a/docs/features/broker-accounts-overview/tasks.md b/docs/features/broker-accounts-overview/tasks.md new file mode 100644 index 0000000..3cbff55 --- /dev/null +++ b/docs/features/broker-accounts-overview/tasks.md @@ -0,0 +1,40 @@ +# Информативный обзор брокерских счетов — задачи + +Статус: готово к реализации + +Подробные шаги, команды и ожидаемые результаты находятся в [plan.md](plan.md). + +## 1. Агрегация + +- [ ] Добавить чистую валютно-безопасную агрегацию портфелей. +- [ ] Покрыть формулу дневного процента и edge cases unit-тестами. +- [ ] Не смешивать валюты и не выполнять неявную конвертацию. + +## 2. Загрузка данных + +- [ ] Добавить параллельные portfolio queries для списка счетов. +- [ ] Переиспользовать query keys детальной страницы. +- [ ] Проверить независимое завершение запросов и кеш. + +## 3. Интерфейс + +- [ ] Добавить общую сводку по успешно загруженным счетам. +- [ ] Добавить информативную карточку счёта и allocation bar. +- [ ] Добавить skeleton, empty state и локальную ошибку с retry. +- [ ] Убрать технический ID и сырые T-Bank enum-значения. +- [ ] Обеспечить keyboard navigation и текстовые признаки доходности. + +## 4. Визуальная проверка + +- [ ] Реализовать согласованное зелёно-нейтральное визуальное направление. +- [ ] Проверить desktop 1280px и mobile 390px без horizontal overflow. +- [ ] Проверить loading и partial-error states в браузере. + +## 5. Definition of Done + +- [ ] Frontend tests проходят. +- [ ] Frontend lint проходит. +- [ ] Frontend build проходит. +- [ ] Docusaurus build проходит. +- [ ] Code review завершён. +- [ ] Roadmap отмечает фичу реализованной. -- 2.47.2 From 1e007de5be42dd0184ba6be64b93a471f8a9a492 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 19 Jun 2026 14:31:53 +0300 Subject: [PATCH 3/3] feat: add broker accounts overview --- .../hooks/useBrokerAccountPortfolios.test.tsx | 135 +++++++ .../src/hooks/useBrokerAccountPortfolios.ts | 17 + .../src/pages/broker/BrokerAccountCard.tsx | 142 ++++++++ .../pages/broker/BrokerAccountsPage.test.tsx | 293 +++++++++++++++ .../src/pages/broker/BrokerAccountsPage.tsx | 191 ++++++---- .../pages/broker/BrokerAccountsSummary.tsx | 164 +++++++++ .../src/pages/broker/BrokerAllocationBar.tsx | 43 +++ .../src/pages/broker/BrokerPages.test.tsx | 33 -- .../broker/brokerAccountsOverview.test.ts | 127 +++++++ .../pages/broker/brokerAccountsOverview.ts | 198 ++++++++++ apps/frontend/src/styles.css | 341 +++++++++++++++++- .../broker-accounts-overview/tasks.md | 42 +-- docs/roadmap.md | 4 +- 13 files changed, 1591 insertions(+), 139 deletions(-) create mode 100644 apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx create mode 100644 apps/frontend/src/hooks/useBrokerAccountPortfolios.ts create mode 100644 apps/frontend/src/pages/broker/BrokerAccountCard.tsx create mode 100644 apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx create mode 100644 apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx create mode 100644 apps/frontend/src/pages/broker/BrokerAllocationBar.tsx create mode 100644 apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts create mode 100644 apps/frontend/src/pages/broker/brokerAccountsOverview.ts diff --git a/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx b/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx new file mode 100644 index 0000000..0db41a6 --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx @@ -0,0 +1,135 @@ +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 { getBrokerPortfolio } from '../api/broker'; +import type { BrokerAccount, BrokerPortfolio } from '../api/responses'; +import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios'; + +vi.mock('../api/broker', () => ({ + getBrokerPortfolio: vi.fn(), +})); + +function createWrapper(queryClient?: QueryClient) { + const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function createAccount(id: string): BrokerAccount { + return { + id, + type: 'brokerage', + name: id, + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }; +} + +function createPortfolio(id: string): BrokerPortfolio { + return { + account: createAccount(id), + positionCounts: { shares: 1, bonds: 0, etf: 0, other: 0 }, + totals: { + shares: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + bonds: null, + etf: null, + currencies: { currency: 'RUB', units: '0', nano: 0, value: 20 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '0', nano: 0, value: 120 }, + }, + yields: { + expectedPercent: 3, + daily: { currency: 'RUB', units: '0', nano: 0, value: 10 }, + dailyPercent: 1, + }, + cash: [{ currency: 'RUB', units: '0', nano: 0, value: 20 }], + blockedCash: [], + asOf: '2026-06-19T10:00:00.000Z', + }; +} + +describe('useBrokerAccountPortfolios', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('keeps account-to-query mapping regardless of completion order', async () => { + const first = createDeferred<{ + data: BrokerPortfolio; + meta: { fromCache: false; cachedAt: null }; + }>(); + const second = createDeferred<{ + data: BrokerPortfolio; + meta: { fromCache: false; cachedAt: null }; + }>(); + + vi.mocked(getBrokerPortfolio).mockImplementation((accountId: string) => { + if (accountId === 'acc-1') { + return first.promise; + } + + if (accountId === 'acc-2') { + return second.promise; + } + + throw new Error(`Unexpected account ${accountId}`); + }); + + const accounts = [createAccount('acc-1'), createAccount('acc-2')]; + const { result } = renderHook(() => useBrokerAccountPortfolios(accounts), { + wrapper: createWrapper(), + }); + + expect(getBrokerPortfolio).toHaveBeenCalledTimes(2); + expect(getBrokerPortfolio).toHaveBeenNthCalledWith(1, 'acc-1'); + expect(getBrokerPortfolio).toHaveBeenNthCalledWith(2, 'acc-2'); + + second.resolve({ + data: createPortfolio('acc-2'), + meta: { fromCache: false, cachedAt: null }, + }); + + await waitFor(() => expect(result.current[1].query.data?.account.id).toBe('acc-2')); + expect(result.current[0].account.id).toBe('acc-1'); + expect(result.current[0].query.data).toBeUndefined(); + + first.resolve({ + data: createPortfolio('acc-1'), + meta: { fromCache: false, cachedAt: null }, + }); + + await waitFor(() => expect(result.current[0].query.data?.account.id).toBe('acc-1')); + expect(result.current[1].query.data?.account.id).toBe('acc-2'); + }); + + it('reuses the same cache key as broker account overview page', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const cachedPortfolio = createPortfolio('acc-1'); + queryClient.setQueryData(['broker', 'portfolio', 'acc-1'], cachedPortfolio); + + const { result } = renderHook(() => useBrokerAccountPortfolios([createAccount('acc-1')]), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current[0].query.data).toBe(cachedPortfolio)); + expect(getBrokerPortfolio).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts b/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts new file mode 100644 index 0000000..7feede3 --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts @@ -0,0 +1,17 @@ +import { useQueries } from '@tanstack/react-query'; +import { getBrokerPortfolio } from '../api/broker'; +import type { BrokerAccount, BrokerPortfolio } from '../api/responses'; + +export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { + const queries = useQueries({ + queries: accounts.map((account) => ({ + queryKey: ['broker', 'portfolio', account.id], + queryFn: async (): Promise => (await getBrokerPortfolio(account.id)).data, + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + })), + }); + + return accounts.map((account, index) => ({ account, query: queries[index] })); +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountCard.tsx b/apps/frontend/src/pages/broker/BrokerAccountCard.tsx new file mode 100644 index 0000000..4a4b82b --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountCard.tsx @@ -0,0 +1,142 @@ +import { Link } from 'react-router-dom'; +import { SkeletonBlock } from '../../components/SkeletonBlock'; +import type { BrokerAccount, BrokerPortfolio } from '../../api/responses'; +import { buildBrokerAllocation } from './brokerAllocation'; +import { BrokerAllocationBar } from './BrokerAllocationBar'; +import { + brokerAccountTypeLabel, + formatBrokerDate, + formatBrokerMoney, + formatBrokerSignedCurrencyValue, + formatBrokerSignedPercent, +} from './brokerAccountsOverview'; + +function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) { + return ( +
+
+
+

{typeLabel}

+

{name}

+
+
+
+ {[1, 2, 3, 4].map((item) => ( +
+ + +
+ ))} +
+ + +
+ ); +} + +function BrokerAccountCardError({ + account, + onRetry, +}: { + account: BrokerAccount; + onRetry: () => void; +}) { + return ( +
+
+
+

{brokerAccountTypeLabel(account.type)}

+

{account.name}

+
+
+
+

Не удалось загрузить данные счёта

+ +
+
+ ); +} + +function BrokerAccountCardSuccess({ + account, + portfolio, +}: { + account: BrokerAccount; + portfolio: BrokerPortfolio; +}) { + const typeLabel = brokerAccountTypeLabel(account.type); + const openedAt = formatBrokerDate(account.openedAt); + const allocation = buildBrokerAllocation(portfolio); + + return ( + +
+
+

{typeLabel}

+

{account.name}

+
+ {openedAt ?

Открыт {openedAt}

: null} +
+ +
+
+ Стоимость + {formatBrokerMoney(portfolio.totals.portfolio)} +
+
+ За день + + {formatBrokerSignedCurrencyValue( + portfolio.totals.portfolio?.currency ?? 'RUB', + portfolio.yields.daily?.value ?? null, + )} + +
+
+ Дневная динамика + {formatBrokerSignedPercent(portfolio.yields.dailyPercent)} +
+
+ Ожидаемая доходность + {formatBrokerSignedPercent(portfolio.yields.expectedPercent)} +
+
+ + + + ); +} + +export function BrokerAccountCard({ + account, + portfolio, + isLoading, + error, + onRetry, +}: { + account: BrokerAccount; + portfolio?: BrokerPortfolio; + isLoading: boolean; + error: Error | null; + onRetry: () => void; +}) { + if (isLoading && !portfolio) { + return ( + + ); + } + + if (error || !portfolio) { + return ; + } + + return ; +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx new file mode 100644 index 0000000..3ef4508 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx @@ -0,0 +1,293 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BrokerAccount, BrokerPortfolio } from '../../api/responses'; +import * as brokerAccountsHook from '../../hooks/useBrokerAccounts'; +import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios'; +import { BrokerAccountsPage } from './BrokerAccountsPage'; + +function renderPage(ui: ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return render( + + {ui} + , + ); +} + +function createAccount( + account: Partial & Pick, +): BrokerAccount { + return { + id: account.id, + name: account.name, + type: account.type ?? 'brokerage', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: account.openedAt ?? '2022-06-16T00:00:00.000Z', + accessLevel: null, + }; +} + +function createPortfolio( + account: BrokerAccount, + overrides: Partial = {}, +): BrokerPortfolio { + return { + account, + positionCounts: { shares: 4, bonds: 2, etf: 1, other: 0 }, + totals: { + shares: { currency: 'RUB', units: '0', nano: 0, value: 600 }, + bonds: { currency: 'RUB', units: '0', nano: 0, value: 300 }, + etf: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_000 }, + }, + yields: { + expectedPercent: 8, + daily: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + dailyPercent: 11.11, + }, + cash: [{ currency: 'RUB', units: '0', nano: 0, value: 200 }], + blockedCash: [], + asOf: '2026-06-19T10:00:00.000Z', + ...overrides, + }; +} + +function createQueryState(overrides: Record = {}) { + return { + data: undefined, + isLoading: false, + isFetching: false, + isPending: false, + isError: false, + error: null, + refetch: vi.fn(), + ...overrides, + }; +} + +describe('BrokerAccountsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders heading, aggregate summary, daily result and two linked cards', () => { + const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' }); + const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, iis], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { + account: broker, + query: createQueryState({ data: createPortfolio(broker) }), + }, + { + account: iis, + query: createQueryState({ + data: createPortfolio(iis, { + totals: { + shares: { currency: 'RUB', units: '0', nano: 0, value: 800 }, + bonds: { currency: 'RUB', units: '0', nano: 0, value: 500 }, + etf: { currency: 'RUB', units: '0', nano: 0, value: 200 }, + currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_600 }, + }, + yields: { + expectedPercent: 12, + daily: { currency: 'RUB', units: '0', nano: 0, value: 140 }, + dailyPercent: 9.59, + }, + cash: [{ currency: 'RUB', units: '0', nano: 0, value: 300 }], + }), + }), + }, + ] as any); + + renderPage(); + + expect(screen.getByRole('heading', { level: 1, name: 'Брокерские счета' })).toBeInTheDocument(); + expect(screen.getByText(/2[\s\u00a0]?600(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument(); + expect(screen.getByText(/\+?240(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute( + 'href', + '/broker/acc-1', + ); + expect(screen.getByRole('link', { name: /ИИС капитал/i })).toHaveAttribute( + 'href', + '/broker/acc-2', + ); + }); + + it('shows human labels and opened date without exposing technical fields', () => { + const broker = createAccount({ id: 'account one', name: 'Основной счёт' }); + const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, iis], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, + { account: iis, query: createQueryState({ data: createPortfolio(iis) }) }, + ] as any); + + renderPage(); + + expect(screen.getByText('Брокерский счёт')).toBeInTheDocument(); + expect(screen.getByText('ИИС')).toBeInTheDocument(); + expect(screen.getAllByText(/16\.06\.2022/)).toHaveLength(2); + expect(screen.queryByText('ACCOUNT_STATUS_OPEN')).not.toBeInTheDocument(); + expect(screen.queryByText('account one')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute( + 'href', + '/broker/account%20one', + ); + }); + + it('shows page skeleton while accounts are loading', () => { + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + error: null, + } as any); + + const { container } = renderPage(); + + expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0); + }); + + it('renders an empty state when there are no accounts', () => { + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([] as any); + + renderPage(); + + expect( + screen.getByText(/После подключения T-Bank здесь появятся брокерские счета и ИИС/), + ).toBeInTheDocument(); + }); + + it('marks the summary as partial when one account portfolio is unavailable', () => { + const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' }); + const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, iis], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, + { + account: iis, + query: createQueryState({ isError: true, error: new Error('boom') }), + }, + ] as any); + + renderPage(); + + expect(screen.getByText('Доступно по 1 из 2 счетов')).toBeInTheDocument(); + }); + + it('shows a local alert and retries only the failed account', async () => { + const user = userEvent.setup(); + const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' }); + const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); + const refetch = vi.fn(); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, iis], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, + { + account: iis, + query: createQueryState({ isError: true, error: new Error('boom'), refetch }), + }, + ] as any); + + renderPage(); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('Не удалось загрузить данные счёта'); + await user.click( + within(alert.closest('.broker-account-card')!).getByRole('button', { name: 'Повторить' }), + ); + expect(refetch).toHaveBeenCalled(); + }); + + it('keeps currencies separate in the overview summary', () => { + const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' }); + const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' }); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, usd], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, + { + account: usd, + query: createQueryState({ + data: createPortfolio(usd, { + totals: { + shares: { currency: 'USD', units: '0', nano: 0, value: 300 }, + bonds: { currency: 'USD', units: '0', nano: 0, value: 100 }, + etf: null, + currencies: { currency: 'USD', units: '0', nano: 0, value: 100 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'USD', units: '0', nano: 0, value: 500 }, + }, + yields: { + expectedPercent: 4, + daily: { currency: 'USD', units: '0', nano: 0, value: 20 }, + dailyPercent: 4.16, + }, + cash: [{ currency: 'USD', units: '0', nano: 0, value: 25 }], + }), + }), + }, + ] as any); + + renderPage(); + + const summary = screen.getByRole('region', { name: 'Общая сводка по счетам' }); + expect(within(summary).getByText(/1[\s\u00a0]?000(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument(); + expect(within(summary).getByText(/500(?:,00)?[\s\u00a0]?\$/)).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx index fd8ca3d..ba1d963 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx @@ -1,85 +1,118 @@ -import { Link } from 'react-router-dom'; +import { useBrokerAccountPortfolios } from '../../hooks/useBrokerAccountPortfolios'; import { useBrokerAccounts } from '../../hooks/useBrokerAccounts'; -import { SkeletonBlock } from '../../components/SkeletonBlock'; - -const cardStyle = { - display: 'block', - padding: 20, - background: 'var(--color-surface)', - border: '1px solid #e0e0e0', - borderRadius: 8, - color: 'var(--color-text)', - textDecoration: 'none', - boxShadow: 'var(--shadow)', -} satisfies React.CSSProperties; - -export function BrokerAccountsPage() { - const { data: accounts, isLoading, error } = useBrokerAccounts(); - - if (isLoading) { - return ( -
-
-

Брокерские счета

-
-
- {[1, 2, 3].map((i) => ( -
- -
- -
- -
- -
- ))} -
-
- ); - } - if (error) return

Не удалось загрузить счета

; +import { BrokerAccountCard } from './BrokerAccountCard'; +import { BrokerAccountsSummary } from './BrokerAccountsSummary'; +import { aggregateBrokerAccounts } from './brokerAccountsOverview'; +function BrokerAccountsPageSkeleton() { return ( -
-
-

Брокерские счета

- - {(accounts ?? []).length} - -
- -
- {(accounts ?? []).map((account) => ( - -
{account.name}
-
- {account.type === 'iis' ? 'ИИС' : 'Брокерский счет'} - {account.status} - {account.id} -
- +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+
+ +
+ {['1', '2', '3'].map((id) => ( + undefined} + /> + ))} +
+
+ ); +} + +export function BrokerAccountsPage() { + const { data: accounts, isLoading, error } = useBrokerAccounts(); + const safeAccounts = accounts ?? []; + const accountQueries = useBrokerAccountPortfolios(safeAccounts); + + if (isLoading) { + return ; + } + + if (error) { + return

Не удалось загрузить счета

; + } + + if (safeAccounts.length === 0) { + return ( +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+
+
+

Пока нет подключённых счетов

+

+ После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу. +

+
+
+ ); + } + + const successfulPortfolios = accountQueries + .map(({ query }) => query.data) + .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); + + return ( +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+

+ {safeAccounts.length} счетов под наблюдением +

+
+ + 0} + /> + +
+ {accountQueries.map(({ account, query }) => ( + { + void query.refetch(); + }} + /> ))}
diff --git a/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx b/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx new file mode 100644 index 0000000..0329d4d --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx @@ -0,0 +1,164 @@ +import { SkeletonBlock } from '../../components/SkeletonBlock'; +import { buildBrokerAllocation } from './brokerAllocation'; +import { BrokerAllocationBar } from './BrokerAllocationBar'; +import type { BrokerAccountsAggregate } from './brokerAccountsOverview'; +import { + formatBrokerCurrencyValue, + formatBrokerSignedCurrencyValue, + formatBrokerSignedPercent, +} from './brokerAccountsOverview'; + +export function BrokerAccountsSummary({ + aggregate, + availableCount, + totalCount, + isLoading, +}: { + aggregate: BrokerAccountsAggregate; + availableCount: number; + totalCount: number; + isLoading: boolean; +}) { + if (isLoading) { + return ( +
+
+ + + +
+
+ {[1, 2, 3].map((item) => ( +
+ + +
+ ))} +
+
+ ); + } + + return ( +
+
+
+

Финансовый обзор

+

Счета в одном кадре

+
+
+ {totalCount} счетов + {availableCount !== totalCount ? ( + + Доступно по {availableCount} из {totalCount} счетов + + ) : null} +
+
+ +
+ {aggregate.portfolios.map((portfolioSummary) => { + const allocation = buildBrokerAllocation({ + account: { + id: 'aggregate', + type: 'brokerage', + name: 'aggregate', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + positionCounts: { shares: 0, bonds: 0, etf: 0, other: 0 }, + totals: { + shares: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.shares, + }, + bonds: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.bonds, + }, + etf: + portfolioSummary.allocation.etf > 0 + ? { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.etf, + } + : null, + currencies: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.cash, + }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.total, + }, + }, + yields: { expectedPercent: null, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + asOf: '', + }); + + return ( +
+
+ + {portfolioSummary.currency} + + + {formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)} + +
+
+
+
За день
+
+ {formatBrokerSignedCurrencyValue( + portfolioSummary.currency, + portfolioSummary.daily, + )} +
+
+
+
Динамика
+
{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}
+
+
+
Свободные деньги
+
+ {formatBrokerCurrencyValue( + portfolioSummary.currency, + aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency) + ?.value ?? 0, + )} +
+
+
+ +
+ ); + })} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx b/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx new file mode 100644 index 0000000..d4203f4 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx @@ -0,0 +1,43 @@ +import type { BrokerAllocationItem } from './brokerAllocation'; + +export function BrokerAllocationBar({ + items, + title, +}: { + items: BrokerAllocationItem[]; + title: string; +}) { + const positiveItems = items.filter((item) => item.value > 0); + + if (positiveItems.length === 0) { + return

Нет данных для распределения

; + } + + return ( +
+
+ {positiveItems.map((item) => ( +
+
    + {positiveItems.map((item) => ( +
  • +
  • + ))} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index fb9e536..5c3d63a 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event'; import { type ReactElement } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; -import * as accountHook from '../../hooks/useBrokerAccounts'; import * as operationsHook from '../../hooks/useBrokerOperations'; import * as portfolioHook from '../../hooks/useBrokerPortfolio'; import * as positionsHook from '../../hooks/useBrokerPositions'; @@ -12,7 +11,6 @@ import type { BrokerPortfolio, BrokerPosition } from '../../api/responses'; import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout'; import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage'; -import { BrokerAccountsPage } from './BrokerAccountsPage'; import { BrokerPositionsPage } from './BrokerPositionsPage'; import { BrokerOperationsPage } from './BrokerOperationsPage'; @@ -276,37 +274,6 @@ describe('Broker pages', () => { expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument(); }); - it('renders broker and IIS accounts', () => { - vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({ - data: [ - { - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - { - id: 'acc-2', - type: 'iis', - name: 'IIS', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - ], - isLoading: false, - isFetching: false, - error: null, - } as any); - - renderWithClient(); - - expect(screen.getByText('Broker')).toBeInTheDocument(); - expect(screen.getByText('IIS')).toBeInTheDocument(); - }); - it('renders the broker account overview with allocation, asset links and recent operations', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio(), diff --git a/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts b/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts new file mode 100644 index 0000000..5e15834 --- /dev/null +++ b/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import type { BrokerPortfolio } from '../../api/responses'; +import { aggregateBrokerAccounts } from './brokerAccountsOverview'; + +function portfolio( + id: string, + currency: string, + total: number, + daily: number | null, + cash: number, +): BrokerPortfolio { + return { + account: { + id, + type: 'brokerage', + name: id, + status: 'ACCOUNT_STATUS_OPEN', + openedAt: '2022-06-16T00:00:00.000Z', + accessLevel: null, + }, + positionCounts: { shares: 1, bonds: 1, etf: 0, other: 0 }, + totals: { + shares: { currency, units: '0', nano: 0, value: total * 0.5 }, + bonds: { currency, units: '0', nano: 0, value: total * 0.3 }, + etf: null, + currencies: { currency, units: '0', nano: 0, value: total * 0.2 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency, units: '0', nano: 0, value: total }, + }, + yields: { + expectedPercent: 10, + daily: daily === null ? null : { currency, units: '0', nano: 0, value: daily }, + dailyPercent: null, + }, + cash: [{ currency, units: '0', nano: 0, value: cash }], + blockedCash: [], + asOf: '2026-06-19T10:00:00.000Z', + }; +} + +describe('aggregateBrokerAccounts', () => { + it('sums comparable portfolios and uses the specified daily percent formula', () => { + const result = aggregateBrokerAccounts([ + portfolio('a', 'RUB', 1_100, 100, 200), + portfolio('b', 'RUB', 2_200, 200, 300), + ]); + + expect(result.portfolios).toEqual([ + expect.objectContaining({ + currency: 'RUB', + total: 3_300, + daily: 300, + dailyPercent: 10, + allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 }, + }), + ]); + 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 }); + }); + + it('does not expose a daily percent when start of day is non-positive', () => { + const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]); + + 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 result = aggregateBrokerAccounts([overAllocated]); + + expect(result.portfolios[0].allocation).toEqual({ + shares: 700, + bonds: 400, + 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; + + 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 }); + + expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([ + { currency: 'RUB', value: 100 }, + { currency: 'USD', value: 25 }, + ]); + }); +}); diff --git a/apps/frontend/src/pages/broker/brokerAccountsOverview.ts b/apps/frontend/src/pages/broker/brokerAccountsOverview.ts new file mode 100644 index 0000000..8226ee2 --- /dev/null +++ b/apps/frontend/src/pages/broker/brokerAccountsOverview.ts @@ -0,0 +1,198 @@ +import type { BrokerMoney, BrokerPortfolio } from '../../api/responses'; + +export interface BrokerCurrencyAllocationSummary { + 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; +} + +export interface BrokerCurrencyCashSummary { + currency: string; + value: number; +} + +export interface BrokerAccountsAggregate { + portfolios: BrokerCurrencyPortfolioSummary[]; + cash: BrokerCurrencyCashSummary[]; +} + +interface MutableCurrencySummary { + currency: string; + total: number; + daily: number | null; + dailyComparable: boolean; + allocation: BrokerCurrencyAllocationSummary; +} + +function moneyValue(money: BrokerMoney | null | undefined): number { + return money?.value ?? 0; +} + +export function formatBrokerMoney(value: BrokerMoney | null | undefined): string { + if (!value) { + return '—'; + } + + return formatBrokerCurrencyValue(value.currency, value.value); +} + +export function formatBrokerCurrencyValue(currency: string, value: number): string { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: currency || 'RUB', + maximumFractionDigits: 2, + }).format(value); +} + +export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string { + if (value === null) { + return '—'; + } + + const formatted = formatBrokerCurrencyValue(currency, Math.abs(value)); + + if (value > 0) { + return `+${formatted}`; + } + + if (value < 0) { + return `−${formatted}`; + } + + return formatted; +} + +export function formatBrokerPercent(value: number | null): string { + if (value === null) { + return '—'; + } + + return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`; +} + +export function formatBrokerSignedPercent(value: number | null): string { + if (value === null) { + return '—'; + } + + const formatted = formatBrokerPercent(Math.abs(value)); + + if (value > 0) { + return `+${formatted}`; + } + + if (value < 0) { + return `−${formatted}`; + } + + return formatted; +} + +export function formatBrokerDate(value: string | null | undefined): string | null { + if (!value) { + return null; + } + + return new Date(value).toLocaleDateString('ru-RU'); +} + +export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { + return type === 'iis' ? 'ИИС' : 'Брокерский счёт'; +} + +export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate { + const portfolioSummaries = new Map(); + const cashSummaries = new Map(); + + for (const portfolio of portfolios) { + for (const cash of portfolio.cash) { + if (!cash.currency) { + continue; + } + + const existingCash = cashSummaries.get(cash.currency); + + if (existingCash) { + existingCash.value += cash.value; + } else { + cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value }); + } + } + + const totalMoney = portfolio.totals.portfolio; + const currency = totalMoney?.currency; + + if (!totalMoney || !currency) { + continue; + } + + const existingSummary = portfolioSummaries.get(currency); + const summary = + existingSummary ?? + ({ + currency, + total: 0, + daily: 0, + dailyComparable: true, + allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 }, + } 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); + + 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; + + if (!comparableDaily) { + summary.daily = null; + summary.dailyComparable = false; + } else if (summary.dailyComparable) { + summary.daily = (summary.daily ?? 0) + dailyMoney.value; + } + + if (!existingSummary) { + 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 dailyPercent = + daily === null || startOfDay === null || startOfDay <= 0 + ? null + : (daily / startOfDay) * 100; + + return { + currency: summary.currency, + total: summary.total, + daily, + dailyPercent, + allocation: summary.allocation, + }; + }), + cash: Array.from(cashSummaries.values()), + }; +} diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 0cf783b..343b42d 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -16,6 +16,12 @@ --color-negative: #c62828; --border-radius: 8px; --shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + --broker-overview-bg: linear-gradient(180deg, #f1f5ef 0%, #f7f2e8 100%); + --broker-overview-panel: rgba(15, 51, 36, 0.93); + --broker-overview-panel-soft: rgba(255, 255, 255, 0.09); + --broker-overview-border: rgba(21, 61, 43, 0.12); + --broker-overview-accent: #98c484; + --broker-overview-gold: #d7b268; } .pnl-cell { @@ -219,12 +225,307 @@ a { color: var(--color-negative); } -@media (max-width: 720px) { -.broker-account__header { - padding: 0 0 8px; +.broker-accounts-page { + display: grid; + gap: 24px; } -.broker-account__workspace { +.broker-accounts-page__header { + display: flex; + justify-content: space-between; + align-items: end; + gap: 16px; +} + +.broker-accounts-page__eyebrow, +.broker-account-card__eyebrow, +.broker-accounts-summary__eyebrow { + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.broker-accounts-page__title, +.broker-account-card__title, +.broker-accounts-summary__title { + font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif; + line-height: 1.05; +} + +.broker-accounts-page__title { + font-size: clamp(2.2rem, 3vw, 3rem); +} + +.broker-accounts-page__caption { + color: var(--color-text-secondary); +} + +.broker-accounts-summary { + padding: 28px; + border-radius: 28px; + background: var(--broker-overview-bg); + box-shadow: + 0 24px 60px rgba(15, 52, 35, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.55); + border: 1px solid rgba(255, 255, 255, 0.7); + display: grid; + gap: 20px; +} + +.broker-accounts-summary__hero { + display: grid; + gap: 12px; + padding: 24px; + border-radius: 22px; + background: + radial-gradient(circle at top right, rgba(152, 196, 132, 0.3), transparent 28%), + linear-gradient(135deg, var(--broker-overview-panel) 0%, #173f2d 100%); + color: #f8f5ec; +} + +.broker-accounts-summary__hero .broker-accounts-summary__eyebrow { + color: rgba(248, 245, 236, 0.72); +} + +.broker-accounts-summary__title { + font-size: clamp(1.9rem, 2.4vw, 2.6rem); +} + +.broker-accounts-summary__status { + display: flex; + flex-wrap: wrap; + gap: 10px; + color: rgba(248, 245, 236, 0.78); +} + +.broker-accounts-summary__status span { + padding: 6px 10px; + border-radius: 999px; + background: var(--broker-overview-panel-soft); +} + +.broker-accounts-summary__currency-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.broker-accounts-summary__currency-card, +.broker-account-card, +.broker-accounts-empty { + border-radius: 24px; + border: 1px solid var(--broker-overview-border); + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 20px 45px rgba(31, 48, 39, 0.08); +} + +.broker-accounts-summary__currency-card { + padding: 22px; + display: grid; + gap: 16px; +} + +.broker-accounts-summary__currency-header { + display: grid; + gap: 6px; +} + +.broker-accounts-summary__currency { + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.broker-accounts-summary__total { + font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif; + font-size: clamp(1.8rem, 2vw, 2.4rem); +} + +.broker-accounts-summary__metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.broker-accounts-summary__metric { + display: grid; + gap: 8px; + padding: 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.7); +} + +.broker-accounts-summary__metric dt, +.broker-account-card__stat span { + font-size: 13px; + color: var(--color-text-secondary); +} + +.broker-accounts-summary__metric dd, +.broker-account-card__stat strong { + font-size: 1.05rem; + font-weight: 700; +} + +.broker-allocation-bar { + display: grid; + gap: 12px; +} + +.broker-allocation-bar__track { + min-height: 12px; + border-radius: 999px; + overflow: hidden; + display: flex; + background: rgba(19, 54, 38, 0.08); +} + +.broker-allocation-bar__segment { + min-width: 8px; +} + +.broker-allocation-bar__legend { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; +} + +.broker-allocation-bar__legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(19, 54, 38, 0.05); +} + +.broker-allocation-bar__swatch { + width: 9px; + height: 9px; + border-radius: 999px; +} + +.broker-allocation-bar__empty { + color: var(--color-text-secondary); +} + +.broker-accounts-page__cards { + display: grid; + gap: 16px; +} + +.broker-account-card { + padding: 22px; +} + +.broker-account-card--link { + display: grid; + gap: 18px; + color: inherit; + text-decoration: none; + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + border-color 0.22s ease; +} + +.broker-account-card--link:hover { + transform: translateY(-2px); + box-shadow: 0 26px 50px rgba(31, 48, 39, 0.11); + border-color: rgba(38, 92, 55, 0.22); +} + +.broker-account-card--link:focus-visible { + outline: 3px solid rgba(59, 128, 74, 0.3); + outline-offset: 3px; +} + +.broker-account-card__header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} + +.broker-account-card__title { + font-size: clamp(1.4rem, 2vw, 1.8rem); +} + +.broker-account-card__opened { + color: var(--color-text-secondary); + text-align: right; +} + +.broker-account-card__grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.broker-account-card__stat { + display: grid; + gap: 8px; + padding: 14px; + border-radius: 18px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(241, 245, 239, 0.88)); + border: 1px solid rgba(31, 48, 39, 0.08); +} + +.broker-account-card__stat--wide { + grid-column: span 2; +} + +.broker-account-card__alert { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(198, 40, 40, 0.08); + color: #8e2525; +} + +.broker-account-card__retry { + border: none; + border-radius: 999px; + background: #163f2c; + color: #fff; + padding: 10px 16px; + font: inherit; + cursor: pointer; +} + +.broker-account-card__retry:focus-visible { + outline: 3px solid rgba(59, 128, 74, 0.3); + outline-offset: 3px; +} + +.broker-accounts-empty { + padding: 28px; + display: grid; + gap: 10px; +} + +@media (prefers-reduced-motion: reduce) { + .broker-account-card--link, + .loading-spinner, + .skeleton { + transition: none; + animation: none; + } +} + +@media (max-width: 720px) { + .broker-account__header { + padding: 0 0 8px; + } + + .broker-account__workspace { gap: 16px; grid-template-columns: minmax(0, 1fr); } @@ -255,6 +556,38 @@ a { width: 100%; align-self: center; } + + .broker-accounts-page__header, + .broker-account-card__header { + grid-template-columns: minmax(0, 1fr); + display: grid; + } + + .broker-accounts-summary, + .broker-account-card, + .broker-accounts-empty { + padding: 18px; + border-radius: 20px; + } + + .broker-accounts-summary__hero, + .broker-accounts-summary__metrics, + .broker-account-card__grid { + grid-template-columns: 1fr; + } + + .broker-account-card__stat--wide { + grid-column: auto; + } + + .broker-account-card__opened { + text-align: left; + } + + .broker-account-card__alert { + align-items: stretch; + flex-direction: column; + } } .broker-operations__toolbar { diff --git a/docs/features/broker-accounts-overview/tasks.md b/docs/features/broker-accounts-overview/tasks.md index 3cbff55..45ffc4e 100644 --- a/docs/features/broker-accounts-overview/tasks.md +++ b/docs/features/broker-accounts-overview/tasks.md @@ -1,40 +1,40 @@ # Информативный обзор брокерских счетов — задачи -Статус: готово к реализации +Статус: реализовано Подробные шаги, команды и ожидаемые результаты находятся в [plan.md](plan.md). ## 1. Агрегация -- [ ] Добавить чистую валютно-безопасную агрегацию портфелей. -- [ ] Покрыть формулу дневного процента и edge cases unit-тестами. -- [ ] Не смешивать валюты и не выполнять неявную конвертацию. +- [x] Добавить чистую валютно-безопасную агрегацию портфелей. +- [x] Покрыть формулу дневного процента и edge cases unit-тестами. +- [x] Не смешивать валюты и не выполнять неявную конвертацию. ## 2. Загрузка данных -- [ ] Добавить параллельные portfolio queries для списка счетов. -- [ ] Переиспользовать query keys детальной страницы. -- [ ] Проверить независимое завершение запросов и кеш. +- [x] Добавить параллельные portfolio queries для списка счетов. +- [x] Переиспользовать query keys детальной страницы. +- [x] Проверить независимое завершение запросов и кеш. ## 3. Интерфейс -- [ ] Добавить общую сводку по успешно загруженным счетам. -- [ ] Добавить информативную карточку счёта и allocation bar. -- [ ] Добавить skeleton, empty state и локальную ошибку с retry. -- [ ] Убрать технический ID и сырые T-Bank enum-значения. -- [ ] Обеспечить keyboard navigation и текстовые признаки доходности. +- [x] Добавить общую сводку по успешно загруженным счетам. +- [x] Добавить информативную карточку счёта и allocation bar. +- [x] Добавить skeleton, empty state и локальную ошибку с retry. +- [x] Убрать технический ID и сырые T-Bank enum-значения. +- [x] Обеспечить keyboard navigation и текстовые признаки доходности. ## 4. Визуальная проверка -- [ ] Реализовать согласованное зелёно-нейтральное визуальное направление. -- [ ] Проверить desktop 1280px и mobile 390px без horizontal overflow. -- [ ] Проверить loading и partial-error states в браузере. +- [x] Реализовать согласованное зелёно-нейтральное визуальное направление. +- [x] Проверить desktop 1280px и mobile 390px без horizontal overflow. +- [x] Проверить loading и partial-error states в браузере. ## 5. Definition of Done -- [ ] Frontend tests проходят. -- [ ] Frontend lint проходит. -- [ ] Frontend build проходит. -- [ ] Docusaurus build проходит. -- [ ] Code review завершён. -- [ ] Roadmap отмечает фичу реализованной. +- [x] Frontend tests проходят. +- [x] Frontend lint проходит. +- [x] Frontend build проходит. +- [x] Docusaurus build проходит. +- [x] Code review завершён. +- [x] Roadmap отмечает фичу реализованной. diff --git a/docs/roadmap.md b/docs/roadmap.md index 16f19db..1296abf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,10 +11,10 @@ Roadmap отражает порядок продуктовой работы, н Цель: сделать реальные брокерские счета понятными на уровне обзора, позиций и операций. - [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано. -- [ ] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — согласовано к планированию. +- [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано. ## Следующие этапы для активной фичи 1. [x] Проверить и утвердить `spec.md` для планирования. 2. [x] Проверить и утвердить подготовленные `plan.md` и `tasks.md`. -3. [ ] Получить отдельное подтверждение пользователя перед началом реализации. +3. [x] Получить отдельное подтверждение пользователя перед началом реализации. -- 2.47.2