feat: redesign portfolio position cards

This commit is contained in:
2026-08-17 13:34:31 +03:00
parent aac1b435af
commit 6795ee6f96
6 changed files with 227 additions and 2 deletions
@@ -0,0 +1,58 @@
## Context
См. proposal.md — Why/What. Текущее состояние: `PositionSection` в `AccountPage.view.tsx:269` рендерит полноширинные `Card`-строки на колонке 960px; позиции уже делятся на секции Акции/Облигации/Другие в upstream-порядке. Backend уже тянет metadata инструмента для каждой позиции (name/ticker) с 24-часовым кэшем; T-Bank в ответе `GetInstrumentBy` дополнительно отдаёт `isin`, но backend его не пробрасывает (`TbankInstrument` типизирован как `{ ticker?, name? }`, `tbank.client.ts:47`).
Важный факт: `totalsByCurrency` включает **и** Cash Balance (`portfolio.service.ts:148` — `new Map(cash)`), поэтому он непригоден как знаменатель доли позиции.
## Goals / Non-Goals
**Goals:**
- Backend отдаёт `isin` позиции без новых обращений к T-Bank.
- Frontend отображает позиции компактными плитками в grid с метриками: идентификатор, количество, `currentValue` (главная), `absoluteReturn` (цвет знака), `averagePurchasePrice`, доля среди позиций той же валюты, `isin`. Null-метрики скрываются.
- Сохранить секции, upstream-порядок, некликабельность, пустые/ошибки/stale-состояния и ровно один способ расчёта доли.
**Non-Goals:**
- Относительная доходность в %, тикер, сортировка, кликабельные карточки, изменение Totals/Cash/«Последних событий», валютная конвертация.
## Decisions
### 1. ISIN прокидывается через существующий metadata-конвейер
`TbankInstrument` расширяется полем `isin?: string`; `position()` в `portfolio.service.ts` возвращает `isin: metadata?.isin?.trim() || null`; `PortfolioPositionDto` получает nullable `isin`. Новых RPC нет — metadata уже запрашивается и кэшируется, меняется только форма значения.
*Альтернатива:* отдельный endpoint инструментов — overkill; маппинг на фронте — невозможно (ISIN нет в данных).
### 2. Доля позиции считается на клиенте, знаменатель — только позиции одной валюты
Знаменатель = Σ `currentValue` **позиций** одной валюты, без Cash Balance. Сумма вычисляется во `view` (или VM) по всему массиву `positions` один раз (Map валюта → сумма) и передаётся в плитки. Важно: доля считается **по всем секциям сразу**, а не внутри секции — иначе USD-облигация и USD-акция считались бы раздельно.
*Альтернатива:* знаменатель из `totalsByCurrency` — отклонено: он включает кэш и занижает долю. Backend-расчёт — отклонено: доля это отображаемая метрика, спека закрепляет её за клиентом.
### 3. Расчёт доли — display-only, точность Number с округлением
`display.ts` получает helper `formatShare(numerator, denominator)` → строка процента (1 знак) или `null` при недоступности. Значения money — decimal-строки; для отображения доли достаточно `Number(value)` и округления: доля не участвует в финансовой арифметике и не передаётся в агрегаты. Один маленький `assert`-тест в `display.test.ts`.
*Risk/mitigation:* значения крупнее 2^53 теряют точность — неприменимо к реальным суммам портфеля и безвредно для display-only.
### 4. Grid — Mantine `SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}`
Тот же компонент, что уже используется для Totals-карточек. Плитки заменяют `Card`-строки в `PositionSection`; на узком экране — одна колонка (спека «Портфель отображается на мобильном устройстве» остаётся верной).
### 5. Структура плитки
- Верх: `displayName` (усечение), `isin` (dim, `mono`, xs) под именем, количество (dim, sm).
- Справа/ниже: `currentValue` крупно (главная метрика), `absoluteReturn` с цветом знака.
- Низ: `averagePurchasePrice` и доля (dim, xs).
### 6. Цвета — Mantine-переменные
Зелёный `var(--mantine-color-green-6)` / красный `var(--mantine-color-red-6)` для `absoluteReturn`; без цвета при `null`. Кастомную палитру не вводим.
### 7. Null-метрики скрываются
Вместо «—» строка не рендерится (компактная плитка). Это меняет текущий тест `AccountPage.test.tsx:94` (`expect(screen.getByText("—"))`).
## Risks / Trade-offs
- [Точность доли при Number] → доля display-only, округление до 1 знака; upgrade до BigInt-деления, если появится сценарий, требующий точности.
- [ISIN отсутствует у валют и части инструментов] → nullable; плитка скрывает строку ISIN.
- [Grid-плитки на узком экране] → `base: 1` гарантирует одноколоночность.
- [«—»-заглушка в тестах] → обновить `AccountPage.test.tsx` на проверку скрытия метрик.
## Migration Plan
Обратимая аддитивная миграция. Backend-поле `isin` nullable: старый frontend игнорирует лишнее поле; старый backend без `isin` даёт `undefined` → плитка скрывает строку. Откат — revert соответствующего коммита в репозитории. Снапшот OpenAPI в frontend обновляется `npm run openapi:sync` после изменения backend.
## Open Questions
Нет. Детали типографики и отступов плитки решаются на этапе реализации без изменения спек.