Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b94a16ae5 | ||
|
|
e2062ce837 | ||
|
|
d910d624d6 | ||
|
|
cc45352243 | ||
|
|
ed0b5388ab | ||
|
|
134c951178 | ||
|
|
2fd7d59e65 | ||
|
|
668f2ef70e | ||
|
|
cadba8d075 | ||
|
|
2db39f25d9 | ||
|
|
2d070ce2b5 | ||
|
|
5077bd4f0b | ||
|
|
e2d143df46 | ||
|
|
ad93725a84 | ||
|
|
8ce1e8e444 | ||
|
|
aaee9591d7 | ||
|
|
9619dc9791 | ||
|
|
71fbe7774d | ||
|
|
870b7e6327 | ||
|
|
70a2c335fd | ||
|
|
7242240b21 |
+6
-7
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
|
|||||||
[package.json](../package.json), реализация — source of truth.
|
[package.json](../package.json), реализация — source of truth.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app → pages → widgets → features → entities → shared
|
app → pages → features → entities → shared
|
||||||
page container → page VM → entity/feature query → shared/api → backend /api/v1
|
page container → page VM → entity/feature query → shared/api → backend /api/v1
|
||||||
↓
|
↓
|
||||||
props → state/section views
|
props → state/section views
|
||||||
@@ -30,14 +30,13 @@ contract; frontend не читает его checkout для генерации
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Bootstrap, глобальные providers, routes | `app` |
|
| Bootstrap, глобальные providers, routes | `app` |
|
||||||
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
|
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
|
||||||
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
|
|
||||||
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
|
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
|
||||||
| Представление/поведение бизнес-сущности | `entities/<entity>` |
|
| Представление/поведение бизнес-сущности | `entities/<entity>` |
|
||||||
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
|
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
|
||||||
|
|
||||||
Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
|
Локальный FSD profile: `app → pages → features → entities → shared`.
|
||||||
Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
|
Слой `widgets` не используется: композиция экрана и компонент для одной page
|
||||||
одной page остаётся у page. Form, mutation или click handler сами по себе не
|
остаются у page. Form, mutation или click handler сами по себе не
|
||||||
требуют feature. Два callers не делают domain helper generic shared.
|
требуют feature. Два callers не делают domain helper generic shared.
|
||||||
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
||||||
|
|
||||||
@@ -49,12 +48,12 @@ Slice именуется по ответственности, а не `helpers`,
|
|||||||
|
|
||||||
Cross-slice consumer использует `@/` и `index.ts` владельца, например
|
Cross-slice consumer использует `@/` и `index.ts` владельца, например
|
||||||
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
|
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
|
||||||
импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
|
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя;
|
||||||
композиция принадлежит вышележащему owner. Existing меж-slice исключения
|
композиция принадлежит вышележащему owner. Existing меж-slice исключения
|
||||||
не являются разрешением добавлять новые.
|
не являются разрешением добавлять новые.
|
||||||
|
|
||||||
Направление зависимостей, public imports и запрет соседних slices в
|
Направление зависимостей, public imports и запрет соседних slices в
|
||||||
`pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
|
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
|
||||||
одного slice и связи shared segments этот запрет не ограничивает.
|
одного slice и связи shared segments этот запрет не ограничивает.
|
||||||
Knip проверяет использование exports, а не смысл placement.
|
Knip проверяет использование exports, а не смысл placement.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
!public/**
|
!public/**
|
||||||
!docker/
|
!docker/
|
||||||
!docker/**
|
!docker/**
|
||||||
|
!scripts/
|
||||||
|
!scripts/minimatch-compat/
|
||||||
|
!scripts/minimatch-compat/**
|
||||||
!packages/
|
!packages/
|
||||||
!packages/tokens/
|
!packages/tokens/
|
||||||
!packages/tokens/package.json
|
!packages/tokens/package.json
|
||||||
|
|||||||
@@ -1,5 +1,61 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.3.0...v1.4.0) (2026-11-06)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **investments:** warn and offer retry after exhausted read retries (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#136](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/136)) ([8ce1e8e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/8ce1e8e44486dcc23d4c074559ca2bb517880b60))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **account:** show source metadata on empty account and income (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#133](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/133)) ([9619dc9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/9619dc979172dbc16391206e801ed67862d20116))
|
||||||
|
* align audit findings for Events, currency and auth (WealthLedger/frontend[#155](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/155)) ([668f2ef](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/668f2ef70e535a1222e7f600b1be4a31fefc6f8b))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* **api:** consume bank operation oneOf contract (WealthLedger/frontend[#144](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/144)) ([#152](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/152)) ([2d070ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2d070ce2b54bbee466ecf6cc18b437910f0c07f8))
|
||||||
|
* drop dead query helpers and simplify read deadline (WealthLedger/frontend[#141](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/141)) ([#149](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/149)) ([5077bd4](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5077bd4f0baa4a59643e875485c2f08253cc198d))
|
||||||
|
* move portfolio structure into account page, drop widgets (WealthLedger/frontend[#139](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/139)) ([#140](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/140)) ([e2d143d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2d143df46e650139e49b7c80a886008ede86d2a))
|
||||||
|
* normalize view models and share breadcrumbs and titles (WealthLedger/frontend[#143](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/143)) ([#153](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/153)) ([2db39f2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2db39f25d9478350ad56a1b2e2ca6496068257c5))
|
||||||
|
* replace repeated lazy page wrappers with lazyPage helper ([#131](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/131)) ([#132](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/132)) ([71fbe77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71fbe7774d3ed63fcb84bc644da997921bc049b2))
|
||||||
|
* share path-root query predicate (WealthLedger/frontend[#142](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/142)) ([#154](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/154)) ([cadba8d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* **api:** update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend[#134](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/134)) ([#135](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/135)) ([aaee959](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/aaee9591d7880193ee9b0c14339e8477264e4a94))
|
||||||
|
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/6)) ([#137](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/137)) ([ad93725](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ad93725a84615ec329631b71ed8db33337085740))
|
||||||
|
|
||||||
|
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** add concentration tab with top-5 slices and chart (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/48)) ([#127](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/127)) ([70a2c33](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/70a2c335fdddb8b6a99e3f609befe06aeb8cc45d))
|
||||||
|
* group all-account rows by instrument and day (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/47)) ([#126](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/126)) ([71126c9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71126c91f0b30f4d65a313ddc7d7f86963340455))
|
||||||
|
* make brokerage account list compact ([#111](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/111)) ([c24704a](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9))
|
||||||
|
* remove top header and rework mobile bottom navigation ([#109](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/109)) ([1a92f02](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1a92f023c1c79f646424f72710ab74de63386a11))
|
||||||
|
* replace account page accordion with tabs and show open position result ([#112](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/112)) ([3075081](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3075081df188de0630d314ba15a34fe6efd1eea9))
|
||||||
|
* show return, cash and structure on account cards ([#114](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/114)) ([431559b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/431559be0fca9eb37f6a09d930d502f939884e39))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* round money to currency minor units without negative zero (WealthLedger/frontend[#118](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/118)) ([#119](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/119)) ([1d49904](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1d4990491f254e1ec6bafad8204872eaae2982d0))
|
||||||
|
* show account source sync status (WealthLedger/frontend[#120](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/120)) ([#121](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/121)) ([a56edf2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a56edf2b3757ef0b0f6c42ecbd422a1a563f6505))
|
||||||
|
* show history loading alert only while loading (WealthLedger/frontend[#124](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/124)) ([#125](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/125)) ([6792f5b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6792f5b70aa3ba90b2eb2df7e63d3ae2f669e295))
|
||||||
|
* stabilize auth client registration (WealthLedger/frontend[#95](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/95)) ([#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([5796d2d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5796d2d789963e66a3a4bcd79eab44700f16bd31))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* derive api error codes from contract (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/117)) ([#123](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/123)) ([32e8fe9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32e8fe9402665ea0c350f5f247d87e328ffd44c8))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* pin date in all accounts events test ([#115](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/115)) ([58b4e12](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/58b4e126330d01c1d6865bd0e9dd066a028fba08))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* **deps:** resolve minimatch and glob audit advisories (WealthLedger/main[#49](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/49)) ([#128](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/128)) ([7242240](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7242240b2188e9d1ca1325e65d0e1a0a3655d882))
|
||||||
|
* sync brokerage accounts contract (WealthLedger/backend[#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([0e4bbec](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0e4bbec2a7b814579095ea7ef6db68a70508fa29))
|
||||||
|
|
||||||
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
|
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ COPY package.json package-lock.json ./
|
|||||||
COPY packages/tokens/package.json ./packages/tokens/
|
COPY packages/tokens/package.json ./packages/tokens/
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
COPY packages/icons/package.json ./packages/icons/
|
COPY packages/icons/package.json ./packages/icons/
|
||||||
|
COPY scripts/minimatch-compat ./scripts/minimatch-compat
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM dependencies AS development
|
FROM dependencies AS development
|
||||||
|
|||||||
+5
-11
@@ -29,17 +29,17 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: {
|
from: {
|
||||||
path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
|
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
|
||||||
},
|
},
|
||||||
name: "slices-must-use-public-api",
|
name: "slices-must-use-public-api",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: {
|
to: {
|
||||||
path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
|
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
|
||||||
pathNot: "^src/$1/$2/",
|
pathNot: "^src/$1/$2/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
|
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
|
||||||
name: "no-same-layer-cross-slice-imports",
|
name: "no-same-layer-cross-slice-imports",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
|
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
|
||||||
@@ -48,24 +48,18 @@ module.exports = {
|
|||||||
from: { path: "^src/shared/" },
|
from: { path: "^src/shared/" },
|
||||||
name: "shared-must-not-depend-on-layers",
|
name: "shared-must-not-depend-on-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/(entities|features|widgets|pages|app)/" },
|
to: { path: "^src/(entities|features|pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/entities/" },
|
from: { path: "^src/entities/" },
|
||||||
name: "entities-must-not-depend-on-higher-layers",
|
name: "entities-must-not-depend-on-higher-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/(features|widgets|pages|app)/" },
|
to: { path: "^src/(features|pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/features/" },
|
from: { path: "^src/features/" },
|
||||||
name: "features-must-not-depend-on-higher-layers",
|
name: "features-must-not-depend-on-higher-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/(widgets|pages|app)/" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: { path: "^src/widgets/" },
|
|
||||||
name: "widgets-must-not-depend-on-higher-layers",
|
|
||||||
severity: "error",
|
|
||||||
to: { path: "^src/(pages|app)/" },
|
to: { path: "^src/(pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,12 +17,21 @@ http {
|
|||||||
default $http_x_forwarded_proto;
|
default $http_x_forwarded_proto;
|
||||||
'' $scheme;
|
'' $scheme;
|
||||||
}
|
}
|
||||||
|
# One shared budget: the external proxy's client-IP forwarding is not verified.
|
||||||
|
map "$request_method:$uri" $auth_admission_key {
|
||||||
|
default "";
|
||||||
|
POST:/api/v1/auth/login auth;
|
||||||
|
POST:/api/v1/auth/register auth;
|
||||||
|
}
|
||||||
|
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
|
||||||
|
limit_req_status 429;
|
||||||
server {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
resolver 127.0.0.11 valid=5s ipv6=off;
|
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
limit_req zone=auth_admission burst=5 nodelay;
|
||||||
set $backend http://backend:3000;
|
set $backend http://backend:3000;
|
||||||
proxy_pass $backend$request_uri;
|
proxy_pass $backend$request_uri;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
|
|||||||
+6
-1
@@ -25,7 +25,11 @@ const readability = {
|
|||||||
)
|
)
|
||||||
context.report({ messageId: "schema", node });
|
context.report({ messageId: "schema", node });
|
||||||
},
|
},
|
||||||
FunctionDeclaration: checkParameters,
|
FunctionDeclaration(node) {
|
||||||
|
checkParameters(node);
|
||||||
|
if (node.id?.name?.endsWith?.("ViewModel"))
|
||||||
|
context.report({ messageId: "vmName", node: node.id });
|
||||||
|
},
|
||||||
FunctionExpression: checkParameters,
|
FunctionExpression: checkParameters,
|
||||||
ImportDeclaration(node) {
|
ImportDeclaration(node) {
|
||||||
if (
|
if (
|
||||||
@@ -95,6 +99,7 @@ const readability = {
|
|||||||
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
||||||
svg: "Own SVG markup in packages/icons, and import its public component.",
|
svg: "Own SVG markup in packages/icons, and import its public component.",
|
||||||
union: "Put complex union members on separate lines.",
|
union: "Put complex union members on separate lines.",
|
||||||
|
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
|
||||||
},
|
},
|
||||||
schema: [],
|
schema: [],
|
||||||
type: "suggestion",
|
type: "suggestion",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"ignoreDependencies": ["conventional-changelog-conventionalcommits"],
|
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
".": {
|
".": {
|
||||||
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
|
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
|
||||||
|
|||||||
Generated
+42
-138
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-frontend",
|
"name": "wealth-ledger-frontend",
|
||||||
"version": "1.2.1",
|
"version": "1.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-frontend",
|
"name": "wealth-ledger-frontend",
|
||||||
"version": "1.2.1",
|
"version": "1.4.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
"jsdom": "30.1.3",
|
"jsdom": "30.1.3",
|
||||||
"knip": "6.43.0",
|
"knip": "6.43.0",
|
||||||
"lint-staged": "17.6.0",
|
"lint-staged": "17.6.0",
|
||||||
|
"minimatch": "file:scripts/minimatch-compat",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
"openapi-typescript": "7.13.0",
|
"openapi-typescript": "7.13.0",
|
||||||
"playwright": "1.64.1",
|
"playwright": "1.64.1",
|
||||||
@@ -88,7 +89,7 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.72.0",
|
"typescript-eslint": "8.72.0",
|
||||||
"vite": "8.3.5",
|
"vite": "8.3.5",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "2.0.0",
|
||||||
"vitest": "5.0.5"
|
"vitest": "5.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2511,14 +2512,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/nonrecursive-glob-match": {
|
||||||
"version": "9.0.0",
|
"version": "1.0.3",
|
||||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0"
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript": {
|
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript": {
|
||||||
"version": "0.7.0",
|
"version": "0.7.0",
|
||||||
@@ -3720,33 +3719,6 @@
|
|||||||
"npm": ">=9.5.0"
|
"npm": ">=9.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/openapi-core/node_modules/balanced-match": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
|
||||||
"version": "2.1.7",
|
|
||||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
|
||||||
"version": "5.1.9",
|
|
||||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.12.0",
|
"version": "2.12.0",
|
||||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
@@ -6448,6 +6420,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -6495,6 +6468,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.12",
|
"version": "5.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -9097,33 +9071,6 @@
|
|||||||
"minimatch": "^5.0.1"
|
"minimatch": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/filelist/node_modules/balanced-match": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/filelist/node_modules/brace-expansion": {
|
|
||||||
"version": "2.1.7",
|
|
||||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/filelist/node_modules/minimatch": {
|
|
||||||
"version": "5.1.9",
|
|
||||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||||
@@ -9194,22 +9141,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/foreground-child": {
|
|
||||||
"version": "3.3.1",
|
|
||||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"cross-spawn": "^7.0.6",
|
|
||||||
"signal-exit": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/formatly": {
|
"node_modules/formatly": {
|
||||||
"version": "0.11.4",
|
"version": "0.11.4",
|
||||||
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.11.4.tgz",
|
||||||
@@ -9488,14 +9419,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "13.0.6",
|
"version": "14.0.3",
|
||||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz",
|
||||||
|
"integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^11.0.2",
|
||||||
"minipass": "^7.1.3",
|
"minipass": "^7.1.3",
|
||||||
"path-scurry": "^2.0.2"
|
"path-scurry": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
@@ -10670,21 +10602,6 @@
|
|||||||
"node": "^18.17 || >=20.6.1"
|
"node": "^18.17 || >=20.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jackspeak": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/cliui": "^9.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jake": {
|
"node_modules/jake": {
|
||||||
"version": "10.9.4",
|
"version": "10.9.4",
|
||||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||||
@@ -11473,12 +11390,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"resolved": "scripts/minimatch-compat",
|
||||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/minimatch-11": {
|
||||||
|
"name": "minimatch",
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-pWTgz5CN3tjqyZNSK2tePlSmu9IwM41q8yn8Usdqqwe9TIHh+9dEpLLjF4uApsfPEAZY71qtSiA2B2APEr+1Yw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.8"
|
"@isaacs/nonrecursive-glob-match": "^1.0.2",
|
||||||
|
"brace-expansion": "^5.0.12"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
@@ -12212,12 +12136,6 @@
|
|||||||
"quickjs-wasi": "^2.2.0"
|
"quickjs-wasi": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0"
|
|
||||||
},
|
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz",
|
||||||
@@ -12314,8 +12232,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/path-scurry": {
|
"node_modules/path-scurry": {
|
||||||
"version": "2.0.2",
|
"version": "3.0.0",
|
||||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -15929,8 +15848,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite-plugin-pwa": {
|
"node_modules/vite-plugin-pwa": {
|
||||||
"version": "1.3.0",
|
"version": "2.0.0",
|
||||||
"integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==",
|
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -15941,13 +15861,13 @@
|
|||||||
"workbox-window": "^7.4.1"
|
"workbox-window": "^7.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.0",
|
"@vite-pwa/assets-generator": "^1.0.0 || ^2.0.0",
|
||||||
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
"workbox-build": "^7.4.1",
|
"workbox-build": "^7.4.1",
|
||||||
"workbox-window": "^7.4.1"
|
"workbox-window": "^7.4.1"
|
||||||
@@ -17642,30 +17562,6 @@
|
|||||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/workbox-build/node_modules/glob": {
|
|
||||||
"version": "11.1.0",
|
|
||||||
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
|
|
||||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"foreground-child": "^3.3.1",
|
|
||||||
"jackspeak": "^4.1.1",
|
|
||||||
"minimatch": "^10.1.1",
|
|
||||||
"minipass": "^7.1.2",
|
|
||||||
"package-json-from-dist": "^1.0.0",
|
|
||||||
"path-scurry": "^2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"glob": "dist/esm/bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/workbox-build/node_modules/lru-cache": {
|
"node_modules/workbox-build/node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||||
@@ -18076,6 +17972,14 @@
|
|||||||
"react": "19.3.1",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.1"
|
"react-dom": "19.3.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scripts/minimatch-compat": {
|
||||||
|
"name": "minimatch",
|
||||||
|
"version": "11.0.2-compat.0",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch-11": "npm:minimatch@11.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-frontend",
|
"name": "wealth-ledger-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.1",
|
"version": "1.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24.22.1"
|
"node": "24.22.1"
|
||||||
@@ -106,6 +106,7 @@
|
|||||||
"jsdom": "30.1.3",
|
"jsdom": "30.1.3",
|
||||||
"knip": "6.43.0",
|
"knip": "6.43.0",
|
||||||
"lint-staged": "17.6.0",
|
"lint-staged": "17.6.0",
|
||||||
|
"minimatch": "file:scripts/minimatch-compat",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
"openapi-typescript": "7.13.0",
|
"openapi-typescript": "7.13.0",
|
||||||
"playwright": "1.64.1",
|
"playwright": "1.64.1",
|
||||||
@@ -114,7 +115,7 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.72.0",
|
"typescript-eslint": "8.72.0",
|
||||||
"vite": "8.3.5",
|
"vite": "8.3.5",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "2.0.0",
|
||||||
"vitest": "5.0.5"
|
"vitest": "5.0.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -124,7 +125,9 @@
|
|||||||
},
|
},
|
||||||
"@vitest/browser": "5.0.5",
|
"@vitest/browser": "5.0.5",
|
||||||
"@vitest/ui": "5.0.5",
|
"@vitest/ui": "5.0.5",
|
||||||
"@vitest/mocker": "5.0.5"
|
"@vitest/mocker": "5.0.5",
|
||||||
|
"minimatch": "$minimatch",
|
||||||
|
"glob": "14.0.3"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"esbuild@0.28.2": true,
|
"esbuild@0.28.2": true,
|
||||||
|
|||||||
+16
-1
@@ -1,15 +1,30 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
|
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"],
|
||||||
"timezone": "Europe/Moscow",
|
"timezone": "Europe/Moscow",
|
||||||
"labels": ["dependencies"],
|
"labels": ["dependencies"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"rangeStrategy": "pin",
|
"rangeStrategy": "pin",
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": ["before 4am on the first day of the month"]
|
||||||
|
},
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
"groupName": "non-major dependencies"
|
"groupName": "non-major dependencies"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "non-major github actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["docker-compose", "dockerfile"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "non-major container images"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": ["major"],
|
"matchUpdateTypes": ["major"],
|
||||||
"dependencyDashboardApproval": true
|
"dependencyDashboardApproval": true
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// ponytail: CommonJS twin of index.js (import-x ships lib/index.cjs too).
|
||||||
|
const minimatch = require("minimatch-11");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...minimatch,
|
||||||
|
makeRe: (pattern, options = {}) => ({
|
||||||
|
test: path => minimatch.minimatch(path, pattern, options),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// ponytail: shim for eslint-plugin-import-x@4.17.1, which still imports the
|
||||||
|
// makeRe export removed in minimatch 11. Its only caller (no-internal-modules)
|
||||||
|
// uses the result via .test(), so a matcher object is enough. Delete this
|
||||||
|
// package and the override once import-x supports minimatch 11.
|
||||||
|
import { minimatch } from "minimatch-11";
|
||||||
|
|
||||||
|
export * from "minimatch-11";
|
||||||
|
|
||||||
|
export const makeRe = (pattern, options = {}) => ({
|
||||||
|
test: path => minimatch(path, pattern, options),
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "minimatch",
|
||||||
|
"version": "11.0.2-compat.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "minimatch 11 plus the makeRe export eslint-plugin-import-x 4.17.1 still imports. Remove when import-x supports minimatch 11.",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"import": "./index.js",
|
||||||
|
"require": "./index.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch-11": "npm:minimatch@11.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,12 +55,12 @@ const position: Portfolio["positions"][number]
|
|||||||
};
|
};
|
||||||
|
|
||||||
const portfolio: Portfolio = {
|
const portfolio: Portfolio = {
|
||||||
cashBalances: [],
|
cash: { currency: "RUB", value: "0" },
|
||||||
isStale: false,
|
isStale: false,
|
||||||
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
||||||
positions: accounts.map(account => ({ ...position, account })),
|
positions: accounts.map(account => ({ ...position, account })),
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
totals: { currency: "RUB", value: "1200" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const receipt: BrokerageOperation
|
const receipt: BrokerageOperation
|
||||||
@@ -106,14 +106,11 @@ const analytics: IncomeAnalytics
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
period: { from: "2026-08-01", to: "2026-08-31" },
|
period: { from: "2026-08-01", to: "2026-08-31" },
|
||||||
totalsByCurrency: [
|
total: {
|
||||||
{
|
|
||||||
coupon: { currency: "RUB", value: "2" },
|
coupon: { currency: "RUB", value: "2" },
|
||||||
currency: "RUB",
|
|
||||||
dividend: { currency: "RUB", value: "0" },
|
dividend: { currency: "RUB", value: "0" },
|
||||||
total: { currency: "RUB", value: "2" },
|
total: { currency: "RUB", value: "2" },
|
||||||
},
|
},
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CurrentLocation: FC = () => {
|
const CurrentLocation: FC = () => {
|
||||||
@@ -216,7 +213,8 @@ beforeEach(() => {
|
|||||||
type: "coupon",
|
type: "coupon",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotal: { currency: "RUB", value: "0" },
|
||||||
|
window: { from: "2026-09-01", to: "2026-10-01" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -226,9 +224,9 @@ beforeEach(() => {
|
|||||||
describe("All accounts routes", () => {
|
describe("All accounts routes", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["/accounts/all", "Все счета"],
|
["/accounts/all", "Все счета"],
|
||||||
["/accounts/all/operations", "Журнал операций"],
|
["/accounts/all/operations", "История операций"],
|
||||||
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
|
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
|
||||||
["/accounts/all/events", "Будущие события"],
|
["/accounts/all/events", "Предстоящие события"],
|
||||||
])("opens %s directly", async (path, title) => {
|
])("opens %s directly", async (path, title) => {
|
||||||
renderPage(path);
|
renderPage(path);
|
||||||
expect(
|
expect(
|
||||||
@@ -310,9 +308,9 @@ describe("All accounts routes", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const [suffix, tab] of [
|
for (const [suffix, tab] of [
|
||||||
["operations", "Операции"],
|
["operations", "История операций"],
|
||||||
["income", "Доход"],
|
["income", "Доход"],
|
||||||
["events", "События"],
|
["events", "Предстоящие события"],
|
||||||
]) {
|
]) {
|
||||||
fireEvent.click(screen.getByRole("tab", { name: tab }));
|
fireEvent.click(screen.getByRole("tab", { name: tab }));
|
||||||
expect(screen.getByRole("link", { name: "Подробнее" }))
|
expect(screen.getByRole("link", { name: "Подробнее" }))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FC } from "react";
|
import type { ComponentType, FC } from "react";
|
||||||
|
|
||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router";
|
import { Navigate, Route, Routes } from "react-router";
|
||||||
@@ -6,101 +6,35 @@ import { Navigate, Route, Routes } from "react-router";
|
|||||||
import { LazyPage } from "../RouteFallback";
|
import { LazyPage } from "../RouteFallback";
|
||||||
import { Protected, Public } from "../RouteGuards";
|
import { Protected, Public } from "../RouteGuards";
|
||||||
|
|
||||||
const CardPage = lazy(async () => {
|
// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each).
|
||||||
const { CardPage } = await import("@/pages/card");
|
function lazyPage<Name extends string>(
|
||||||
|
load: () => Promise<Record<Name, ComponentType>>,
|
||||||
|
name: Name,
|
||||||
|
) {
|
||||||
|
return lazy(async () => {
|
||||||
|
const page = await load();
|
||||||
|
|
||||||
return { default: CardPage };
|
// eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports.
|
||||||
|
return { default: page[name] };
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const CardsPage = lazy(async () => {
|
const CardPage = lazyPage(async () => import("@/pages/card"), "CardPage");
|
||||||
const { CardsPage } = await import("@/pages/cards");
|
const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage");
|
||||||
|
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
|
||||||
return { default: CardsPage };
|
const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage");
|
||||||
});
|
const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage");
|
||||||
|
const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage");
|
||||||
const BondPage = lazy(async () => {
|
const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage");
|
||||||
const { BondPage } = await import("@/pages/bond");
|
const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage");
|
||||||
|
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
|
||||||
return { default: BondPage };
|
const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage");
|
||||||
});
|
const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage");
|
||||||
|
const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage");
|
||||||
const BondsPage = lazy(async () => {
|
const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage");
|
||||||
const { BondsPage } = await import("@/pages/bonds");
|
const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage");
|
||||||
|
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
|
||||||
return { default: BondsPage };
|
const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout");
|
||||||
});
|
|
||||||
|
|
||||||
const StockPage = lazy(async () => {
|
|
||||||
const { StockPage } = await import("@/pages/stock");
|
|
||||||
|
|
||||||
return { default: StockPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const StocksPage = lazy(async () => {
|
|
||||||
const { StocksPage } = await import("@/pages/stocks");
|
|
||||||
|
|
||||||
return { default: StocksPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountPage = lazy(async () => {
|
|
||||||
const { AccountPage } = await import("@/pages/account");
|
|
||||||
|
|
||||||
return { default: AccountPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountEventsPage = lazy(async () => {
|
|
||||||
const { AccountEventsPage } = await import("@/pages/account-events");
|
|
||||||
|
|
||||||
return { default: AccountEventsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountOperationsPage = lazy(async () => {
|
|
||||||
const { AccountOperationsPage } = await import("@/pages/account-operations");
|
|
||||||
|
|
||||||
return { default: AccountOperationsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountIncomePage = lazy(async () => {
|
|
||||||
const { AccountIncomePage } = await import("@/pages/account-income");
|
|
||||||
|
|
||||||
return { default: AccountIncomePage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountsPage = lazy(async () => {
|
|
||||||
const { AccountsPage } = await import("@/pages/accounts");
|
|
||||||
|
|
||||||
return { default: AccountsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const LoginPage = lazy(async () => {
|
|
||||||
const { LoginPage } = await import("@/pages/login");
|
|
||||||
|
|
||||||
return { default: LoginPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const NotFoundPage = lazy(async () => {
|
|
||||||
const { NotFoundPage } = await import("@/pages/not-found");
|
|
||||||
|
|
||||||
return { default: NotFoundPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const RegisterPage = lazy(async () => {
|
|
||||||
const { RegisterPage } = await import("@/pages/register");
|
|
||||||
|
|
||||||
return { default: RegisterPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const SettingsPage = lazy(async () => {
|
|
||||||
const { SettingsPage } = await import("@/pages/settings");
|
|
||||||
|
|
||||||
return { default: SettingsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const ProtectedLayout = lazy(async () => {
|
|
||||||
const { ProtectedLayout } = await import("../ProtectedLayout");
|
|
||||||
|
|
||||||
return { default: ProtectedLayout };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AppRoutes: FC = () => (
|
const AppRoutes: FC = () => (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useCurrentRoute } from "@/app/model";
|
import { useCurrentRoute } from "@/app/model";
|
||||||
@@ -8,12 +7,12 @@ import { useBankCard } from "@/entities/bank-card";
|
|||||||
import { useBond } from "@/entities/bond";
|
import { useBond } from "@/entities/bond";
|
||||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
import { useStock } from "@/entities/stock";
|
import { useStock } from "@/entities/stock";
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
type DocumentTitleProps = { title: string };
|
type DocumentTitleProps = { title: string };
|
||||||
|
|
||||||
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
|
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
|
||||||
const { t } = useTranslation();
|
useAppDocumentTitle(props.title);
|
||||||
useDocumentTitle(`${props.title} — ${t("appName")}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountDocumentTitleProps = {
|
type AccountDocumentTitleProps = {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { FC, PropsWithChildren } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
|
||||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
|
||||||
import { Component } from "react";
|
import { Component } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
|
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
type BoundaryProps = PropsWithChildren<{
|
type BoundaryProps = PropsWithChildren<{
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
@@ -37,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
|
|||||||
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
||||||
const { authenticated } = props;
|
const { authenticated } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
|
useAppDocumentTitle(t("routeError.title"));
|
||||||
const to = authenticated ? "/accounts" : "/login";
|
const to = authenticated ? "/accounts" : "/login";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ describe("AppBreadcrumbs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["operations", "Операции"],
|
["operations", "История операций"],
|
||||||
["OPERATIONS", "Операции"],
|
["OPERATIONS", "История операций"],
|
||||||
["events", "Будущие события"],
|
["events", "Предстоящие события"],
|
||||||
["income", "Пассивный доход"],
|
["income", "Пассивный доход"],
|
||||||
])("links to the account hierarchy from %s", (section, title) => {
|
])("links to the account hierarchy from %s", (section, title) => {
|
||||||
renderBreadcrumbs(`/accounts/account-1/${section}`, [
|
renderBreadcrumbs(`/accounts/account-1/${section}`, [
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import { useCurrentRoute } from "@/app/model";
|
|||||||
|
|
||||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||||
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
||||||
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
import { EntityBreadcrumbs } from "./EntityBreadcrumbs";
|
||||||
import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
|
||||||
|
|
||||||
const AppBreadcrumbs: FC = () => {
|
const AppBreadcrumbs: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -26,9 +24,16 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
stock,
|
stock,
|
||||||
} = useCurrentRoute();
|
} = useCurrentRoute();
|
||||||
|
|
||||||
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
if (bond) {
|
||||||
if (stock)
|
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />;
|
||||||
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
}
|
||||||
|
|
||||||
|
if (stock) {
|
||||||
|
return (
|
||||||
|
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (account && (section || !account.params.section))
|
if (account && (section || !account.params.section))
|
||||||
return (
|
return (
|
||||||
<Stack gap={0} mb="md">
|
<Stack gap={0} mb="md">
|
||||||
@@ -43,8 +48,13 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
|
|
||||||
|
if (card) {
|
||||||
|
return <EntityBreadcrumbs identifier={card.params.cardId ?? ""} kind="card" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||||
|
|
||||||
if (accounts)
|
if (accounts)
|
||||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useBond } from "@/entities/bond";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type BondBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useBond(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("bond.searchTitle"), to: "/bonds" },
|
|
||||||
{ label: data?.name ?? t("bond.instrument") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { BondBreadcrumbs };
|
|
||||||
@@ -39,4 +39,4 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { BreadcrumbTrail };
|
export { BreadcrumbTrail, type Crumb };
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useBankCard } from "@/entities/bank-card";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type CardBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useBankCard(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("bank.title"), to: "/cards" },
|
|
||||||
{ label: data?.name ?? t("bank.card") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CardBreadcrumbs };
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useBankCard } from "@/entities/bank-card";
|
||||||
|
import { useBond } from "@/entities/bond";
|
||||||
|
import { useStock } from "@/entities/stock";
|
||||||
|
|
||||||
|
import type { Crumb } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
|
type EntityBreadcrumbsProps = {
|
||||||
|
identifier: string;
|
||||||
|
kind:
|
||||||
|
| "bond"
|
||||||
|
| "card"
|
||||||
|
| "stock";
|
||||||
|
};
|
||||||
|
|
||||||
|
const EntityBreadcrumbs: FC<EntityBreadcrumbsProps> = (props) => {
|
||||||
|
const { identifier, kind } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const bond = useBond(kind === "bond" ? identifier : "");
|
||||||
|
const card = useBankCard(kind === "card" ? identifier : "");
|
||||||
|
const stock = useStock(kind === "stock" ? identifier : "");
|
||||||
|
|
||||||
|
let parent: Crumb;
|
||||||
|
let name: string;
|
||||||
|
|
||||||
|
if (kind === "bond") {
|
||||||
|
parent = { label: t("bond.searchTitle"), to: "/bonds" };
|
||||||
|
name = bond.data?.name ?? t("bond.instrument");
|
||||||
|
}
|
||||||
|
else if (kind === "stock") {
|
||||||
|
parent = { label: t("stock.searchTitle"), to: "/stocks" };
|
||||||
|
name = stock.data?.name ?? t("stock.share");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parent = { label: t("bank.title"), to: "/cards" };
|
||||||
|
name = card.data?.name ?? t("bank.card");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BreadcrumbTrail items={[parent, { label: name }]} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EntityBreadcrumbs };
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useStock } from "@/entities/stock";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type StockBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useStock(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("stock.searchTitle"), to: "/stocks" },
|
|
||||||
{ label: data?.name ?? t("stock.share") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { StockBreadcrumbs };
|
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ApiErrorCode,
|
ApiErrorCode,
|
||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
isApiErrorCode,
|
isApiErrorCode,
|
||||||
|
isGetUnder,
|
||||||
} from "@/shared/api";
|
} from "@/shared/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -26,21 +27,14 @@ async function mutationData<T>(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bankCardGetPredicate = isGetUnder(["/api/v1/bank-cards"]);
|
||||||
|
|
||||||
function useBankMutationOptions() {
|
function useBankMutationOptions() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: ({ queryKey }) => {
|
predicate: ({ queryKey }) => bankCardGetPredicate(queryKey),
|
||||||
const [method, path] = queryKey;
|
|
||||||
|
|
||||||
return (
|
|
||||||
method === "get"
|
|
||||||
&& typeof path === "string"
|
|
||||||
&& (path === "/api/v1/bank-cards"
|
|
||||||
|| path.startsWith("/api/v1/bank-cards/"))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ function entryLabelKey(operation: BrokerageOperation): string {
|
|||||||
return `ledger.category.${operation.category}`;
|
return `ledger.category.${operation.category}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCouponPayment(operation: BrokerageOperation): boolean {
|
||||||
|
return isOperationType(operation.type, OperationType.Coupon);
|
||||||
|
}
|
||||||
|
|
||||||
function isIncomePayment(operation: BrokerageOperation): boolean {
|
function isIncomePayment(operation: BrokerageOperation): boolean {
|
||||||
return [OperationType.Coupon, OperationType.Dividend].some(type =>
|
return [OperationType.Coupon, OperationType.Dividend].some(type =>
|
||||||
isOperationType(operation.type, type),
|
isOperationType(operation.type, type),
|
||||||
@@ -131,6 +135,7 @@ function paymentPresentation(
|
|||||||
export {
|
export {
|
||||||
brokerageOperationGroupKey,
|
brokerageOperationGroupKey,
|
||||||
entryLabelKey,
|
entryLabelKey,
|
||||||
|
isCouponPayment,
|
||||||
isIncomePayment,
|
isIncomePayment,
|
||||||
isZeroDecimal,
|
isZeroDecimal,
|
||||||
operationLabelKey,
|
operationLabelKey,
|
||||||
|
|||||||
@@ -55,23 +55,27 @@ function renderGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("OperationGroup", () => {
|
describe("OperationGroup", () => {
|
||||||
it("shows the header, exact total and separate card rows per account", () => {
|
it("shows the received coupon and positive totals in flat account rows", () => {
|
||||||
renderGroup([
|
renderGroup([
|
||||||
operation("1", { id: "broker", name: "Брокерский" }, "0.1"),
|
{ ...operation("1", { id: "broker", name: "Брокерский" }, "0.1"), description: "Выплата купонов СФО ВТБ РКС Эталон 04" },
|
||||||
operation("2", { id: "iis", name: "ИИС" }, "0.2"),
|
operation("2", { id: "iis", name: "ИИС" }, "0.2"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
|
expect(screen.getByText("Выплата купонов СФО ВТБ РКС Эталон 04")).toBeInTheDocument();
|
||||||
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
|
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
|
||||||
|
|
||||||
const accounts = within(screen.getByRole("list", {
|
const accounts = within(screen.getByRole("list", {
|
||||||
name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
|
name: /Выплата купонов СФО ВТБ РКС Эталон 04, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
|
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
|
||||||
.toHaveTextContent("Купон");
|
.toHaveTextContent("Купон");
|
||||||
|
expect(accounts.getByText("+0,1 ₽")).toHaveClass(/positive/);
|
||||||
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
|
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
|
||||||
.toHaveTextContent("Купон");
|
.toHaveTextContent("Купон");
|
||||||
|
expect(accounts.getByText("+0,2 ₽")).toHaveClass(/positive/);
|
||||||
|
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
|
||||||
|
.not.toHaveClass("EntryGroup_accountCardRow");
|
||||||
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
|
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
|
||||||
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
|
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -83,8 +87,10 @@ describe("OperationGroup", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(screen.getByRole("list", {
|
expect(screen.getByRole("list", {
|
||||||
name: /Купон · BOND, Исполнено .*Итог недоступен/,
|
name: /Выплата купонов BOND, Исполнено .*Итог недоступен/,
|
||||||
})).toBeInTheDocument();
|
})).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("—")).not.toHaveClass(/positive/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["0", "0.000"])(
|
it.each(["0", "0.000"])(
|
||||||
@@ -114,6 +120,7 @@ describe("OperationGroup", () => {
|
|||||||
operation("2", { id: "iis", name: "ИИС" }, "-100"),
|
operation("2", { id: "iis", name: "ИИС" }, "-100"),
|
||||||
]);
|
]);
|
||||||
expect(screen.getByText("0 ₽")).toBeInTheDocument();
|
expect(screen.getByText("0 ₽")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("−100 ₽")).toHaveClass(/negative/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders repeated ids inside one group as separate rows", () => {
|
it("renders repeated ids inside one group as separate rows", () => {
|
||||||
@@ -123,7 +130,7 @@ describe("OperationGroup", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
|
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
|
||||||
const accounts = within(screen.getByRole("list", { name: /Купон/ }));
|
const accounts = within(screen.getByRole("list", { name: /Выплата купонов/ }));
|
||||||
expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
|
expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
|
||||||
expect(accounts.getAllByText("ИИС")).toHaveLength(1);
|
expect(accounts.getAllByText("ИИС")).toHaveLength(1);
|
||||||
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
|
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
sumMoney,
|
sumMoney,
|
||||||
totalText,
|
totalText,
|
||||||
} from "@/shared/lib";
|
} from "@/shared/lib";
|
||||||
import { AccountCardRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
|
import { AccountCardRow, AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
|
||||||
|
|
||||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
entryLabelKey,
|
entryLabelKey,
|
||||||
|
isCouponPayment,
|
||||||
isZeroDecimal,
|
isZeroDecimal,
|
||||||
operationLabelKey,
|
operationLabelKey,
|
||||||
paymentPresentation,
|
paymentPresentation,
|
||||||
@@ -37,6 +38,8 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
|
|||||||
? entryLabelKey(first)
|
? entryLabelKey(first)
|
||||||
: operationLabelKey(first.type));
|
: operationLabelKey(first.type));
|
||||||
|
|
||||||
|
const isCoupon = isCouponPayment(first);
|
||||||
|
|
||||||
const instrument
|
const instrument
|
||||||
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
|
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
|
||||||
|
|
||||||
@@ -52,9 +55,21 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const date = formatSnapshotDay(first.occurredAt);
|
const date = formatSnapshotDay(first.occurredAt);
|
||||||
const title = `${typeLabel} · ${instrument}`;
|
const description = first.description.trim();
|
||||||
|
let title = `${typeLabel} · ${instrument}`;
|
||||||
|
|
||||||
|
if (isCoupon) {
|
||||||
|
title = description && description.toLocaleLowerCase() !== "купон"
|
||||||
|
? description
|
||||||
|
: t("ledger.couponPayment", { instrument });
|
||||||
|
}
|
||||||
|
|
||||||
const totalsText = totalText(totals, true);
|
const totalsText = totalText(totals, true);
|
||||||
|
|
||||||
|
const totalTone = isCoupon && totals && !isZeroDecimal(totals.value) && !totals.value.startsWith("-")
|
||||||
|
? "positive"
|
||||||
|
: "neutral";
|
||||||
|
|
||||||
const accessibleName = [
|
const accessibleName = [
|
||||||
title,
|
title,
|
||||||
t("payout.executedAt", { date }),
|
t("payout.executedAt", { date }),
|
||||||
@@ -63,13 +78,14 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryGroup
|
<EntryGroup
|
||||||
|
flat={isCoupon}
|
||||||
header={(
|
header={(
|
||||||
<EntryCard
|
<EntryCard
|
||||||
imageSrc={instrumentLogoUrl(first.logoName)}
|
imageSrc={instrumentLogoUrl(first.logoName)}
|
||||||
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
|
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
|
||||||
metadata={t("payout.executedAt", { date })}
|
metadata={t("payout.executedAt", { date })}
|
||||||
title={title}
|
title={title}
|
||||||
tone="neutral"
|
tone={totalTone}
|
||||||
value={<EntryGroupTotal isSigned totals={totals} />}
|
value={<EntryGroupTotal isSigned totals={totals} />}
|
||||||
valueLabel={typeLabel}
|
valueLabel={typeLabel}
|
||||||
wrap
|
wrap
|
||||||
@@ -80,14 +96,15 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
|
|||||||
{props.entries.map(({ key, operation }) => {
|
{props.entries.map(({ key, operation }) => {
|
||||||
const payment = paymentPresentation(operation);
|
const payment = paymentPresentation(operation);
|
||||||
|
|
||||||
return (
|
const accountRowProps = {
|
||||||
<AccountCardRow
|
accountName: operation.account?.name ?? "—",
|
||||||
accountName={operation.account?.name ?? "—"}
|
value: payment.amount,
|
||||||
key={key}
|
valueLabel: t(payment.label),
|
||||||
value={payment.amount}
|
};
|
||||||
valueLabel={t(payment.label)}
|
|
||||||
/>
|
return isCoupon
|
||||||
);
|
? <AccountRow key={key} {...accountRowProps} tone={payment.tone} />
|
||||||
|
: <AccountCardRow key={key} {...accountRowProps} />;
|
||||||
})}
|
})}
|
||||||
</EntryGroup>
|
</EntryGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type {
|
|||||||
BrokerageAccount,
|
BrokerageAccount,
|
||||||
} from "../api/useBrokerageAccounts";
|
} from "../api/useBrokerageAccounts";
|
||||||
|
|
||||||
const VISIBLE_CURRENCY_LIMIT = 2;
|
|
||||||
|
|
||||||
type BrokerageAccountCard = {
|
type BrokerageAccountCard = {
|
||||||
account: BrokerageAccount;
|
account: BrokerageAccount;
|
||||||
positionCounts: null | PositionCounts;
|
positionCounts: null | PositionCounts;
|
||||||
@@ -13,18 +11,13 @@ type BrokerageAccountCard = {
|
|||||||
summary:
|
summary:
|
||||||
| {
|
| {
|
||||||
portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>;
|
portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>;
|
||||||
remainingCurrencyCount: number;
|
|
||||||
state: "available";
|
state: "available";
|
||||||
totals: Money[];
|
totals: NonNullable<BrokerageAccount["portfolioSummary"]>["totals"];
|
||||||
}
|
}
|
||||||
| { state: "empty" }
|
| { state: "empty" }
|
||||||
| { state: "unavailable" };
|
| { state: "unavailable" };
|
||||||
};
|
};
|
||||||
|
|
||||||
type Money = NonNullable<
|
|
||||||
BrokerageAccount["portfolioSummary"]
|
|
||||||
>["totalsByCurrency"][number];
|
|
||||||
|
|
||||||
type PositionCounts = { bonds: number; stocks: number; total: number };
|
type PositionCounts = { bonds: number; stocks: number; total: number };
|
||||||
|
|
||||||
function accountTypeKey(type: BrokerageAccount["type"]) {
|
function accountTypeKey(type: BrokerageAccount["type"]) {
|
||||||
@@ -57,7 +50,7 @@ function brokerageAccountCards(
|
|||||||
total: summary.totalPositionCount,
|
total: summary.totalPositionCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (summary.totalsByCurrency.length === 0)
|
if (Number(summary.totals.value) === 0)
|
||||||
return { account, positionCounts, source, summary: { state: "empty" } };
|
return { account, positionCounts, source, summary: { state: "empty" } };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -66,12 +59,8 @@ function brokerageAccountCards(
|
|||||||
source,
|
source,
|
||||||
summary: {
|
summary: {
|
||||||
portfolio: summary,
|
portfolio: summary,
|
||||||
remainingCurrencyCount: Math.max(
|
|
||||||
summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
state: "available",
|
state: "available",
|
||||||
totals: summary.totalsByCurrency.slice(0, VISIBLE_CURRENCY_LIMIT),
|
totals: summary.totals,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-2
@@ -65,18 +65,32 @@
|
|||||||
|
|
||||||
.summary {
|
.summary {
|
||||||
font-variant-numeric: tabular-nums lining-nums;
|
font-variant-numeric: tabular-nums lining-nums;
|
||||||
white-space: nowrap;
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currencySummary {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total {
|
.total {
|
||||||
color: var(--wl-color-text);
|
color: var(--wl-color-text);
|
||||||
font-size: var(--mantine-font-size-lg);
|
font-size: var(--mantine-font-size-lg);
|
||||||
line-height: var(--mantine-line-height-lg);
|
line-height: var(--mantine-line-height-lg);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.return,
|
.return,
|
||||||
.cashValue {
|
.cashValue {
|
||||||
font-variant-numeric: tabular-nums lining-nums;
|
font-variant-numeric: tabular-nums lining-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return {
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cashValue {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +152,11 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance > div {
|
.summary {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-53
@@ -14,36 +14,29 @@ type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
|
|||||||
const rubReturn = {
|
const rubReturn = {
|
||||||
absolute: "136900",
|
absolute: "136900",
|
||||||
costBasis: "1103600.50",
|
costBasis: "1103600.50",
|
||||||
currency: "RUB",
|
|
||||||
partial: false,
|
partial: false,
|
||||||
percent: "12.40",
|
percent: "12.40",
|
||||||
};
|
};
|
||||||
|
|
||||||
const portfolioSummary = {
|
const portfolioSummary = {
|
||||||
bondPositionCount: 12,
|
bondPositionCount: 12,
|
||||||
cashBalances: [
|
cash: { currency: "RUB", value: "82500.50" },
|
||||||
{ currency: "RUB", value: "82500.50" },
|
|
||||||
{ currency: "USD", value: "1500" },
|
|
||||||
],
|
|
||||||
fundPositionCount: 2,
|
fundPositionCount: 2,
|
||||||
stockPositionCount: 5,
|
stockPositionCount: 5,
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [{
|
|
||||||
categories: [
|
categories: [
|
||||||
{ sharePercent: "55.000000", type: "STOCK", value: "682500" },
|
{ sharePercent: "55.000000", type: "STOCK", value: "682500" },
|
||||||
{ sharePercent: "25.000000", type: "BOND", value: "310500" },
|
{ sharePercent: "25.000000", type: "BOND", value: "310500" },
|
||||||
{ sharePercent: "10.000000", type: "OTHER", value: "124200" },
|
{ sharePercent: "10.000000", type: "OTHER", value: "124200" },
|
||||||
{ sharePercent: "10.000000", type: "CASH", value: "124200" },
|
{ sharePercent: "10.000000", type: "CASH", value: "124200" },
|
||||||
],
|
],
|
||||||
currency: "RUB",
|
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
totalValue: "1241400",
|
totalValue: "1241400",
|
||||||
}],
|
|
||||||
unvaluedCategories: [],
|
unvaluedCategories: [],
|
||||||
},
|
},
|
||||||
totalPositionCount: 19,
|
totalPositionCount: 19,
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1240500.50" }],
|
totals: { currency: "RUB", value: "1240500.50" },
|
||||||
unrealizedReturnByCurrency: [rubReturn],
|
unrealizedReturn: rubReturn,
|
||||||
} satisfies PortfolioSummary;
|
} satisfies PortfolioSummary;
|
||||||
|
|
||||||
function account(
|
function account(
|
||||||
@@ -63,20 +56,25 @@ function account(
|
|||||||
|
|
||||||
const negativeSummary: PortfolioSummary = {
|
const negativeSummary: PortfolioSummary = {
|
||||||
...portfolioSummary,
|
...portfolioSummary,
|
||||||
unrealizedReturnByCurrency: [{
|
unrealizedReturn: {
|
||||||
...rubReturn,
|
...rubReturn,
|
||||||
absolute: "-4200",
|
absolute: "-4200",
|
||||||
percent: "-3.05",
|
percent: "-3.05",
|
||||||
}],
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptySummary: PortfolioSummary = {
|
const emptySummary: PortfolioSummary = {
|
||||||
...portfolioSummary,
|
...portfolioSummary,
|
||||||
cashBalances: [],
|
cash: { currency: "RUB", value: "0" },
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: {
|
||||||
|
categories: [],
|
||||||
|
hasNegativeValues: false,
|
||||||
|
totalValue: "0",
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
totalPositionCount: 0,
|
totalPositionCount: 0,
|
||||||
totalsByCurrency: [],
|
totals: { currency: "RUB", value: "0" },
|
||||||
unrealizedReturnByCurrency: [],
|
unrealizedReturn: rubReturn,
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
@@ -94,7 +92,7 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
"Карточки счетов: стоимость и доходность по валютам, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.",
|
"Карточки счетов: стоимость и доходность, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -108,40 +106,6 @@ type Story = StoryObj<typeof meta>;
|
|||||||
|
|
||||||
export const Normal: Story = {};
|
export const Normal: Story = {};
|
||||||
|
|
||||||
export const MultiCurrency: Story = {
|
|
||||||
args: {
|
|
||||||
accounts: brokerageAccountCards([account("multi", "Мультивалютный", {
|
|
||||||
...portfolioSummary,
|
|
||||||
cashBalances: [
|
|
||||||
{ currency: "RUB", value: "82500.50" },
|
|
||||||
{ currency: "USD", value: "1500" },
|
|
||||||
],
|
|
||||||
totalsByCurrency: [
|
|
||||||
{ currency: "RUB", value: "1240500.50" },
|
|
||||||
{ currency: "USD", value: "3200" },
|
|
||||||
{ currency: "EUR", value: "900" },
|
|
||||||
],
|
|
||||||
unrealizedReturnByCurrency: [
|
|
||||||
...portfolioSummary.unrealizedReturnByCurrency,
|
|
||||||
{
|
|
||||||
absolute: "20",
|
|
||||||
costBasis: "3180",
|
|
||||||
currency: "USD",
|
|
||||||
partial: false,
|
|
||||||
percent: "2.10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
absolute: "0",
|
|
||||||
costBasis: "900",
|
|
||||||
currency: "EUR",
|
|
||||||
partial: false,
|
|
||||||
percent: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})]),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Negative: Story = {
|
export const Negative: Story = {
|
||||||
args: {
|
args: {
|
||||||
accounts: brokerageAccountCards([
|
accounts: brokerageAccountCards([
|
||||||
@@ -154,10 +118,10 @@ export const Partial: Story = {
|
|||||||
args: {
|
args: {
|
||||||
accounts: brokerageAccountCards([account("partial", "Частичные данные", {
|
accounts: brokerageAccountCards([account("partial", "Частичные данные", {
|
||||||
...portfolioSummary,
|
...portfolioSummary,
|
||||||
unrealizedReturnByCurrency: [{
|
unrealizedReturn: {
|
||||||
...rubReturn,
|
...rubReturn,
|
||||||
partial: true,
|
partial: true,
|
||||||
}],
|
},
|
||||||
})]),
|
})]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+27
-51
@@ -14,55 +14,28 @@ type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
|
|||||||
|
|
||||||
const portfolioSummary = {
|
const portfolioSummary = {
|
||||||
bondPositionCount: 12,
|
bondPositionCount: 12,
|
||||||
cashBalances: [
|
cash: { currency: "RUB", value: "82500.5" },
|
||||||
{ currency: "RUB", value: "82500.5" },
|
|
||||||
{ currency: "USD", value: "20" },
|
|
||||||
],
|
|
||||||
fundPositionCount: 0,
|
fundPositionCount: 0,
|
||||||
stockPositionCount: 5,
|
stockPositionCount: 5,
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [{
|
|
||||||
categories: [
|
categories: [
|
||||||
{ sharePercent: "50.000000", type: "STOCK", value: "100" },
|
{ sharePercent: "50.000000", type: "STOCK", value: "100" },
|
||||||
{ sharePercent: "30.000000", type: "BOND", value: "60" },
|
{ sharePercent: "30.000000", type: "BOND", value: "60" },
|
||||||
{ sharePercent: "0.000000", type: "OTHER", value: "0" },
|
{ sharePercent: "0.000000", type: "OTHER", value: "0" },
|
||||||
{ sharePercent: "20.000000", type: "CASH", value: "40" },
|
{ sharePercent: "20.000000", type: "CASH", value: "40" },
|
||||||
],
|
],
|
||||||
currency: "RUB",
|
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
totalValue: "200",
|
totalValue: "200",
|
||||||
}],
|
|
||||||
unvaluedCategories: [],
|
unvaluedCategories: [],
|
||||||
},
|
},
|
||||||
totalPositionCount: 17,
|
totalPositionCount: 17,
|
||||||
totalsByCurrency: [
|
totals: { currency: "RUB", value: "123456789012345678901.995" },
|
||||||
{ currency: "RUB", value: "123456789012345678901.995" },
|
unrealizedReturn: {
|
||||||
{ currency: "USD", value: "20" },
|
|
||||||
{ currency: "EUR", value: "5" },
|
|
||||||
],
|
|
||||||
unrealizedReturnByCurrency: [
|
|
||||||
{
|
|
||||||
absolute: "136900",
|
absolute: "136900",
|
||||||
costBasis: "1000000",
|
costBasis: "1000000",
|
||||||
currency: "RUB",
|
|
||||||
partial: true,
|
partial: true,
|
||||||
percent: "12.40",
|
percent: "12.40",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
absolute: "-4.2",
|
|
||||||
costBasis: "24.2",
|
|
||||||
currency: "USD",
|
|
||||||
partial: false,
|
|
||||||
percent: "-3.05",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
absolute: "0",
|
|
||||||
costBasis: "5",
|
|
||||||
currency: "EUR",
|
|
||||||
partial: false,
|
|
||||||
percent: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies PortfolioSummary;
|
} satisfies PortfolioSummary;
|
||||||
|
|
||||||
type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][
|
type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][
|
||||||
@@ -97,16 +70,19 @@ describe("BrokerageAccountList", () => {
|
|||||||
it("renders returns, cash and structure with precise totals", () => {
|
it("renders returns, cash and structure with precise totals", () => {
|
||||||
const nullReturnSummary: PortfolioSummary = {
|
const nullReturnSummary: PortfolioSummary = {
|
||||||
...portfolioSummary,
|
...portfolioSummary,
|
||||||
cashBalances: [],
|
structure: {
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
categories: [],
|
||||||
totalsByCurrency: [{ currency: "EUR", value: "5" }],
|
hasNegativeValues: false,
|
||||||
unrealizedReturnByCurrency: [{
|
totalValue: "0",
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
|
totals: { currency: "RUB", value: "5" },
|
||||||
|
unrealizedReturn: {
|
||||||
absolute: "0",
|
absolute: "0",
|
||||||
costBasis: "5",
|
costBasis: "5",
|
||||||
currency: "EUR",
|
|
||||||
partial: false,
|
partial: false,
|
||||||
percent: null,
|
percent: null,
|
||||||
}],
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
renderList(brokerageAccountCards([
|
renderList(brokerageAccountCards([
|
||||||
@@ -118,38 +94,38 @@ describe("BrokerageAccountList", () => {
|
|||||||
expect(link).toHaveAttribute("href", "/accounts/account-1");
|
expect(link).toHaveAttribute("href", "/accounts/account-1");
|
||||||
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
|
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument();
|
expect(within(link).getByText("+12,4 %")).toBeInTheDocument();
|
||||||
expect(within(link).getByText("+12,40 %")).toBeInTheDocument();
|
|
||||||
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
|
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
|
||||||
expect(within(link).getByText("−3,05 %")).toBeInTheDocument();
|
|
||||||
expect(within(link).getByText("−4,2 USD")).toBeInTheDocument();
|
|
||||||
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
|
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
|
||||||
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
|
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
|
||||||
expect(within(link).getByText("частично")).toBeInTheDocument();
|
expect(within(link).getByText("частично")).toBeInTheDocument();
|
||||||
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
|
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
|
||||||
expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0);
|
|
||||||
expect(within(link).getByText("17 позиций")).toBeInTheDocument();
|
expect(within(link).getByText("17 позиций")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("img", {
|
expect(screen.getByRole("img", {
|
||||||
name: "Структура в RUB: Акции 50 %; Облигации 30 %; "
|
name: "Структура: Акции 50,0 %; Облигации 30,0 %; "
|
||||||
+ "Другие активы 0 %; Свободные деньги 20 %",
|
+ "Другие активы 0,0 %; Свободные деньги 20,0 %",
|
||||||
})).toBeInTheDocument();
|
})).toBeInTheDocument();
|
||||||
const stockLegend = within(link).getByText(/Акции 50 %/);
|
const stockLegend = within(link).getByText(/Акции 50,0 %/);
|
||||||
expect(stockLegend).toHaveTextContent("● Акции 50 %");
|
expect(stockLegend).toHaveTextContent("● Акции 50,0 %");
|
||||||
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
|
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
|
||||||
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
|
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
|
||||||
expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument();
|
expect(within(link).queryByText(/Другие активы 0,0 %/))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
expect(link).toHaveTextContent("По данным брокера");
|
expect(link).toHaveTextContent("По данным брокера");
|
||||||
expect(link).toHaveTextContent("Структура RUB: Акции 100");
|
expect(link).toHaveTextContent("Структура: Акции 100");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves empty, unavailable and stale states", () => {
|
it("preserves empty, unavailable and stale states", () => {
|
||||||
const emptySummary: PortfolioSummary = {
|
const emptySummary: PortfolioSummary = {
|
||||||
...portfolioSummary,
|
...portfolioSummary,
|
||||||
cashBalances: [],
|
structure: {
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
categories: [],
|
||||||
|
hasNegativeValues: false,
|
||||||
|
totalValue: "0",
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
totalPositionCount: 0,
|
totalPositionCount: 0,
|
||||||
totalsByCurrency: [],
|
totals: { currency: "RUB", value: "0" },
|
||||||
unrealizedReturnByCurrency: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const cards = brokerageAccountCards([
|
const cards = brokerageAccountCards([
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import classes from "./BrokerageAccountList.module.css";
|
|||||||
const DECIMAL_PART_COUNT = 2;
|
const DECIMAL_PART_COUNT = 2;
|
||||||
const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
|
const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
|
||||||
|
|
||||||
|
const percentFormatter = new Intl.NumberFormat("ru-RU", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
type AvailableSummary = Extract<
|
type AvailableSummary = Extract<
|
||||||
BrokerageAccountCard["summary"],
|
BrokerageAccountCard["summary"],
|
||||||
{ state: "available" }
|
{ state: "available" }
|
||||||
@@ -29,28 +34,23 @@ type BrokerageAccountSummaryProps = {
|
|||||||
type PortfolioSummary = AvailableSummary["portfolio"];
|
type PortfolioSummary = AvailableSummary["portfolio"];
|
||||||
|
|
||||||
type ReturnValueProps = {
|
type ReturnValueProps = {
|
||||||
item: PortfolioSummary["unrealizedReturnByCurrency"][number];
|
item: PortfolioSummary["unrealizedReturn"];
|
||||||
};
|
};
|
||||||
|
|
||||||
type StructureCurrency = PortfolioSummary["structure"]["currencies"][number];
|
type StructureCurrency = PortfolioSummary["structure"];
|
||||||
|
|
||||||
function formatPercent(value: null | string) {
|
function formatPercent(value: null | string) {
|
||||||
if (value === null) return "—";
|
if (value === null) return "—";
|
||||||
|
|
||||||
const formatted = `${value.replace(".", ",")}\u{A0}%`;
|
const rounded = percentFormatter.format(Math.abs(Number(value)));
|
||||||
if (isZero(value))
|
let sign = "";
|
||||||
return value.startsWith("-") ? formatted.slice(1) : formatted;
|
if (rounded !== "0,0") sign = value.startsWith("-") ? "−" : "+";
|
||||||
|
|
||||||
return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
|
return `${sign}${rounded}\u{A0}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStructurePercent(value: string) {
|
function formatStructurePercent(value: string) {
|
||||||
let trimmed = value;
|
return percentFormatter.format(Number(value));
|
||||||
while (trimmed.includes(".") && trimmed.endsWith("0"))
|
|
||||||
trimmed = trimmed.slice(0, -1);
|
|
||||||
if (trimmed.endsWith(".")) trimmed = trimmed.slice(0, -1);
|
|
||||||
|
|
||||||
return trimmed.replace(".", ",");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isZero(value: string) {
|
function isZero(value: string) {
|
||||||
@@ -65,27 +65,23 @@ const ReturnValue: FC<ReturnValueProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { item } = props;
|
const { item } = props;
|
||||||
|
|
||||||
const isNegative = item.percent !== null
|
const displayedPercent = formatPercent(item.percent);
|
||||||
&& item.percent.startsWith("-")
|
const isNegative = displayedPercent.startsWith("−");
|
||||||
&& !isZero(item.percent);
|
const isPositive = displayedPercent.startsWith("+");
|
||||||
|
|
||||||
const isPositive = item.percent !== null
|
|
||||||
&& !item.percent.startsWith("-")
|
|
||||||
&& !isZero(item.percent);
|
|
||||||
|
|
||||||
let toneClass = classes.neutral;
|
let toneClass = classes.neutral;
|
||||||
if (isNegative) toneClass = classes.negative;
|
if (isNegative) toneClass = classes.negative;
|
||||||
else if (isPositive) toneClass = classes.positive;
|
else if (isPositive) toneClass = classes.positive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group className={classes.return} gap="xs" wrap="nowrap">
|
<Group className={classes.return} gap="xs" wrap="wrap">
|
||||||
<Text className={toneClass} fw={700} size="sm">
|
<Text className={toneClass} fw={700} size="sm">
|
||||||
{formatPercent(item.percent)}
|
{displayedPercent}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className={toneClass} size="sm">
|
<Text className={toneClass} size="sm">
|
||||||
{formatMoney(
|
{formatMoney(
|
||||||
{ currency: item.currency, value: item.absolute },
|
{ currency: "RUB", value: item.absolute },
|
||||||
{ withSign: true },
|
{ withSign: true },
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -123,7 +119,6 @@ const StructureBar: FC<StructureBarProps> = (props) => {
|
|||||||
}).join("; ");
|
}).join("; ");
|
||||||
|
|
||||||
const label = t("accounts.structure.label", {
|
const label = t("accounts.structure.label", {
|
||||||
currency: props.item.currency,
|
|
||||||
value: description,
|
value: description,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,51 +195,25 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className={classes.balance} gap="xs">
|
<Stack className={classes.balance} gap="xs">
|
||||||
<Group align="flex-start" gap="lg" justify="flex-end" wrap="nowrap">
|
<Stack className={classes.summary} gap="sm">
|
||||||
<Stack className={classes.summary} gap={4}>
|
<Stack className={classes.currencySummary} gap={2}>
|
||||||
{summary.totals.map(total => (
|
<Text className={classes.total} fw={700}>
|
||||||
<Text className={classes.total} fw={700} key={total.currency}>
|
{formatMoney(summary.totals, { withRoundedCents: true })}
|
||||||
{formatMoney(total, { withRoundedCents: true })}
|
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
|
||||||
|
|
||||||
{!!summary.remainingCurrencyCount && (
|
<ReturnValue item={summary.portfolio.unrealizedReturn} />
|
||||||
<Text c="dimmed" size="sm">
|
</Stack>
|
||||||
{t("accounts.remainingCurrencies", {
|
|
||||||
count: summary.remainingCurrencyCount,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap={4}>
|
|
||||||
{summary.totals.map((total) => {
|
|
||||||
const item = summary.portfolio.unrealizedReturnByCurrency.find(
|
|
||||||
returnItem => returnItem.currency === total.currency,
|
|
||||||
);
|
|
||||||
|
|
||||||
return item
|
|
||||||
? <ReturnValue item={item} key={total.currency} />
|
|
||||||
: null;
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{summary.portfolio.cashBalances.length > 0 && (
|
|
||||||
<Group className={classes.cash} gap="xs" justify="flex-end">
|
<Group className={classes.cash} gap="xs" justify="flex-end">
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{t("accounts.cash")}
|
{t("accounts.cash")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Stack gap={0}>
|
<Text className={classes.cashValue} size="sm">
|
||||||
{summary.portfolio.cashBalances.map(cash => (
|
{formatMoney(summary.portfolio.cash, { withRoundedCents: true })}
|
||||||
<Text className={classes.cashValue} key={cash.currency} size="sm">
|
|
||||||
{formatMoney(cash, { withRoundedCents: true })}
|
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -291,39 +260,31 @@ const AccountDescription: FC<AccountDescriptionProps> = (props) => {
|
|||||||
const { account, summary } = props.account;
|
const { account, summary } = props.account;
|
||||||
if (summary.state !== "available") return null;
|
if (summary.state !== "available") return null;
|
||||||
|
|
||||||
const returns = summary.portfolio.unrealizedReturnByCurrency.map(item =>
|
const returns = [t("accounts.description.return", {
|
||||||
t("accounts.description.return", {
|
|
||||||
absolute: formatMoney(
|
absolute: formatMoney(
|
||||||
{ currency: item.currency, value: item.absolute },
|
{ currency: "RUB", value: summary.portfolio.unrealizedReturn.absolute },
|
||||||
{ withSign: true },
|
{ withSign: true },
|
||||||
),
|
),
|
||||||
currency: item.currency,
|
percent: formatPercent(summary.portfolio.unrealizedReturn.percent),
|
||||||
percent: formatPercent(item.percent),
|
})];
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const structures = summary.portfolio.structure.currencies.map((item) => {
|
const structures = (() => {
|
||||||
const categories = item.categories.map(category =>
|
const categories = summary.portfolio.structure.categories.map(category =>
|
||||||
t("accounts.description.structureCategory", {
|
t("accounts.description.structureCategory", {
|
||||||
category: t(portfolioCategoryPresentation[category.type].label),
|
category: t(portfolioCategoryPresentation[category.type].label),
|
||||||
value: formatMoney(
|
value: formatMoney(
|
||||||
{ currency: item.currency, value: category.value },
|
{ currency: "RUB", value: category.value },
|
||||||
{ withRoundedCents: true },
|
{ withRoundedCents: true },
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
).join(", ");
|
).join(", ");
|
||||||
|
|
||||||
return t("accounts.description.structure", {
|
return t("accounts.description.structure", { value: categories });
|
||||||
currency: item.currency,
|
})();
|
||||||
value: categories,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some(
|
const hasPartialReturn = summary.portfolio.unrealizedReturn.partial;
|
||||||
item => item.partial,
|
|
||||||
);
|
|
||||||
|
|
||||||
const details = [...returns, ...structures].join(". ");
|
const details = [returns, structures].join(". ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisuallyHidden>
|
<VisuallyHidden>
|
||||||
@@ -332,8 +293,7 @@ const AccountDescription: FC<AccountDescriptionProps> = (props) => {
|
|||||||
type: t(accountTypeKey(account.type)),
|
type: t(accountTypeKey(account.type)),
|
||||||
})}
|
})}
|
||||||
{t("accounts.description.currentValue", {
|
{t("accounts.description.currentValue", {
|
||||||
value: summary.portfolio.totalsByCurrency
|
value: formatMoney(summary.portfolio.totals),
|
||||||
.map(total => formatMoney(total)).join(", "),
|
|
||||||
})}
|
})}
|
||||||
{details ? `${details}. ` : ""}
|
{details ? `${details}. ` : ""}
|
||||||
{hasPartialReturn ? t("accounts.brokerDescription") : ""}
|
{hasPartialReturn ? t("accounts.brokerDescription") : ""}
|
||||||
@@ -373,9 +333,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
|||||||
|
|
||||||
{summary.state === "available" && (
|
{summary.state === "available" && (
|
||||||
<Stack className={classes.structure} gap={4}>
|
<Stack className={classes.structure} gap={4}>
|
||||||
{summary.portfolio.structure.currencies.map(item => (
|
<StructureBar item={summary.portfolio.structure} />
|
||||||
<StructureBar item={item} key={item.currency} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ describe("BrokerageEventGroup", () => {
|
|||||||
expect(screen.getByText("0,00 ₽")).toBeInTheDocument();
|
expect(screen.getByText("0,00 ₽")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows separate forecast totals per currency without conversion", () => {
|
it("sums forecast totals exactly", () => {
|
||||||
renderGroup([
|
renderGroup([
|
||||||
event({ id: "broker", name: "Брокерский" }, "10", "USD"),
|
event({ id: "broker", name: "Брокерский" }, "10"),
|
||||||
event({ id: "iis", name: "ИИС" }, "200"),
|
event({ id: "iis", name: "ИИС" }, "200"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(screen.getByRole("list", { name: /10 USD.*200 ₽/ }))
|
expect(screen.getByRole("list", { name: /210 ₽/ }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { apiQueryClient, type paths, useAuthenticatedQuery } from "@/shared/api";
|
||||||
|
|
||||||
|
type Performance = NonNullable<
|
||||||
|
paths["/api/v1/brokerage-accounts/{accountId}/performance"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
function usePerformance(accountId: string) {
|
||||||
|
return useAuthenticatedQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
|
...apiQueryClient.queryOptions(
|
||||||
|
"get",
|
||||||
|
"/api/v1/brokerage-accounts/{accountId}/performance",
|
||||||
|
{ params: { path: { accountId } } },
|
||||||
|
),
|
||||||
|
enabled: Boolean(accountId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type Performance, usePerformance };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { type Performance, usePerformance } from "./api/usePerformance";
|
||||||
@@ -2,7 +2,7 @@ import type { Portfolio } from "../api/usePortfolio";
|
|||||||
|
|
||||||
const PERCENT_SCALE = 100;
|
const PERCENT_SCALE = 100;
|
||||||
|
|
||||||
type Money = Portfolio["totalsByCurrency"][number];
|
type Money = Portfolio["totals"];
|
||||||
|
|
||||||
function formatShare(
|
function formatShare(
|
||||||
numerator: Money | null,
|
numerator: Money | null,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
|
|
||||||
@@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the retry of an exhausted read and only of that read", () => {
|
||||||
|
const refetch = vi.fn(() => Promise.resolve());
|
||||||
|
|
||||||
|
const state = section({
|
||||||
|
data: { rows: [] },
|
||||||
|
error: { code: "TBANK_UNAVAILABLE", status: 503 },
|
||||||
|
isError: true,
|
||||||
|
isFetching: false,
|
||||||
|
refetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.readRetry?.exhausted).toBe(true);
|
||||||
|
state.readRetry?.onRetry();
|
||||||
|
expect(refetch).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no retry for access loss or a healthy read", () => {
|
||||||
|
const base = { data: undefined, isFetching: false, refetch: vi.fn() };
|
||||||
|
|
||||||
|
expect(section({ ...base, error: { code: "NOT_FOUND" }, isError: true })
|
||||||
|
.readRetry?.exhausted).toBe(false);
|
||||||
|
expect(section({ ...base, error: null, isError: false })
|
||||||
|
.readRetry?.exhausted).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
|
||||||
|
|
||||||
type SectionState = {
|
type SectionState = {
|
||||||
error: boolean;
|
error: boolean;
|
||||||
hasData: boolean;
|
hasData: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
|
readRetry?: ReturnType<typeof readRetry>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasSectionData(data: unknown) {
|
function hasSectionData(data: unknown) {
|
||||||
@@ -13,8 +14,11 @@ function hasSectionData(data: unknown) {
|
|||||||
|
|
||||||
function section(query: {
|
function section(query: {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
error?: unknown;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
meta?: InvestmentResourceMeta;
|
meta?: InvestmentResourceMeta;
|
||||||
|
refetch?: () => Promise<unknown>;
|
||||||
}): SectionState {
|
}): SectionState {
|
||||||
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
|
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
|
||||||
const hasData = hasSectionData(query.data);
|
const hasData = hasSectionData(query.data);
|
||||||
@@ -24,6 +28,17 @@ function section(query: {
|
|||||||
hasData,
|
hasData,
|
||||||
loading: !hasData && !isError,
|
loading: !hasData && !isError,
|
||||||
partial: query.meta?.completeness === "incomplete",
|
partial: query.meta?.completeness === "incomplete",
|
||||||
|
// Each section retries only its own GET, so sections recover independently.
|
||||||
|
readRetry: query.refetch
|
||||||
|
? readRetry([
|
||||||
|
{
|
||||||
|
error: query.error,
|
||||||
|
isError: query.isError,
|
||||||
|
isFetching: query.isFetching ?? false,
|
||||||
|
refetch: query.refetch,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
|
|||||||
const average = vm.position?.averagePurchasePrice;
|
const average = vm.position?.averagePurchasePrice;
|
||||||
|
|
||||||
const averageLine = average && props.priceUnit !== "percent"
|
const averageLine = average && props.priceUnit !== "percent"
|
||||||
&& average.currency === vm.candles?.currency
|
|
||||||
? Number(average.value)
|
? Number(average.value)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
|
|||||||
currency:
|
currency:
|
||||||
props.priceUnit === "percent"
|
props.priceUnit === "percent"
|
||||||
? t("bond.percentOfNominal")
|
? t("bond.percentOfNominal")
|
||||||
: (vm.candles?.currency ?? ""),
|
: "₽",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ReadRetry } from "@/shared/ui";
|
||||||
|
|
||||||
import type { SectionState } from "../model/section";
|
import type { SectionState } from "../model/section";
|
||||||
|
|
||||||
import classes from "./InstrumentDetails.module.css";
|
import classes from "./InstrumentDetails.module.css";
|
||||||
@@ -81,6 +83,8 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!!props.state.readRetry && <ReadRetry {...props.state.readRetry} />}
|
||||||
|
|
||||||
{sectionContent}
|
{sectionContent}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ import type { QueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
|
isGetUnder,
|
||||||
type paths,
|
type paths,
|
||||||
} from "@/shared/api";
|
} from "@/shared/api";
|
||||||
|
|
||||||
type TbankConnectionStatus
|
type TbankConnectionStatus
|
||||||
= paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
= paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
|
|
||||||
function isInvestmentPath(path: string) {
|
const investmentGetPredicate = isGetUnder([
|
||||||
return [
|
|
||||||
"/api/v1/brokerage-accounts",
|
"/api/v1/brokerage-accounts",
|
||||||
"/api/v1/stocks",
|
"/api/v1/stocks",
|
||||||
"/api/v1/bonds",
|
"/api/v1/bonds",
|
||||||
].some(root => path === root || path.startsWith(`${root}/`));
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
const tbankConnectionQueryOptions = () =>
|
const tbankConnectionQueryOptions = () =>
|
||||||
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
|
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
|
||||||
@@ -27,15 +26,7 @@ function commitTbankConnectionBoundary(
|
|||||||
) {
|
) {
|
||||||
queryClient.setQueryData(tbankConnectionQueryKey, status);
|
queryClient.setQueryData(tbankConnectionQueryKey, status);
|
||||||
void queryClient.resetQueries({
|
void queryClient.resetQueries({
|
||||||
predicate: ({ queryKey }) => {
|
predicate: ({ queryKey }) => investmentGetPredicate(queryKey),
|
||||||
const [method, path] = queryKey;
|
|
||||||
|
|
||||||
return (
|
|
||||||
method === "get"
|
|
||||||
&& typeof path === "string"
|
|
||||||
&& isInvestmentPath(path)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { server } from "@/test/server";
|
|||||||
|
|
||||||
import { useScreenSync } from "../index";
|
import { useScreenSync } from "../index";
|
||||||
|
|
||||||
|
const TRANSPORT_ATTEMPTS = 3;
|
||||||
|
const TRANSPORT_RECOVERY_MS = 3000;
|
||||||
|
|
||||||
function auth() {
|
function auth() {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "synthetic",
|
accessToken: () => "synthetic",
|
||||||
@@ -28,11 +31,13 @@ describe("read-only screen synchronization", () => {
|
|||||||
gets += 1;
|
gets += 1;
|
||||||
syncModes.push(new URL(request.url).searchParams.get("sync"));
|
syncModes.push(new URL(request.url).searchParams.get("sync"));
|
||||||
|
|
||||||
return gets === 1
|
// The transport retries a transient read twice (1 s, 2 s) on its own.
|
||||||
|
return gets <= TRANSPORT_ATTEMPTS
|
||||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
: HttpResponse.json(resource([]));
|
: HttpResponse.json(resource([]));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => {
|
() => {
|
||||||
@@ -46,12 +51,26 @@ describe("read-only screen synchronization", () => {
|
|||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(gets).toBe(1));
|
try {
|
||||||
await act(async () => result.current.sync.retry());
|
// Exhaust the transport back-off so the query settles in error.
|
||||||
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
|
await vi.waitFor(async () => {
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(TRANSPORT_RECOVERY_MS));
|
||||||
|
expect(result.current.sync.transportError).toBe(true);
|
||||||
|
});
|
||||||
|
expect(gets).toBe(TRANSPORT_ATTEMPTS);
|
||||||
|
|
||||||
expect(gets).toBe(2);
|
await act(async () => result.current.sync.retry());
|
||||||
expect(syncModes).toEqual([null, null]);
|
await vi.waitFor(async () => {
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
expect(result.current.accounts.data).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(gets).toBe(TRANSPORT_ATTEMPTS + 1);
|
||||||
|
expect(syncModes).toEqual(Array.from({ length: gets }, () => null));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes the currently open resources together", async () => {
|
it("refreshes the currently open resources together", async () => {
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ const meta = {
|
|||||||
},
|
},
|
||||||
render: arguments_ => (
|
render: arguments_ => (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>125 000,50 ₽ · 320 USD</Text>
|
<Text>125 000,50 ₽</Text>
|
||||||
|
|
||||||
<InvestmentRefreshState {...arguments_} />
|
<InvestmentRefreshState {...arguments_} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import {
|
|||||||
TbankConnectionError,
|
TbankConnectionError,
|
||||||
TbankConnectionLoading,
|
TbankConnectionLoading,
|
||||||
} from "./TbankConnectionSection.view";
|
} from "./TbankConnectionSection.view";
|
||||||
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
|
import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm";
|
||||||
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
|
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
|
||||||
|
|
||||||
const TbankConnectionSection: FC = () => {
|
const TbankConnectionSection: FC = () => {
|
||||||
const viewModel = useTbankConnectionViewModel();
|
const vm = useTbankConnectionSectionVm();
|
||||||
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
if (vm.state === "loading") return <TbankConnectionLoading />;
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return vm.state === "error"
|
||||||
? <TbankConnectionError retry={viewModel.retry} />
|
? <TbankConnectionError retry={vm.retry} />
|
||||||
: <TbankConnectionSectionLoaded {...viewModel} />;
|
: <TbankConnectionSectionLoaded {...vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { TbankConnectionSection };
|
export { TbankConnectionSection };
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api";
|
|||||||
|
|
||||||
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
|
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
|
||||||
|
|
||||||
function useTbankConnectionViewModel() {
|
function useTbankConnectionSectionVm() {
|
||||||
const status = useAuthenticatedQuery({
|
const status = useAuthenticatedQuery({
|
||||||
...tbankConnectionQueryOptions(),
|
...tbankConnectionQueryOptions(),
|
||||||
});
|
});
|
||||||
@@ -22,4 +22,4 @@ function useTbankConnectionViewModel() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useTbankConnectionViewModel };
|
export { useTbankConnectionSectionVm };
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { resource } from "@/test/resource";
|
|||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { AccountEventsPage } from "./AccountEventsPage";
|
import { AccountEventsPage } from "./AccountEventsPage";
|
||||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
|
||||||
|
|
||||||
const Navigation: FC = () => {
|
const Navigation: FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => {
|
|||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
const { result } = renderHook(() => useAccountEventsPageVm(), {
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
|
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
|
||||||
@@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => {
|
|||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useAccountEventsPageViewModel(),
|
() => useAccountEventsPageVm(),
|
||||||
{
|
{
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -176,7 +176,11 @@ describe("Upcoming event date filters", () => {
|
|||||||
({ request }) => {
|
({ request }) => {
|
||||||
searches.push(new URL(request.url).search);
|
searches.push(new URL(request.url).search);
|
||||||
|
|
||||||
return HttpResponse.json(resource({ events: [] }));
|
return HttpResponse.json(resource({
|
||||||
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
|
events: [],
|
||||||
|
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -223,7 +227,11 @@ describe("Upcoming event date filters", () => {
|
|||||||
"*/api/v1/brokerage-accounts/:accountId/events",
|
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||||
({ request }) =>
|
({ request }) =>
|
||||||
new URL(request.url).searchParams.get("from") === defaultFrom
|
new URL(request.url).searchParams.get("from") === defaultFrom
|
||||||
? HttpResponse.json(resource({ events: [] }))
|
? HttpResponse.json(resource({
|
||||||
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
|
events: [],
|
||||||
|
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||||
|
}))
|
||||||
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
|
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -255,7 +263,8 @@ describe("Upcoming event date filters", () => {
|
|||||||
accountId,
|
accountId,
|
||||||
calculatedAt: "2099-01-01T00:00:00Z",
|
calculatedAt: "2099-01-01T00:00:00Z",
|
||||||
events: [],
|
events: [],
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotal: { currency: "RUB", value: "0" },
|
||||||
|
window: { from: "2099-01-01", to: "2099-01-31" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -264,6 +273,7 @@ describe("Upcoming event date filters", () => {
|
|||||||
renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]);
|
renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]);
|
||||||
const from = await screen.findByLabelText("Период");
|
const from = await screen.findByLabelText("Период");
|
||||||
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
|
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
|
||||||
|
expect(screen.getByText(/Прогноз от .* на 01\.01\.2099 – 31\.01\.2099/)).toBeVisible();
|
||||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||||
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
|
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
|
||||||
|
|
||||||
@@ -320,7 +330,7 @@ describe("Upcoming event date filters", () => {
|
|||||||
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
|
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
|
||||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("За выбранный период событий не найдено"),
|
screen.getByText("На выбранный период предстоящих событий не найдено"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -343,7 +353,7 @@ it("retains the previous complete forecast when its source refresh fails", async
|
|||||||
];
|
];
|
||||||
|
|
||||||
const snapshot = resource(
|
const snapshot = resource(
|
||||||
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
|
{ events, incomeTotal: { currency: "RUB", value: "123.45" } },
|
||||||
{
|
{
|
||||||
lastAttempt: {
|
lastAttempt: {
|
||||||
finishedAt: "2026-08-14T00:01:00Z",
|
finishedAt: "2026-08-14T00:01:00Z",
|
||||||
@@ -361,7 +371,7 @@ it("retains the previous complete forecast when its source refresh fails", async
|
|||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
const { result } = renderHook(() => useAccountEventsPageVm(), {
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={["/accounts/all/events"]}>
|
<MemoryRouter initialEntries={["/accounts/all/events"]}>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
|
import { AccountEventsPage } from "./AccountEventsPage";
|
||||||
|
|
||||||
|
// Automatic read retries wait 1s + 2s before the failure is final.
|
||||||
|
const RETRIES_EXHAUSTED_MS = 6000;
|
||||||
|
const TEST_MS = 10_000;
|
||||||
|
|
||||||
|
const snapshot = resource({
|
||||||
|
accountId: "account-1",
|
||||||
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
date: "2026-10-01",
|
||||||
|
dateKind: "event",
|
||||||
|
displayName: "Synthetic coupon",
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
isin: null,
|
||||||
|
logoName: null,
|
||||||
|
projectedAmount: { currency: "RUB", value: "123.45" },
|
||||||
|
ticker: "SYN",
|
||||||
|
type: "coupon",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
incomeTotal: { currency: "RUB", value: "123.45" },
|
||||||
|
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderEvents() {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "token",
|
||||||
|
clear: vi.fn(),
|
||||||
|
refresh: () => Promise.resolve(true),
|
||||||
|
});
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<MemoryRouter initialEntries={["/accounts/account-1/events"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
element={<AccountEventsPage />}
|
||||||
|
path="/accounts/:accountId/events"
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AccountEventsPage read retry", () => {
|
||||||
|
it(
|
||||||
|
"offers retry after an initial failure and recovers in place",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
let isFailing = true;
|
||||||
|
let requests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
|
||||||
|
requests += 1;
|
||||||
|
|
||||||
|
return isFailing
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(snapshot);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderEvents();
|
||||||
|
|
||||||
|
const retry = await screen.findByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Повторить" },
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requests).toBe(3);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Купон · Synthetic coupon")).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"keeps cached events, warns and retries the same GET",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
let isFailing = false;
|
||||||
|
let requests = 0;
|
||||||
|
let imports = 0;
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/tbank-sync", () => {
|
||||||
|
imports += 1;
|
||||||
|
|
||||||
|
return HttpResponse.json({}, { status: 202 });
|
||||||
|
}),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
|
||||||
|
requests += 1;
|
||||||
|
|
||||||
|
return isFailing
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(snapshot);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { queryClient } = renderEvents();
|
||||||
|
await screen.findByText("Купон · Synthetic coupon");
|
||||||
|
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
|
isFailing = true;
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/events"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const retry = await screen.findByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Повторить" },
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("Не удалось обновить данные"))
|
||||||
|
.not.toBeInTheDocument());
|
||||||
|
expect(requests).toBe(5);
|
||||||
|
expect(imports).toBe(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not offer a plain retry when the account is gone", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
|
HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })),
|
||||||
|
);
|
||||||
|
renderEvents();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("heading", { name: "Загрузка" }))
|
||||||
|
.not.toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,6 +47,7 @@ describe("AccountEventsPage", () => {
|
|||||||
accountId: "account",
|
accountId: "account",
|
||||||
calculatedAt: "2026-08-14T00:00:00Z",
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
events: [],
|
events: [],
|
||||||
|
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||||
},
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -136,6 +137,7 @@ describe("AccountEventsPage", () => {
|
|||||||
type: "offer",
|
type: "offer",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||||
},
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -169,15 +171,18 @@ describe("AccountEventsPage", () => {
|
|||||||
expect(screen.getAllByText("O")).toHaveLength(2);
|
expect(screen.getAllByText("O")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders empty and quiet error states", async () => {
|
it("renders empty and error states with retry", async () => {
|
||||||
const { rerender } = renderPage();
|
const { rerender } = renderPage();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("За выбранный период событий не найдено"),
|
screen.getByText("На выбранный период предстоящих событий не найдено"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
query.mockReturnValue({
|
query.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
error: { code: "TBANK_UNAVAILABLE", status: 503 },
|
||||||
isError: true,
|
isError: true,
|
||||||
|
isFetching: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
} as never);
|
} as never);
|
||||||
rerender(
|
rerender(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
@@ -187,7 +192,7 @@ describe("AccountEventsPage", () => {
|
|||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
|
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible();
|
||||||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Повторить" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a structural busy state before events are available", () => {
|
it("shows a structural busy state before events are available", () => {
|
||||||
@@ -234,7 +239,7 @@ describe("AccountEventsPage", () => {
|
|||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("За выбранный период событий не найдено"),
|
screen.queryByText("На выбранный период предстоящих событий не найдено"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,6 +278,7 @@ describe("AccountEventsPage", () => {
|
|||||||
accountId: "all",
|
accountId: "all",
|
||||||
calculatedAt: "2026-08-14T00:00:00Z",
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
events: [...events],
|
events: [...events],
|
||||||
|
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||||
},
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
|
||||||
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
||||||
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||||
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||||
|
|
||||||
const AccountEventsPage: FC = () => {
|
const AccountEventsPage: FC = () => {
|
||||||
const viewModel = useAccountEventsPageViewModel();
|
const vm = useAccountEventsPageVm();
|
||||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
if (vm.state === "loading") return <AccountEventsPageLoading />;
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return vm.state === "error"
|
||||||
? <AccountEventsPageError {...viewModel} />
|
? <AccountEventsPageError {...vm} />
|
||||||
: <AccountEventsPageLoaded {...viewModel} />;
|
: <AccountEventsPageLoaded {...vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountEventsPage };
|
export { AccountEventsPage };
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
|
|
||||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type BrokerageEvent,
|
type BrokerageEvent,
|
||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
|
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
|
||||||
import {
|
import {
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
isValidPeriod,
|
isValidPeriod,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
type AccountEventsPageViewModel
|
type AccountEventsPageViewModel
|
||||||
= | {
|
= | {
|
||||||
allAccounts: boolean;
|
allAccounts: boolean;
|
||||||
|
calculatedAt: string;
|
||||||
canApply: boolean;
|
canApply: boolean;
|
||||||
draftPeriod: [null | string, null | string];
|
draftPeriod: [null | string, null | string];
|
||||||
events: BrokerageEvent[];
|
events: BrokerageEvent[];
|
||||||
@@ -28,14 +28,20 @@ type AccountEventsPageViewModel
|
|||||||
onPeriodPopoverOpen: () => void;
|
onPeriodPopoverOpen: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
|
readRetry: ReturnType<typeof readRetry>;
|
||||||
source?: InvestmentResourceMeta;
|
source?: InvestmentResourceMeta;
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
totalVisible: boolean;
|
totalVisible: boolean;
|
||||||
|
window: { from: string; to: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
onReset: () => void;
|
||||||
|
readRetry: ReturnType<typeof readRetry>;
|
||||||
|
state: "error";
|
||||||
}
|
}
|
||||||
| { onReset: () => void; state: "error" }
|
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
function useAccountEventsPageVm(): AccountEventsPageViewModel {
|
||||||
const { accountId = "" } = useParams();
|
const { accountId = "" } = useParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -83,6 +89,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
|||||||
isFetching: events.isFetching,
|
isFetching: events.isFetching,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retry = readRetry([events]);
|
||||||
|
|
||||||
if (availability === "loading" && events.isLoading)
|
if (availability === "loading" && events.isLoading)
|
||||||
return { state: "loading" };
|
return { state: "loading" };
|
||||||
|
|
||||||
@@ -94,15 +102,17 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
|||||||
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
|
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
|
||||||
return {
|
return {
|
||||||
onReset,
|
onReset,
|
||||||
|
readRetry: retry,
|
||||||
state: "error",
|
state: "error",
|
||||||
};
|
};
|
||||||
if (!events.data)
|
if (!events.data)
|
||||||
return availability === "unavailable"
|
return availability === "unavailable"
|
||||||
? { onReset, state: "error" }
|
? { onReset, readRetry: retry, state: "error" }
|
||||||
: { state: "loading" };
|
: { state: "loading" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allAccounts: accountId === "all",
|
allAccounts: accountId === "all",
|
||||||
|
calculatedAt: events.data.calculatedAt,
|
||||||
canApply:
|
canApply:
|
||||||
(draftFrom !== appliedFrom || draftTo !== appliedTo)
|
(draftFrom !== appliedFrom || draftTo !== appliedTo)
|
||||||
&& isValidPeriod(draftFrom, draftTo),
|
&& isValidPeriod(draftFrom, draftTo),
|
||||||
@@ -122,10 +132,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
|||||||
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
|
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
|
||||||
onReset,
|
onReset,
|
||||||
partial: events.meta?.completeness === "incomplete",
|
partial: events.meta?.completeness === "incomplete",
|
||||||
|
readRetry: retry,
|
||||||
source: events.meta,
|
source: events.meta,
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
totalVisible: events.meta?.completeness === "complete",
|
totalVisible: events.meta?.completeness === "complete",
|
||||||
|
window: events.data.window,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
|
export { type AccountEventsPageViewModel, useAccountEventsPageVm };
|
||||||
|
|||||||
+13
-1
@@ -4,7 +4,12 @@ import { Button, Group } from "@mantine/core";
|
|||||||
import { StateView } from "@wealthledger/ui";
|
import { StateView } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type AccountEventsPageErrorProps = { onReset: () => void };
|
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
|
||||||
|
|
||||||
|
type AccountEventsPageErrorProps = {
|
||||||
|
onReset: () => void;
|
||||||
|
readRetry: ReadRetryProps;
|
||||||
|
};
|
||||||
|
|
||||||
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -13,6 +18,13 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
|||||||
<StateView
|
<StateView
|
||||||
action={(
|
action={(
|
||||||
<Group>
|
<Group>
|
||||||
|
{props.readRetry.exhausted && (
|
||||||
|
<ReadRetryButton
|
||||||
|
onRetry={props.readRetry.onRetry}
|
||||||
|
retrying={props.readRetry.retrying}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button onClick={props.onReset} variant="default">
|
<Button onClick={props.onReset} variant="default">
|
||||||
{t("ledger.reset")}
|
{t("ledger.reset")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+12
-2
@@ -17,8 +17,8 @@ import {
|
|||||||
brokerageEventGroupKey,
|
brokerageEventGroupKey,
|
||||||
brokerageEventKey,
|
brokerageEventKey,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
import { groupItems } from "@/shared/lib";
|
import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib";
|
||||||
import { SourceSyncStatus } from "@/shared/ui";
|
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||||
|
|
||||||
@@ -83,10 +83,20 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
|||||||
stale={props.source?.freshness === "stale"}
|
stale={props.source?.freshness === "stale"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReadRetry {...props.readRetry} />
|
||||||
|
|
||||||
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||||
|
|
||||||
<Text c="dimmed">{t("events.hint")}</Text>
|
<Text c="dimmed">{t("events.hint")}</Text>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("events.forecastWindow", {
|
||||||
|
date: formatSnapshotTime(props.calculatedAt),
|
||||||
|
from: formatDate(props.window.from),
|
||||||
|
to: formatDate(props.window.to),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Group align="end">
|
<Group align="end">
|
||||||
<DatePickerInput
|
<DatePickerInput
|
||||||
allowSingleDateInRange
|
allowSingleDateInRange
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { IncomeAnalytics } from "@/entities/income";
|
import type { IncomeAnalytics } from "@/entities/income";
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -33,10 +34,14 @@ import { server } from "@/test/server";
|
|||||||
import { toIncomeDisplayModel } from "../../model/display";
|
import { toIncomeDisplayModel } from "../../model/display";
|
||||||
import { AccountIncomePage } from "./AccountIncomePage";
|
import { AccountIncomePage } from "./AccountIncomePage";
|
||||||
import {
|
import {
|
||||||
useAccountIncomePageViewModel,
|
useAccountIncomePageVm,
|
||||||
} from "./AccountIncomePage.vm";
|
} from "./AccountIncomePage.vm";
|
||||||
import { IncomePayments } from "./components/IncomePayments.view";
|
import { IncomePayments } from "./components/IncomePayments.view";
|
||||||
|
|
||||||
|
// Automatic read retries wait 1s + 2s before the failure is final.
|
||||||
|
const RETRIES_EXHAUSTED_MS = 6000;
|
||||||
|
const TEST_MS = 10_000;
|
||||||
|
|
||||||
const analytics
|
const analytics
|
||||||
= {
|
= {
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
@@ -44,7 +49,7 @@ const analytics
|
|||||||
buckets: [
|
buckets: [
|
||||||
{
|
{
|
||||||
period: { from: "2026-08-01", to: "2026-08-01" },
|
period: { from: "2026-08-01", to: "2026-08-01" },
|
||||||
totalsByCurrency: [summary("RUB", "10", "2")],
|
total: summary("10", "2"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
calculatedAt: "2026-08-27T00:00:00Z",
|
calculatedAt: "2026-08-27T00:00:00Z",
|
||||||
@@ -72,7 +77,7 @@ const analytics
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
period: { from: "2026-08-01", to: "2026-08-27" },
|
period: { from: "2026-08-01", to: "2026-08-27" },
|
||||||
totalsByCurrency: [summary("RUB", "10", "2")],
|
total: summary("10", "2"),
|
||||||
} satisfies IncomeAnalytics & { instruments: [unknown] };
|
} satisfies IncomeAnalytics & { instruments: [unknown] };
|
||||||
|
|
||||||
function entry(): IncomeAnalytics["entries"][number] {
|
function entry(): IncomeAnalytics["entries"][number] {
|
||||||
@@ -140,12 +145,11 @@ function renderPage(
|
|||||||
return { ...view, queryClient };
|
return { ...view, queryClient };
|
||||||
}
|
}
|
||||||
|
|
||||||
function summary(currency: string, coupon: string, dividend: string) {
|
function summary(coupon: string, dividend: string) {
|
||||||
return {
|
return {
|
||||||
coupon: money(currency, coupon),
|
coupon: money("RUB", coupon),
|
||||||
currency,
|
dividend: money("RUB", dividend),
|
||||||
dividend: money(currency, dividend),
|
total: money("RUB", String(Number(coupon) + Number(dividend))),
|
||||||
total: money(currency, String(Number(coupon) + Number(dividend))),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +170,7 @@ describe("AccountIncomePage", () => {
|
|||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
|
const { result } = renderHook(() => useAccountIncomePageVm(), {
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
|
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
|
||||||
@@ -303,7 +307,7 @@ describe("AccountIncomePage", () => {
|
|||||||
buckets: [],
|
buckets: [],
|
||||||
entries: [],
|
entries: [],
|
||||||
instruments: [],
|
instruments: [],
|
||||||
totalsByCurrency: [],
|
total: summary("0", "0"),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -403,21 +407,124 @@ describe("AccountIncomePage", () => {
|
|||||||
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a quiet realized error", async () => {
|
it.each([
|
||||||
|
["full", analytics],
|
||||||
|
["empty", { ...analytics, buckets: [], entries: [], instruments: [], total: summary("0", "0") }],
|
||||||
|
])("shows the server source time and last attempt for a %s result", async (_name, data) => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource(data, {
|
||||||
|
freshness: "stale",
|
||||||
|
lastAttempt: {
|
||||||
|
finishedAt: "2026-08-27T01:00:00Z",
|
||||||
|
problems: [],
|
||||||
|
startedAt: "2026-08-27T00:59:00Z",
|
||||||
|
state: "failed",
|
||||||
|
},
|
||||||
|
lastSuccessfulAt: "2026-08-26T12:00:00Z",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByText(/^Данные на /)).toHaveTextContent(
|
||||||
|
formatSnapshotTime("2026-08-26T12:00:00Z"),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"offers retry after an initial failure and recovers in place",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
let isFailing = true;
|
||||||
|
let requests = 0;
|
||||||
server.use(
|
server.use(
|
||||||
http.get(
|
http.get(
|
||||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||||
() => new HttpResponse(null, { status: 503 }),
|
() => {
|
||||||
|
requests += 1;
|
||||||
|
|
||||||
|
return isFailing
|
||||||
|
? new HttpResponse(null, { status: 503 })
|
||||||
|
: HttpResponse.json(resource(analytics));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
|
await screen.findByText(
|
||||||
timeout: 4000,
|
"Не удалось рассчитать пассивный доход",
|
||||||
}),
|
undefined,
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
const retry = screen.getByRole("button", { name: "Повторить" });
|
||||||
|
expect(requests).toBe(3);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("region", { name: "Выплаты" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"keeps cached income, warns and retries the same GET",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
let isFailing = false;
|
||||||
|
let requests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||||
|
() => {
|
||||||
|
requests += 1;
|
||||||
|
|
||||||
|
return isFailing
|
||||||
|
? HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
: HttpResponse.json(resource(analytics));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { queryClient } = renderPage();
|
||||||
|
await screen.findByRole("region", { name: "Выплаты" });
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
|
isFailing = true;
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/income-analytics"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retry = await screen.findByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Повторить" },
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("Не удалось обновить данные"))
|
||||||
|
.not.toBeInTheDocument());
|
||||||
|
expect(requests).toBe(5);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const LocationProbe: FC = () => {
|
const LocationProbe: FC = () => {
|
||||||
@@ -629,11 +736,11 @@ describe("income period and snapshot boundary", () => {
|
|||||||
const instruments = [
|
const instruments = [
|
||||||
{
|
{
|
||||||
...analytics.instruments[0],
|
...analytics.instruments[0],
|
||||||
coupon: money("USD", "10"),
|
coupon: money("RUB", "10"),
|
||||||
dividend: money("USD", "0"),
|
dividend: money("RUB", "0"),
|
||||||
key: "usd",
|
key: "rub-positive",
|
||||||
receiptCount: 1,
|
receiptCount: 1,
|
||||||
total: money("USD", "10"),
|
total: money("RUB", "10"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...analytics.instruments[0],
|
...analytics.instruments[0],
|
||||||
@@ -662,7 +769,7 @@ describe("income period and snapshot boundary", () => {
|
|||||||
payment: instrument.total,
|
payment: instrument.total,
|
||||||
})),
|
})),
|
||||||
instruments,
|
instruments,
|
||||||
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
|
total: summary("-5", "0"),
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -674,10 +781,9 @@ describe("income period and snapshot boundary", () => {
|
|||||||
screen.getByRole("heading", { name: "Выплаты" }),
|
screen.getByRole("heading", { name: "Выплаты" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText("RUB")).not.toBeInTheDocument();
|
expect(screen.queryByText("RUB")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("USD")).not.toBeInTheDocument();
|
|
||||||
const cards = screen.getAllByRole("listitem");
|
const cards = screen.getAllByRole("listitem");
|
||||||
expect(cards).toHaveLength(3);
|
expect(cards).toHaveLength(3);
|
||||||
expect(cards[0]).toHaveTextContent("+10 USD");
|
expect(cards[0]).toHaveTextContent("+10 ₽");
|
||||||
expect(cards[1]).toHaveTextContent("−2 ₽");
|
expect(cards[1]).toHaveTextContent("−2 ₽");
|
||||||
expect(cards[2]).toHaveTextContent("−3 ₽");
|
expect(cards[2]).toHaveTextContent("−3 ₽");
|
||||||
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
|
||||||
@@ -709,11 +815,11 @@ describe("income period and snapshot boundary", () => {
|
|||||||
);
|
);
|
||||||
renderPage(undefined, "all");
|
renderPage(undefined, "all");
|
||||||
|
|
||||||
expect(await screen.findByText("Купон · BOND")).toBeInTheDocument();
|
expect(await screen.findByText("Выплата купонов BOND")).toBeInTheDocument();
|
||||||
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
|
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
|
||||||
|
|
||||||
const accounts = within(screen.getByRole("list", {
|
const accounts = within(screen.getByRole("list", {
|
||||||
name: /Купон · BOND, Исполнено/,
|
name: /Выплата купонов BOND, Исполнено/,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
|
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
|
||||||
@@ -748,7 +854,7 @@ describe("income period and snapshot boundary", () => {
|
|||||||
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
|
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
|
||||||
|
|
||||||
const accounts = within(screen.getByRole("list", {
|
const accounts = within(screen.getByRole("list", {
|
||||||
name: /Купон · BOND/,
|
name: /Выплата купонов BOND/,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
|
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
|
||||||
@@ -781,7 +887,7 @@ describe("income period and snapshot boundary", () => {
|
|||||||
);
|
);
|
||||||
const view = renderPage(undefined, "all");
|
const view = renderPage(undefined, "all");
|
||||||
|
|
||||||
await screen.findByText("Купон · BOND");
|
await screen.findByText("Выплата купонов BOND");
|
||||||
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
|
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
|
||||||
view.unmount();
|
view.unmount();
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
|
import { useAccountIncomePageVm } from "./AccountIncomePage.vm";
|
||||||
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
|
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
|
||||||
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
||||||
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
||||||
|
|
||||||
const AccountIncomePage: FC = () => {
|
const AccountIncomePage: FC = () => {
|
||||||
const viewModel = useAccountIncomePageViewModel();
|
const vm = useAccountIncomePageVm();
|
||||||
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
|
if (vm.state === "loading") return <AccountIncomePageLoading />;
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return vm.state === "error"
|
||||||
? <AccountIncomePageError {...viewModel} />
|
? <AccountIncomePageError {...vm} />
|
||||||
: <AccountIncomePageLoaded {...viewModel} />;
|
: <AccountIncomePageLoaded {...vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountIncomePage };
|
export { AccountIncomePage };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from "react-router";
|
|||||||
|
|
||||||
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
|
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
|
||||||
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
|
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -30,16 +31,19 @@ type AccountIncomePageViewModel
|
|||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onSubmit: (values: IncomeForm) => void;
|
onSubmit: (values: IncomeForm) => void;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
|
readRetry: ReturnType<typeof readRetry>;
|
||||||
|
source?: InvestmentResourceMeta;
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
totalVisible: boolean;
|
totalVisible: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
period: IncomePeriod;
|
period: IncomePeriod;
|
||||||
|
readRetry: ReturnType<typeof readRetry>;
|
||||||
state: "error";
|
state: "error";
|
||||||
}
|
}
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
|
||||||
const { accountId = "" } = useParams();
|
const { accountId = "" } = useParams();
|
||||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||||
|
|
||||||
@@ -84,12 +88,14 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
|||||||
isFetching: analytics.isFetching,
|
isFetching: analytics.isFetching,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retry = readRetry([analytics]);
|
||||||
|
|
||||||
if (availability === "loading" && analytics.isLoading)
|
if (availability === "loading" && analytics.isLoading)
|
||||||
return { state: "loading" };
|
return { state: "loading" };
|
||||||
|
|
||||||
if (!analytics.data)
|
if (!analytics.data)
|
||||||
return availability === "unavailable"
|
return availability === "unavailable"
|
||||||
? { period, state: "error" }
|
? { period, readRetry: retry, state: "error" }
|
||||||
: { state: "loading" };
|
: { state: "loading" };
|
||||||
|
|
||||||
const display = toIncomeDisplayModel(analytics.data);
|
const display = toIncomeDisplayModel(analytics.data);
|
||||||
@@ -116,6 +122,8 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
|||||||
onReset,
|
onReset,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
partial: analytics.meta?.completeness === "incomplete",
|
partial: analytics.meta?.completeness === "incomplete",
|
||||||
|
readRetry: retry,
|
||||||
|
source: analytics.meta,
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
totalVisible: analytics.meta?.completeness === "complete",
|
totalVisible: analytics.meta?.completeness === "complete",
|
||||||
};
|
};
|
||||||
@@ -138,5 +146,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
type AccountIncomePageViewModel,
|
type AccountIncomePageViewModel,
|
||||||
useAccountIncomePageViewModel,
|
useAccountIncomePageVm,
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-1
@@ -3,15 +3,27 @@ import type { FC } from "react";
|
|||||||
import { StateView } from "@wealthledger/ui";
|
import { StateView } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ReadRetryButton } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||||
|
|
||||||
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
|
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
|
||||||
|
|
||||||
const AccountIncomePageError: FC<ErrorProps> = () => {
|
const AccountIncomePageError: FC<ErrorProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const retry = props.readRetry.exhausted
|
||||||
|
? (
|
||||||
|
<ReadRetryButton
|
||||||
|
onRetry={props.readRetry.onRetry}
|
||||||
|
retrying={props.readRetry.retrying}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
|
action={retry}
|
||||||
error
|
error
|
||||||
text={t("income.errorText")}
|
text={t("income.errorText")}
|
||||||
title={t("income.errorTitle")}
|
title={t("income.errorTitle")}
|
||||||
|
|||||||
+13
-8
@@ -5,10 +5,11 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { StateView } from "@wealthledger/ui";
|
|
||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||||
|
|
||||||
import classes from "../AccountIncomePage.module.css";
|
import classes from "../AccountIncomePage.module.css";
|
||||||
@@ -23,13 +24,9 @@ const AccountIncomePageLoadedSummary: FC<LoadedProps> = (props) => {
|
|||||||
|
|
||||||
const { analytics } = props;
|
const { analytics } = props;
|
||||||
|
|
||||||
if (props.partial) {
|
return props.partial
|
||||||
return <Alert role="status">{t("investment.partial")}</Alert>;
|
? <Alert role="status">{t("investment.partial")}</Alert>
|
||||||
}
|
: <IncomeSummary totals={analytics.total} />;
|
||||||
|
|
||||||
return analytics.totalsByCurrency.length > 0
|
|
||||||
? <IncomeSummary totals={analytics.totalsByCurrency} />
|
|
||||||
: <StateView text={t("income.emptyText")} title={t("income.emptyTitle")} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||||
@@ -49,6 +46,14 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|
||||||
|
<SourceSyncStatus
|
||||||
|
lastAttempt={props.source?.lastAttempt}
|
||||||
|
lastSuccessfulAt={props.source?.lastSuccessfulAt}
|
||||||
|
stale={props.source?.freshness === "stale"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReadRetry {...props.readRetry} />
|
||||||
|
|
||||||
<AccountIncomePageLoadedSummary {...props} />
|
<AccountIncomePageLoadedSummary {...props} />
|
||||||
|
|
||||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { IncomeAnalytics } from "@/entities/income";
|
|||||||
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
|
type IncomeSummaryProps = { totals: IncomeAnalytics["total"] };
|
||||||
|
|
||||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -16,16 +16,14 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Title order={2}>{t("income.summaryTitle")}</Title>
|
<Title order={2}>{t("income.summaryTitle")}</Title>
|
||||||
|
|
||||||
{props.totals.map(total => (
|
<Paper p="md" radius="md" withBorder>
|
||||||
<Paper key={total.currency} p="md" radius="md" withBorder>
|
<Text fw={700}>{formatMoney(props.totals.total)}</Text>
|
||||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
|
||||||
|
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
|
{t("income.coupon")}: {formatMoney(props.totals.coupon)} ·{" "}
|
||||||
{t("income.dividend")}: {formatMoney(total.dividend)}
|
{t("income.dividend")}: {formatMoney(props.totals.dividend)}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+96
-13
@@ -90,9 +90,13 @@ function configureTestAuth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(search = "", accountId = "account-1") {
|
function renderPage(
|
||||||
|
search = "",
|
||||||
|
accountId = "account-1",
|
||||||
|
queryClient = createTestQueryClient(),
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={createTestQueryClient()}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter
|
<MemoryRouter
|
||||||
initialEntries={[`/accounts/${accountId}/operations${search}`]}
|
initialEntries={[`/accounts/${accountId}/operations${search}`]}
|
||||||
@@ -109,6 +113,18 @@ function renderPage(search = "", accountId = "account-1") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatic read retries wait 1s + 2s before the failure is final.
|
||||||
|
const RETRIES_EXHAUSTED_MS = 6000;
|
||||||
|
const TEST_MS = 10_000;
|
||||||
|
|
||||||
|
const loadedOperations = {
|
||||||
|
accountId: "account-1",
|
||||||
|
hasNext: false,
|
||||||
|
items: [operation],
|
||||||
|
nextCursor: null,
|
||||||
|
observedAt: "2026-08-14T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
describe("LedgerEntryRow", () => {
|
describe("LedgerEntryRow", () => {
|
||||||
it("marks cashless operations without rendering operation details", () => {
|
it("marks cashless operations without rendering operation details", () => {
|
||||||
render(
|
render(
|
||||||
@@ -424,7 +440,7 @@ describe("AccountOperationsPage", () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
await screen.findByRole("heading", { name: "История операций" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("link", { name: "К счету" }),
|
screen.queryByRole("link", { name: "К счету" }),
|
||||||
@@ -466,7 +482,7 @@ describe("AccountOperationsPage", () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
await screen.findByRole("heading", { name: "История операций" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
await waitFor(() => expect(searches.length).toBeGreaterThan(0));
|
await waitFor(() => expect(searches.length).toBeGreaterThan(0));
|
||||||
const countBefore = searches.length;
|
const countBefore = searches.length;
|
||||||
@@ -505,7 +521,7 @@ describe("AccountOperationsPage", () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
await screen.findByRole("heading", { name: "История операций" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
|
fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
|
||||||
fireEvent.click(await screen.findByText("Налог"));
|
fireEvent.click(await screen.findByText("Налог"));
|
||||||
@@ -545,21 +561,88 @@ describe("AccountOperationsPage", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an error state", async () => {
|
it(
|
||||||
|
"renders an error state with retry that recovers in place",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
|
let isFailing = true;
|
||||||
|
let requests = 0;
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => {
|
||||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
requests += 1;
|
||||||
),
|
|
||||||
|
return isFailing
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(resource(loadedOperations));
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Не удалось загрузить журнал", undefined, {
|
await screen.findByText("Не удалось загрузить историю операций", undefined, {
|
||||||
timeout: 4000,
|
timeout: RETRIES_EXHAUSTED_MS,
|
||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(3);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Покупка акций")).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"keeps cached operations, warns and retries the same GET",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
configureTestAuth();
|
||||||
|
let isFailing = false;
|
||||||
|
let requests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => {
|
||||||
|
requests += 1;
|
||||||
|
|
||||||
|
return isFailing
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(resource(loadedOperations));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
renderPage("", "account-1", queryClient);
|
||||||
|
await screen.findByText("Покупка акций");
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
|
isFailing = true;
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [
|
||||||
|
"get",
|
||||||
|
"/api/v1/brokerage-accounts/{accountId}/operations",
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retry = await screen.findByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Повторить" },
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Покупка акций")).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("Не удалось обновить данные"))
|
||||||
|
.not.toBeInTheDocument());
|
||||||
|
expect(requests).toBe(5);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("keeps a null operations envelope loading instead of a confirmed empty period", async () => {
|
it("keeps a null operations envelope loading instead of a confirmed empty period", async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
server.use(
|
server.use(
|
||||||
@@ -704,10 +787,10 @@ describe("all-account operation groups", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
|
|
||||||
const accounts = await screen.findByRole("list", {
|
const accounts = await screen.findByRole("list", {
|
||||||
name: /Купон · BOND, Исполнено/,
|
name: /Выплата купонов BOND, Исполнено/,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
|
expect(screen.getByText("Выплата купонов BOND")).toBeInTheDocument();
|
||||||
expect(screen.getByText("+200 ₽")).toBeInTheDocument();
|
expect(screen.getByText("+200 ₽")).toBeInTheDocument();
|
||||||
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
|
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL
|
|||||||
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
|
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
|
||||||
|
|
||||||
const AccountOperationsPage: FC = () => {
|
const AccountOperationsPage: FC = () => {
|
||||||
const viewModel = useAccountOperationsPageVm();
|
const vm = useAccountOperationsPageVm();
|
||||||
|
|
||||||
if (viewModel.state === "loading") {
|
if (vm.state === "loading") {
|
||||||
return <AccountOperationsPageLoading />;
|
return <AccountOperationsPageLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return vm.state === "error"
|
||||||
? <AccountOperationsPageError {...viewModel} />
|
? <AccountOperationsPageError {...vm} />
|
||||||
: <AccountOperationsPageLoaded {...viewModel} />;
|
: <AccountOperationsPageLoaded {...vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountOperationsPage };
|
export { AccountOperationsPage };
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
useBrokerageOperationHistory,
|
useBrokerageOperationHistory,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
import {
|
||||||
|
ApiErrorCode,
|
||||||
|
apiErrorCode,
|
||||||
|
isApiErrorCode,
|
||||||
|
readRetry,
|
||||||
|
} from "@/shared/api";
|
||||||
import {
|
import {
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
isValidPeriod,
|
isValidPeriod,
|
||||||
@@ -42,11 +47,16 @@ type AccountOperationsPageViewModel
|
|||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
|
readRetry: ReturnType<typeof readRetry>;
|
||||||
source?: InvestmentResourceMeta;
|
source?: InvestmentResourceMeta;
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
totalVisible: boolean;
|
totalVisible: boolean;
|
||||||
}
|
}
|
||||||
| { onReset: () => void; state: "error" }
|
| {
|
||||||
|
onReset: () => void;
|
||||||
|
readRetry: ReturnType<typeof readRetry>;
|
||||||
|
state: "error";
|
||||||
|
}
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||||
@@ -107,6 +117,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
|||||||
isFetching: operations.isFetching,
|
isFetching: operations.isFetching,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retry = readRetry([operations]);
|
||||||
|
|
||||||
if (availability === "loading" && operations.isLoading)
|
if (availability === "loading" && operations.isLoading)
|
||||||
return { state: "loading" };
|
return { state: "loading" };
|
||||||
|
|
||||||
@@ -134,11 +146,11 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
|||||||
(isInvalidCursor || !operations.data)
|
(isInvalidCursor || !operations.data)
|
||||||
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
|
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
|
||||||
)
|
)
|
||||||
return { onReset, state: "error" };
|
return { onReset, readRetry: retry, state: "error" };
|
||||||
|
|
||||||
if (!operations.data)
|
if (!operations.data)
|
||||||
return availability === "unavailable"
|
return availability === "unavailable"
|
||||||
? { onReset, state: "error" }
|
? { onReset, readRetry: retry, state: "error" }
|
||||||
: { state: "loading" };
|
: { state: "loading" };
|
||||||
|
|
||||||
const nextCursor = operations.data.nextCursor;
|
const nextCursor = operations.data.nextCursor;
|
||||||
@@ -182,6 +194,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
|||||||
onPrevious: history.onPrevious,
|
onPrevious: history.onPrevious,
|
||||||
onReset,
|
onReset,
|
||||||
partial: operations.meta?.completeness === "incomplete",
|
partial: operations.meta?.completeness === "incomplete",
|
||||||
|
readRetry: retry,
|
||||||
source: operations.meta,
|
source: operations.meta,
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
totalVisible: operations.meta?.completeness === "complete",
|
totalVisible: operations.meta?.completeness === "complete",
|
||||||
|
|||||||
+12
-1
@@ -1,9 +1,11 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { Button } from "@mantine/core";
|
import { Button, Group } from "@mantine/core";
|
||||||
import { StateView } from "@wealthledger/ui";
|
import { StateView } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ReadRetryButton } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||||
|
|
||||||
type AccountOperationsPageErrorProps = Extract<
|
type AccountOperationsPageErrorProps = Extract<
|
||||||
@@ -19,9 +21,18 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
|
|||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
action={(
|
action={(
|
||||||
|
<Group gap="sm" justify="center">
|
||||||
|
{props.readRetry.exhausted && (
|
||||||
|
<ReadRetryButton
|
||||||
|
onRetry={props.readRetry.onRetry}
|
||||||
|
retrying={props.readRetry.retrying}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button onClick={props.onReset} variant="default">
|
<Button onClick={props.onReset} variant="default">
|
||||||
{t("ledger.reset")}
|
{t("ledger.reset")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
text={t("ledger.errorText")}
|
text={t("ledger.errorText")}
|
||||||
title={t("ledger.errorTitle")}
|
title={t("ledger.errorTitle")}
|
||||||
|
|||||||
+3
-1
@@ -19,7 +19,7 @@ import {
|
|||||||
OperationGroup,
|
OperationGroup,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { groupItems } from "@/shared/lib";
|
import { groupItems } from "@/shared/lib";
|
||||||
import { SourceSyncStatus } from "@/shared/ui";
|
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||||
|
|
||||||
@@ -102,6 +102,8 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
stale={props.source?.freshness === "stale"}
|
stale={props.source?.freshness === "stale"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReadRetry {...props.readRetry} />
|
||||||
|
|
||||||
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||||
|
|
||||||
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
|
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
|
||||||
|
|||||||
@@ -35,15 +35,13 @@ describe("account concentration", () => {
|
|||||||
const model = toConcentration(snapshot([
|
const model = toConcentration(snapshot([
|
||||||
stock("A", "1"), stock("B", "15"),
|
stock("A", "1"), stock("B", "15"),
|
||||||
stock("C", "0"), stock("D", "-50"), stock("E", null),
|
stock("C", "0"), stock("D", "-50"), stock("E", null),
|
||||||
stock("F", null, "USD"), stock("G", "-3", "USD"),
|
]), "stocks", labels);
|
||||||
]), "stocks", "RUB", labels);
|
|
||||||
|
|
||||||
expect(model.currencies).toEqual(["RUB", "USD"]);
|
|
||||||
expect(model.total).toBe("16");
|
expect(model.total).toBe("16");
|
||||||
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
|
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
|
||||||
expect(model.unaccounted).toBe(3);
|
expect(model.unaccounted).toBe(2);
|
||||||
const negative = snapshot([stock("A", "-3", "USD")]);
|
const negative = snapshot([stock("A", "-3")]);
|
||||||
expect(toConcentration(negative, "stocks", "USD", labels))
|
expect(toConcentration(negative, "stocks", labels))
|
||||||
.toMatchObject({ empty: true, unaccounted: 1 });
|
.toMatchObject({ empty: true, unaccounted: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +50,7 @@ describe("account concentration", () => {
|
|||||||
stock("A", "400"), stock("B", "250"), stock("C", "150"),
|
stock("A", "400"), stock("B", "250"), stock("C", "150"),
|
||||||
stock("D", "100"), stock("E", "50"), stock("F", "30"),
|
stock("D", "100"), stock("E", "50"), stock("F", "30"),
|
||||||
stock("G", "20"),
|
stock("G", "20"),
|
||||||
]), "stocks", "RUB", labels);
|
]), "stocks", labels);
|
||||||
|
|
||||||
expect(model.items.map(item => item.amount)).toEqual([
|
expect(model.items.map(item => item.amount)).toEqual([
|
||||||
"400", "250", "150", "100", "50",
|
"400", "250", "150", "100", "50",
|
||||||
@@ -71,7 +69,7 @@ describe("account concentration", () => {
|
|||||||
stock("Сбер", "100"), stock("X5", "100"),
|
stock("Сбер", "100"), stock("X5", "100"),
|
||||||
stock("VK", "100"), stock("D", "100"),
|
stock("VK", "100"), stock("D", "100"),
|
||||||
stock("E", "100"), stock("F", "100"),
|
stock("E", "100"), stock("F", "100"),
|
||||||
]), "stocks", "RUB", labels);
|
]), "stocks", labels);
|
||||||
|
|
||||||
expect(model.items).toHaveLength(6);
|
expect(model.items).toHaveLength(6);
|
||||||
expect(model.other).toBeNull();
|
expect(model.other).toBeNull();
|
||||||
@@ -83,7 +81,7 @@ describe("account concentration", () => {
|
|||||||
stock("Large", "100000000"), stock("Small", "1"),
|
stock("Large", "100000000"), stock("Small", "1"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share)
|
expect(toConcentration(tiny, "stocks", labels).items[1]?.share)
|
||||||
.toBe("< 0,1 %");
|
.toBe("< 0,1 %");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,9 +101,9 @@ describe("account concentration", () => {
|
|||||||
stock("D", "50", "RUB", { issuer: null, sector: null }),
|
stock("D", "50", "RUB", { issuer: null, sector: null }),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(toConcentration(snapshot(positions), "sectors", "RUB", labels)
|
expect(toConcentration(snapshot(positions), "sectors", labels)
|
||||||
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
|
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
|
||||||
expect(toConcentration(snapshot(positions), "issuers", "RUB", labels)
|
expect(toConcentration(snapshot(positions), "issuers", labels)
|
||||||
.items.map(item => [item.name, item.amount])).toEqual([
|
.items.map(item => [item.name, item.amount])).toEqual([
|
||||||
["Сбербанк", "500"], ["", "50"],
|
["Сбербанк", "500"], ["", "50"],
|
||||||
]);
|
]);
|
||||||
@@ -123,7 +121,7 @@ describe("account concentration", () => {
|
|||||||
stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
|
stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(toConcentration(issuers, "issuers", "RUB", labels)
|
expect(toConcentration(issuers, "issuers", labels)
|
||||||
.items.map(item => [item.label, item.amount])).toEqual([
|
.items.map(item => [item.label, item.amount])).toEqual([
|
||||||
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
|
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
|
||||||
]);
|
]);
|
||||||
@@ -140,7 +138,7 @@ describe("account concentration", () => {
|
|||||||
: "Энергетика",
|
: "Энергетика",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toConcentration(sectors, "sectors", "RUB", sectorLabels)
|
expect(toConcentration(sectors, "sectors", sectorLabels)
|
||||||
.items.map(item => item.label))
|
.items.map(item => item.label))
|
||||||
.toEqual(["Информационные технологии", "Энергетика"]);
|
.toEqual(["Информационные технологии", "Энергетика"]);
|
||||||
});
|
});
|
||||||
@@ -158,7 +156,7 @@ describe("account concentration", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(toConcentration(data, "issuers", "RUB", labels).items
|
expect(toConcentration(data, "issuers", labels).items
|
||||||
.map(item => [item.name, item.label, item.amount])).toEqual([
|
.map(item => [item.name, item.label, item.amount])).toEqual([
|
||||||
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
|
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
|
||||||
]);
|
]);
|
||||||
@@ -169,7 +167,7 @@ describe("account concentration", () => {
|
|||||||
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
|
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
|
||||||
})]);
|
})]);
|
||||||
|
|
||||||
expect(toConcentration(data, "bonds", "RUB", labels)
|
expect(toConcentration(data, "bonds", labels)
|
||||||
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
|
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@/shared/lib";
|
} from "@/shared/lib";
|
||||||
|
|
||||||
type Concentration = {
|
type Concentration = {
|
||||||
currencies: string[];
|
|
||||||
empty: boolean;
|
empty: boolean;
|
||||||
items: ConcentrationItem[];
|
items: ConcentrationItem[];
|
||||||
other: ConcentrationOther | null;
|
other: ConcentrationOther | null;
|
||||||
@@ -83,11 +82,9 @@ function addPosition(
|
|||||||
index: number,
|
index: number,
|
||||||
slice: Slice,
|
slice: Slice,
|
||||||
labels: ConcentrationLabels,
|
labels: ConcentrationLabels,
|
||||||
selected: null | string,
|
|
||||||
): number {
|
): number {
|
||||||
if (!isIncluded(slice, position.type)) return 0;
|
if (!isIncluded(slice, position.type)) return 0;
|
||||||
if (!position.currentValue) return 1;
|
if (!position.currentValue) return 1;
|
||||||
if (position.currentValue.currency !== selected) return 0;
|
|
||||||
const value = decimalFromString(position.currentValue.value);
|
const value = decimalFromString(position.currentValue.value);
|
||||||
if (value.value < 0n) return 1;
|
if (value.value < 0n) return 1;
|
||||||
if (value.value === 0n) return 0;
|
if (value.value === 0n) return 0;
|
||||||
@@ -135,16 +132,6 @@ function compareDecimal(a: Decimal, b: Decimal): number {
|
|||||||
return left < right ? -1 : 0;
|
return left < right ? -1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function concentrationCurrencies(portfolio: Portfolio): string[] {
|
|
||||||
return [...new Set(portfolio.positions
|
|
||||||
.filter(position => eligibleTypes.has(position.type))
|
|
||||||
.filter(position => position.currentValue
|
|
||||||
&& decimalFromString(position.currentValue.value).value !== 0n)
|
|
||||||
.map(position => position.currentValue?.currency)
|
|
||||||
.filter((value): value is string => !!value))]
|
|
||||||
.toSorted((a, b) => a.localeCompare(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupIdentity(
|
function groupIdentity(
|
||||||
position: Position,
|
position: Position,
|
||||||
slice: Slice,
|
slice: Slice,
|
||||||
@@ -214,22 +201,13 @@ function share(part: Decimal, total: Decimal): string {
|
|||||||
function toConcentration(
|
function toConcentration(
|
||||||
portfolio: Portfolio,
|
portfolio: Portfolio,
|
||||||
slice: Slice,
|
slice: Slice,
|
||||||
currency: null | string,
|
|
||||||
labels: ConcentrationLabels,
|
labels: ConcentrationLabels,
|
||||||
): Concentration {
|
): Concentration {
|
||||||
const currencies = concentrationCurrencies(portfolio);
|
|
||||||
|
|
||||||
const selected = currency && currencies.includes(currency)
|
|
||||||
? currency
|
|
||||||
: currencies[0] ?? null;
|
|
||||||
|
|
||||||
const groups = new Map<string, Group>();
|
const groups = new Map<string, Group>();
|
||||||
let unaccounted = 0;
|
let unaccounted = 0;
|
||||||
|
|
||||||
for (const [index, position] of portfolio.positions.entries()) {
|
for (const [index, position] of portfolio.positions.entries()) {
|
||||||
unaccounted += addPosition(
|
unaccounted += addPosition(groups, position, index, slice, labels);
|
||||||
groups, position, index, slice, labels, selected,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranked = Array.from(groups.values(), group => ({
|
const ranked = Array.from(groups.values(), group => ({
|
||||||
@@ -278,7 +256,6 @@ function toConcentration(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currencies,
|
|
||||||
empty: !total,
|
empty: !total,
|
||||||
items: other ? entries.slice(0, TOP) : entries,
|
items: other ? entries.slice(0, TOP) : entries,
|
||||||
other,
|
other,
|
||||||
@@ -289,7 +266,6 @@ function toConcentration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
concentrationCurrencies,
|
|
||||||
toConcentration,
|
toConcentration,
|
||||||
};
|
};
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Performance } from "@/entities/performance";
|
||||||
|
|
||||||
|
import { formatDate, formatMoney, formatPercentagePoints, formatSignedPercent } from "@/shared/lib";
|
||||||
|
|
||||||
|
type TextValue = { tone: Tone; value: string };
|
||||||
|
|
||||||
|
type Tone
|
||||||
|
= | "negative"
|
||||||
|
| "neutral"
|
||||||
|
| "positive";
|
||||||
|
|
||||||
|
const reasonLabels: Record<string, string> = {
|
||||||
|
ACCOUNT_VALUATION_DATES_DIFFER: "Оценки счетов сделаны в разные дни",
|
||||||
|
AMBIGUOUS_RETURN: "У расчёта доходности несколько возможных решений",
|
||||||
|
HISTORY_START_UNKNOWN: "Начало истории счёта неизвестно",
|
||||||
|
INDEX_HISTORY_UNAVAILABLE: "История индекса временно недоступна",
|
||||||
|
NO_SOLUTION: "Расчёт доходности не сходится",
|
||||||
|
OPERATIONS_HISTORY_INCOMPLETE: "История операций ещё не получена полностью",
|
||||||
|
PORTFOLIO_VALUATION_INCOMPLETE: "Нет полной оценки портфеля",
|
||||||
|
UNCONFIRMED_TRANSFER: "Перевод между счетами не удалось подтвердить",
|
||||||
|
UNVALUED_SECURITIES_TRANSFER: "Бумаги переведены без денежной оценки",
|
||||||
|
};
|
||||||
|
|
||||||
|
const unknownReason = "Причину недоступности не удалось определить";
|
||||||
|
|
||||||
|
function performanceVm(data: Performance) {
|
||||||
|
return {
|
||||||
|
basis: data.return.basis,
|
||||||
|
comparison: data.outperformance
|
||||||
|
? {
|
||||||
|
money: signedMoney(data.outperformance.money),
|
||||||
|
points: valueText(
|
||||||
|
data.outperformance.percentagePoints,
|
||||||
|
formatPercentagePoints,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
current: formatMoney(data.currentValue),
|
||||||
|
currentPoint: data.series.length > 0
|
||||||
|
? { date: formatDate(`${data.series.at(-1)?.date ?? data.period.to}T00:00:00Z`) }
|
||||||
|
: null,
|
||||||
|
days: data.period.days,
|
||||||
|
excluded: data.excludedAccounts.map(item => ({
|
||||||
|
id: item.accountId,
|
||||||
|
name: item.name,
|
||||||
|
reason: reasonText(item.reason),
|
||||||
|
})),
|
||||||
|
from: data.period.from,
|
||||||
|
indexReason: data.benchmark.unavailableReason
|
||||||
|
? reasonText(data.benchmark.unavailableReason)
|
||||||
|
: null,
|
||||||
|
indexReturn: valueText(data.benchmark.return.value, formatSignedPercent),
|
||||||
|
indexValue: data.benchmark.value ? formatMoney(data.benchmark.value) : "—",
|
||||||
|
invested: formatMoney(data.netInvested),
|
||||||
|
profit: signedMoney(data.profit),
|
||||||
|
returnReason: data.return.unavailableReason
|
||||||
|
? reasonText(data.return.unavailableReason)
|
||||||
|
: null,
|
||||||
|
returnValue: valueText(data.return.value, formatSignedPercent),
|
||||||
|
series: data.series.map(item => ({
|
||||||
|
benchmarkValue: Number(item.benchmarkValue),
|
||||||
|
date: item.date,
|
||||||
|
label: formatDate(`${item.date}T00:00:00Z`),
|
||||||
|
netInvested: Number(item.netInvested),
|
||||||
|
referenceValue: item.referenceValue === null
|
||||||
|
? null
|
||||||
|
: Number(item.referenceValue),
|
||||||
|
})),
|
||||||
|
to: data.period.to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonText(reason: string): string {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Lookup accepts only an API reason, with a safe fallback.
|
||||||
|
return reasonLabels[reason] ?? unknownReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedMoney(value: Performance["profit"]): TextValue {
|
||||||
|
return {
|
||||||
|
tone: tone(value.value),
|
||||||
|
value: formatMoney(value, { withSign: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tone(value: string): Tone {
|
||||||
|
const number = Number(value);
|
||||||
|
if (number > 0) return "positive";
|
||||||
|
|
||||||
|
return number < 0 ? "negative" : "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tone follows the displayed text, so "0,0 %" is never coloured.
|
||||||
|
function valueText(
|
||||||
|
value: null | string,
|
||||||
|
format: (value: string) => string,
|
||||||
|
): TextValue {
|
||||||
|
if (value === null) return { tone: "neutral", value: "—" };
|
||||||
|
const text = format(value);
|
||||||
|
if (text.startsWith("+")) return { tone: "positive", value: text };
|
||||||
|
|
||||||
|
return { tone: text.startsWith("−") ? "negative" : "neutral", value: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { performanceVm, reasonText };
|
||||||
@@ -2,7 +2,7 @@ import type { Portfolio } from "@/entities/portfolio";
|
|||||||
|
|
||||||
const portfolio
|
const portfolio
|
||||||
= {
|
= {
|
||||||
cashBalances: [{ currency: "USD", value: "1" }],
|
cash: { currency: "RUB", value: "1" },
|
||||||
isStale: true,
|
isStale: true,
|
||||||
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||||||
positions: [
|
positions: [
|
||||||
@@ -49,8 +49,13 @@ const portfolio
|
|||||||
type: "FUND",
|
type: "FUND",
|
||||||
},
|
},
|
||||||
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
|
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: {
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "150" }],
|
categories: [],
|
||||||
|
hasNegativeValues: false,
|
||||||
|
totalValue: "0",
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
|
totals: { currency: "RUB", value: "150" },
|
||||||
} satisfies Portfolio;
|
} satisfies Portfolio;
|
||||||
|
|
||||||
const position: Portfolio["positions"][number] = {
|
const position: Portfolio["positions"][number] = {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function sections(positions: Portfolio["positions"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("account position sections", () => {
|
describe("account position sections", () => {
|
||||||
it("excludes currency instruments only from display, preserving order and share denominators", () => {
|
it("excludes currency mirrors from display and share denominators", () => {
|
||||||
const otherTypes = [
|
const otherTypes = [
|
||||||
"OPTION",
|
"OPTION",
|
||||||
"OTHER",
|
"OTHER",
|
||||||
@@ -35,7 +35,7 @@ describe("account position sections", () => {
|
|||||||
|
|
||||||
const snapshot: Portfolio = {
|
const snapshot: Portfolio = {
|
||||||
...portfolio,
|
...portfolio,
|
||||||
cashBalances: [{ currency: "RUB", value: "1000" }],
|
cash: { currency: "RUB", value: "1000" },
|
||||||
positions: [
|
positions: [
|
||||||
...portfolio.positions,
|
...portfolio.positions,
|
||||||
{
|
{
|
||||||
@@ -56,8 +56,8 @@ describe("account position sections", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
|
totals: { currency: "RUB", value: "1250" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const original = structuredClone(snapshot);
|
const original = structuredClone(snapshot);
|
||||||
@@ -73,12 +73,30 @@ describe("account position sections", () => {
|
|||||||
"FIGI-FUND",
|
"FIGI-FUND",
|
||||||
...otherTypes.map(type => `FIGI-${type}-NEXT`),
|
...otherTypes.map(type => `FIGI-${type}-NEXT`),
|
||||||
]);
|
]);
|
||||||
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
|
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("50 %");
|
||||||
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
|
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("25 %");
|
||||||
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
|
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("25 %");
|
||||||
expect(snapshot).toEqual(original);
|
expect(snapshot).toEqual(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps all-account positions from both accounts without counting mirrored cash", () => {
|
||||||
|
const result = sections([
|
||||||
|
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "100" } }),
|
||||||
|
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "50" }, type: "CURRENCY" }),
|
||||||
|
makePosition({ account: { id: "b", name: "ИИС" }, currentValue: { currency: "RUB", value: "100" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const visible = result.flatMap(section =>
|
||||||
|
section.groups.flatMap(group => group.positions));
|
||||||
|
|
||||||
|
expect(visible)
|
||||||
|
.toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ account: { id: "a", name: "Основной" }, shareText: "50 %" }),
|
||||||
|
expect.objectContaining({ account: { id: "b", name: "ИИС" }, shareText: "50 %" }),
|
||||||
|
]));
|
||||||
|
expect(visible).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("prepares grouped position sections with derived display data", () => {
|
it("prepares grouped position sections with derived display data", () => {
|
||||||
expect(
|
expect(
|
||||||
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
|
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
|
||||||
@@ -175,13 +193,13 @@ describe("account position sections", () => {
|
|||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps currencies and purchase prices attached to their source positions", () => {
|
it("keeps values and purchase prices attached to their source positions", () => {
|
||||||
const source = [
|
const source = [
|
||||||
position,
|
position,
|
||||||
{
|
{
|
||||||
...second,
|
...second,
|
||||||
averagePurchasePrice: { currency: "USD", value: "0.99" },
|
averagePurchasePrice: { currency: "RUB", value: "0.99" },
|
||||||
currentValue: { currency: "USD", value: "1.01" },
|
currentValue: { currency: "RUB", value: "1.01" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -246,10 +264,6 @@ describe("account position returns", () => {
|
|||||||
{ name: "zero quantity", overrides: { quantity: "0" } },
|
{ name: "zero quantity", overrides: { quantity: "0" } },
|
||||||
{ name: "negative quantity", overrides: { quantity: "-1" } },
|
{ name: "negative quantity", overrides: { quantity: "-1" } },
|
||||||
{ name: "missing average price", overrides: { averagePurchasePrice: null } },
|
{ name: "missing average price", overrides: { averagePurchasePrice: null } },
|
||||||
{
|
|
||||||
name: "currency mismatch",
|
|
||||||
overrides: { averagePurchasePrice: { currency: "USD", value: "280" } },
|
|
||||||
},
|
|
||||||
{ name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } },
|
{ name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } },
|
||||||
])("returns null for $name", ({ overrides }) => {
|
])("returns null for $name", ({ overrides }) => {
|
||||||
const result = sections([makePosition({
|
const result = sections([makePosition({
|
||||||
@@ -283,29 +297,24 @@ describe("account position returns", () => {
|
|||||||
expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected);
|
expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sums complete unrealized returns by currency", () => {
|
it("sums complete unrealized returns", () => {
|
||||||
const result = toUnrealizedReturns([
|
const result = toUnrealizedReturns([
|
||||||
makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }),
|
makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }),
|
||||||
makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }),
|
makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }),
|
||||||
makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual({ currency: "RUB", value: "0.3" });
|
||||||
{ currency: "RUB", value: "0.3" },
|
|
||||||
{ currency: "USD", value: "-2.5" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits only currencies with incomplete returns", () => {
|
it("returns null when any return is incomplete", () => {
|
||||||
const result = toUnrealizedReturns([
|
const result = toUnrealizedReturns([
|
||||||
makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }),
|
makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }),
|
||||||
makePosition({
|
makePosition({
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
currentValue: { currency: "RUB", value: "100" },
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
}),
|
}),
|
||||||
makePosition({ absoluteReturn: { currency: "USD", value: "2" } }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result).toEqual([{ currency: "USD", value: "2" }]);
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ function toAccountPagePositionSections(
|
|||||||
const denominators = new Map<string, number>();
|
const denominators = new Map<string, number>();
|
||||||
|
|
||||||
for (const position of portfolio.positions) {
|
for (const position of portfolio.positions) {
|
||||||
if (!position.currentValue) continue;
|
if (position.type === "CURRENCY" || !position.currentValue) continue;
|
||||||
const currency = position.currentValue.currency;
|
const currency = position.currentValue.currency;
|
||||||
denominators.set(
|
denominators.set(
|
||||||
currency,
|
currency,
|
||||||
@@ -249,37 +249,23 @@ function toSection(
|
|||||||
|
|
||||||
function toUnrealizedReturns(
|
function toUnrealizedReturns(
|
||||||
positions: Portfolio["positions"],
|
positions: Portfolio["positions"],
|
||||||
): Money[] {
|
): Money | null {
|
||||||
const totals = new Map<string, {
|
const values: Decimal[] = [];
|
||||||
complete: boolean;
|
|
||||||
values: Decimal[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
const currencies = position.absoluteReturn
|
if (!position.absoluteReturn) {
|
||||||
? [position.absoluteReturn.currency]
|
// A position without any valuation has nothing to contribute.
|
||||||
: [...new Set([
|
|
||||||
position.averagePurchasePrice?.currency,
|
|
||||||
position.currentValue?.currency,
|
|
||||||
].filter((currency): currency is string => !!currency))];
|
|
||||||
|
|
||||||
for (const currency of currencies) {
|
if (!position.averagePurchasePrice && !position.currentValue)
|
||||||
const total = totals.get(currency) ?? { complete: true, values: [] };
|
continue;
|
||||||
|
|
||||||
if (position.absoluteReturn) {
|
return null;
|
||||||
total.values.push(decimalFromString(position.absoluteReturn.value));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
total.complete = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
totals.set(currency, total);
|
values.push(decimalFromString(position.absoluteReturn.value));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...totals].flatMap(([currency, total]) => total.complete
|
return { currency: "RUB", value: decimalToString(sumDecimals(values)) };
|
||||||
? [{ currency, value: decimalToString(sumDecimals(total.values)) }]
|
|
||||||
: []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function toIncomeSummary(
|
|||||||
if (income.meta?.completeness === "incomplete") return { state: "partial", to };
|
if (income.meta?.completeness === "incomplete") return { state: "partial", to };
|
||||||
if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
|
if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
|
||||||
return { state: "error", to };
|
return { state: "error", to };
|
||||||
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
if (!income.data) return { state: "empty", to };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
|
|||||||
|
|
||||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -59,6 +60,10 @@ function configureTestAuth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatic read retries wait 1s + 2s before the failure is final.
|
||||||
|
const RETRIES_EXHAUSTED_MS = 6000;
|
||||||
|
const TEST_MS = 10_000;
|
||||||
|
|
||||||
function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
|
function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ function renderPage(ui: React.ReactNode) {
|
|||||||
|
|
||||||
const portfolio
|
const portfolio
|
||||||
= {
|
= {
|
||||||
cashBalances: [{ currency: "USD", value: "0.5" }],
|
cash: { currency: "RUB", value: "0.5" },
|
||||||
isStale: true,
|
isStale: true,
|
||||||
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||||||
positions: [
|
positions: [
|
||||||
@@ -124,8 +129,8 @@ const portfolio
|
|||||||
type: "BOND",
|
type: "BOND",
|
||||||
},
|
},
|
||||||
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
|
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
|
totals: { currency: "RUB", value: "1234.5" },
|
||||||
} satisfies Portfolio;
|
} satisfies Portfolio;
|
||||||
|
|
||||||
function position(
|
function position(
|
||||||
@@ -196,8 +201,6 @@ const LoadedHarness = (
|
|||||||
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||||||
return {
|
return {
|
||||||
concentration: null,
|
concentration: null,
|
||||||
currency: null,
|
|
||||||
onCurrencyChange: vi.fn(),
|
|
||||||
onSliceChange: vi.fn(),
|
onSliceChange: vi.fn(),
|
||||||
portfolio: nextPortfolio,
|
portfolio: nextPortfolio,
|
||||||
positionSections: toAccountPagePositionSections(nextPortfolio),
|
positionSections: toAccountPagePositionSections(nextPortfolio),
|
||||||
@@ -231,6 +234,37 @@ describe("AccountPage states", () => {
|
|||||||
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
|
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows server source time and attempt for an empty account", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(resource(
|
||||||
|
{
|
||||||
|
...portfolio,
|
||||||
|
cash: { currency: "RUB", value: "0" },
|
||||||
|
isStale: false,
|
||||||
|
positions: [],
|
||||||
|
totals: { currency: "RUB", value: "0" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lastAttempt: {
|
||||||
|
finishedAt: "2026-08-14T01:00:00Z",
|
||||||
|
problems: [],
|
||||||
|
startedAt: "2026-08-14T00:59:00Z",
|
||||||
|
state: "failed",
|
||||||
|
},
|
||||||
|
lastSuccessfulAt: "2026-08-13T12:00:00Z",
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderAccountRoute(createTestQueryClient());
|
||||||
|
expect(await screen.findByText(/^Данные на /)).toHaveTextContent(
|
||||||
|
formatSnapshotTime("2026-08-13T12:00:00Z"),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("expands and resets other when switching slice", () => {
|
it("expands and resets other when switching slice", () => {
|
||||||
const positions = [400, 250, 150, 100, 50, 30, 20].map(
|
const positions = [400, 250, 150, 100, 50, 30, 20].map(
|
||||||
(value, index) => position({
|
(value, index) => position({
|
||||||
@@ -242,7 +276,7 @@ describe("AccountPage states", () => {
|
|||||||
const data = { ...portfolio, positions };
|
const data = { ...portfolio, positions };
|
||||||
|
|
||||||
const model = toConcentration(
|
const model = toConcentration(
|
||||||
data, "stocks", "RUB", concentrationLabels(),
|
data, "stocks", concentrationLabels(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const props = loadedProps(data);
|
const props = loadedProps(data);
|
||||||
@@ -251,7 +285,6 @@ describe("AccountPage states", () => {
|
|||||||
{...props}
|
{...props}
|
||||||
accountId="a"
|
accountId="a"
|
||||||
concentration={model}
|
concentration={model}
|
||||||
currency="RUB"
|
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -290,10 +323,8 @@ describe("AccountPage states", () => {
|
|||||||
concentration={toConcentration(
|
concentration={toConcentration(
|
||||||
{ ...portfolio, positions },
|
{ ...portfolio, positions },
|
||||||
"stocks",
|
"stocks",
|
||||||
"RUB",
|
|
||||||
concentrationLabels(),
|
concentrationLabels(),
|
||||||
)}
|
)}
|
||||||
currency="RUB"
|
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -318,9 +349,8 @@ describe("AccountPage states", () => {
|
|||||||
{...loadedProps(data)}
|
{...loadedProps(data)}
|
||||||
accountId="a"
|
accountId="a"
|
||||||
concentration={toConcentration(
|
concentration={toConcentration(
|
||||||
data, "stocks", "RUB", concentrationLabels(),
|
data, "stocks", concentrationLabels(),
|
||||||
)}
|
)}
|
||||||
currency="RUB"
|
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -334,9 +364,8 @@ describe("AccountPage states", () => {
|
|||||||
{...loadedProps(data)}
|
{...loadedProps(data)}
|
||||||
accountId="a"
|
accountId="a"
|
||||||
concentration={toConcentration(
|
concentration={toConcentration(
|
||||||
data, "issuers", "RUB", concentrationLabels(),
|
data, "issuers", concentrationLabels(),
|
||||||
)}
|
)}
|
||||||
currency="RUB"
|
|
||||||
slice="issuers"
|
slice="issuers"
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
@@ -356,9 +385,8 @@ describe("AccountPage states", () => {
|
|||||||
{...loadedProps(data)}
|
{...loadedProps(data)}
|
||||||
accountId="a"
|
accountId="a"
|
||||||
concentration={toConcentration(
|
concentration={toConcentration(
|
||||||
data, "stocks", "RUB", concentrationLabels(),
|
data, "stocks", concentrationLabels(),
|
||||||
)}
|
)}
|
||||||
currency="RUB"
|
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -388,9 +416,8 @@ describe("AccountPage states", () => {
|
|||||||
{...loadedProps(data)}
|
{...loadedProps(data)}
|
||||||
accountId="a"
|
accountId="a"
|
||||||
concentration={toConcentration(
|
concentration={toConcentration(
|
||||||
data, "sectors", "RUB", concentrationLabels(),
|
data, "sectors", concentrationLabels(),
|
||||||
)}
|
)}
|
||||||
currency="RUB"
|
|
||||||
slice="sectors"
|
slice="sectors"
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
@@ -421,9 +448,8 @@ describe("AccountPage states", () => {
|
|||||||
{...loadedProps(data)}
|
{...loadedProps(data)}
|
||||||
accountId="a"
|
accountId="a"
|
||||||
concentration={toConcentration(
|
concentration={toConcentration(
|
||||||
data, "stocks", "RUB", concentrationLabels(),
|
data, "stocks", concentrationLabels(),
|
||||||
)}
|
)}
|
||||||
currency="RUB"
|
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -448,9 +474,8 @@ describe("AccountPage states", () => {
|
|||||||
{...loadedProps(data)}
|
{...loadedProps(data)}
|
||||||
accountId="a"
|
accountId="a"
|
||||||
concentration={toConcentration(
|
concentration={toConcentration(
|
||||||
data, "stocks", "RUB", concentrationLabels(),
|
data, "stocks", concentrationLabels(),
|
||||||
)}
|
)}
|
||||||
currency="RUB"
|
|
||||||
partial
|
partial
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
@@ -460,16 +485,14 @@ describe("AccountPage states", () => {
|
|||||||
.toHaveLength(1);
|
.toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps slice and currency while switching page tabs and resets on account change", async () => {
|
it("keeps the slice while switching page tabs and resets on account change", async () => {
|
||||||
const multiCurrency = { ...portfolio, positions:
|
const multiCurrency = { ...portfolio, positions:
|
||||||
["RUB", "USD"].flatMap(currency =>
|
|
||||||
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
|
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
|
||||||
position({
|
position({
|
||||||
currentValue: { currency, value: String(value) },
|
currentValue: { currency: "RUB", value: String(value) },
|
||||||
displayName: `${currency} акция ${index + 1}`,
|
displayName: `Акция ${index + 1}`,
|
||||||
sector: "energy", type: "STOCK",
|
sector: "energy", type: "STOCK",
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
) };
|
) };
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
@@ -484,37 +507,27 @@ describe("AccountPage states", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
||||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||||
.toHaveAttribute("aria-expanded", "true");
|
.toHaveAttribute("aria-expanded", "true");
|
||||||
fireEvent.click(screen.getByRole("combobox", { name: "Валюта концентрации" }));
|
|
||||||
fireEvent.click(screen.getByRole("option", { name: "USD" }));
|
|
||||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
|
||||||
.toHaveAttribute("aria-expanded", "false");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
||||||
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||||
fireEvent.click(screen.getByRole("radio", { name: "Акции" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Акции" }));
|
||||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||||
.toHaveAttribute("aria-expanded", "false");
|
.toHaveAttribute("aria-expanded", "false");
|
||||||
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||||
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
|
||||||
.toHaveValue("USD");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Концентрация" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Концентрация" }));
|
||||||
|
|
||||||
expect(screen.getByRole("radio", { name: "Секторы" }))
|
expect(screen.getByRole("radio", { name: "Секторы" }))
|
||||||
.toBeChecked();
|
.toBeChecked();
|
||||||
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
|
||||||
.toHaveValue("USD");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
|
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
|
||||||
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
||||||
expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked();
|
expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked();
|
||||||
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
|
||||||
.toHaveValue("RUB");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the first currency when the selected one disappears", () => {
|
it("includes all ruble positions in concentration", () => {
|
||||||
const data = { ...portfolio, positions: [
|
const data = { ...portfolio, positions: [
|
||||||
position({
|
position({
|
||||||
currentValue: { currency: "USD", value: "200" },
|
currentValue: { currency: "RUB", value: "200" },
|
||||||
}),
|
}),
|
||||||
position({
|
position({
|
||||||
currentValue: { currency: "RUB", value: "100" },
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
@@ -522,12 +535,11 @@ describe("AccountPage states", () => {
|
|||||||
] };
|
] };
|
||||||
|
|
||||||
const model = toConcentration(
|
const model = toConcentration(
|
||||||
data, "stocks", "EUR", concentrationLabels(),
|
data, "stocks", concentrationLabels(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(model.currencies).toEqual(["RUB", "USD"]);
|
expect(model.total).toBe("300");
|
||||||
expect(model.total).toBe("100");
|
expect(model.items.map(item => item.amount)).toEqual(["200", "100"]);
|
||||||
expect(model.items.map(item => item.amount)).toEqual(["100"]);
|
|
||||||
});
|
});
|
||||||
it("shows the loaded portfolio source sync status", async () => {
|
it("shows the loaded portfolio source sync status", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
@@ -643,7 +655,7 @@ describe("AccountPage states", () => {
|
|||||||
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
|
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("definition").map(node => node.textContent),
|
screen.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(["1 234,5 ₽"]);
|
).toEqual(["1 234,50 ₽"]);
|
||||||
});
|
});
|
||||||
it("keeps initial placeholders decorative without amounts or focus targets", () => {
|
it("keeps initial placeholders decorative without amounts or focus targets", () => {
|
||||||
const { container } = renderPage(<AccountPageLoading />);
|
const { container } = renderPage(<AccountPageLoading />);
|
||||||
@@ -686,11 +698,7 @@ describe("AccountPage states", () => {
|
|||||||
<LoadedHarness
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
cashBalances: [
|
cash: { currency: "RUB", value: "234.500000000000000001" },
|
||||||
{ currency: "RUB", value: "234.500000000000000001" },
|
|
||||||
{ currency: "USD", value: "-0.5" },
|
|
||||||
{ currency: "XYZ", value: "3.00" },
|
|
||||||
],
|
|
||||||
positions: [
|
positions: [
|
||||||
...portfolio.positions,
|
...portfolio.positions,
|
||||||
{
|
{
|
||||||
@@ -701,7 +709,7 @@ describe("AccountPage states", () => {
|
|||||||
type: "CURRENCY",
|
type: "CURRENCY",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||||||
})}
|
})}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -714,14 +722,12 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
balances.getAllByRole("definition").map(node => node.textContent),
|
balances.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
|
).toEqual(["234,50 ₽"]);
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("definition").map(node => node.textContent),
|
screen.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
"1 234,5 ₽",
|
"1 234,50 ₽",
|
||||||
"234,500000000000000001 ₽",
|
"234,50 ₽",
|
||||||
"-0,5 $",
|
|
||||||
"3,00 XYZ",
|
|
||||||
]);
|
]);
|
||||||
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
|
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -738,7 +744,7 @@ describe("AccountPage states", () => {
|
|||||||
resource({
|
resource({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: hasPositions ? portfolio.positions : [],
|
positions: hasPositions ? portfolio.positions : [],
|
||||||
totalsByCurrency: [],
|
totals: { currency: "RUB", value: "0" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -746,20 +752,20 @@ describe("AccountPage states", () => {
|
|||||||
renderAccountRoute(createTestQueryClient());
|
renderAccountRoute(createTestQueryClient());
|
||||||
await screen.findByRole("heading", { level: 1, name: "Портфель" });
|
await screen.findByRole("heading", { level: 1, name: "Портфель" });
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
|
||||||
expect(screen.getByRole("definition")).toHaveTextContent("0,5 $");
|
expect(screen.getByText("0,50 ₽")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
|
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ totalsByCurrency: [] },
|
{ empty: true, totals: { currency: "RUB", value: "0" } },
|
||||||
{ totalsByCurrency: [{ currency: "RUB", value: "100" }] },
|
{ empty: false, totals: { currency: "RUB", value: "100" } },
|
||||||
])(
|
])(
|
||||||
"hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)",
|
"hides the empty other section without emptying a currency-only portfolio (totals: $totals.value)",
|
||||||
async ({ totalsByCurrency }) => {
|
async ({ empty, totals }) => {
|
||||||
const snapshot: Portfolio = {
|
const snapshot: Portfolio = {
|
||||||
...portfolio,
|
...portfolio,
|
||||||
cashBalances: [],
|
cash: { currency: "RUB", value: "0" },
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
...portfolio.positions[0],
|
...portfolio.positions[0],
|
||||||
@@ -768,8 +774,8 @@ describe("AccountPage states", () => {
|
|||||||
type: "CURRENCY",
|
type: "CURRENCY",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||||||
totalsByCurrency,
|
totals,
|
||||||
};
|
};
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
@@ -790,11 +796,11 @@ describe("AccountPage states", () => {
|
|||||||
expect(
|
expect(
|
||||||
within(
|
within(
|
||||||
screen.getByRole("tabpanel", { name: "Деньги" }),
|
screen.getByRole("tabpanel", { name: "Деньги" }),
|
||||||
).queryByRole("definition"),
|
).getByRole("definition"),
|
||||||
).not.toBeInTheDocument();
|
).toHaveTextContent("0,00 ₽");
|
||||||
expect(
|
expect(
|
||||||
screen.queryAllByRole("definition").map(node => node.textContent),
|
screen.queryAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(totalsByCurrency.length > 0 ? ["100 ₽"] : []);
|
).toEqual([empty ? "0,00 ₽" : "100,00 ₽", "0,00 ₽"]);
|
||||||
expect(
|
expect(
|
||||||
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
|
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
|
||||||
).toEqual(resource(snapshot));
|
).toEqual(resource(snapshot));
|
||||||
@@ -814,9 +820,9 @@ describe("AccountPage states", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "Портфель пока пуст" }),
|
screen.getByRole("heading", { name: "Портфель пока пуст" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: "Открыть журнал" }))
|
expect(screen.getByRole("link", { name: "Открыть историю операций" }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: "Открыть будущие события" }))
|
expect(screen.getByRole("link", { name: "Открыть предстоящие события" }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: "Открыть пассивный доход" }))
|
expect(screen.getByRole("link", { name: "Открыть пассивный доход" }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
@@ -857,10 +863,10 @@ describe("AccountPage states", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("status", { name: "Загрузка будущих событий" }),
|
screen.getByRole("status", { name: "Загрузка предстоящих событий" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("На ближайшие 30 дней событий не найдено."),
|
screen.queryByText("На ближайшие 30 дней предстоящих событий не найдено."),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible();
|
expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible();
|
||||||
expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument();
|
expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument();
|
||||||
@@ -874,7 +880,6 @@ describe("AccountPage states", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/T-Bank временно недоступен/))
|
expect(screen.queryByText(/T-Bank временно недоступен/))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("radio", { name: "Облигации" }),
|
screen.getByRole("radio", { name: "Облигации" }),
|
||||||
@@ -1041,7 +1046,7 @@ describe("AccountPage states", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
|
fireEvent.click(screen.getByRole("tab", { name: "История операций" }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Пока недоступно"),
|
screen.getByText("Пока недоступно"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -1080,7 +1085,7 @@ describe("AccountPage states", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
|
fireEvent.click(screen.getByRole("tab", { name: "История операций" }));
|
||||||
expect(screen.getByText("Сделка")).toBeInTheDocument();
|
expect(screen.getByText("Сделка")).toBeInTheDocument();
|
||||||
expect(screen.getByText("−12 ₽")).toBeInTheDocument();
|
expect(screen.getByText("−12 ₽")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -1112,7 +1117,7 @@ describe("AccountPage states", () => {
|
|||||||
date: "2026-09-11",
|
date: "2026-09-11",
|
||||||
dateKind: "registry",
|
dateKind: "registry",
|
||||||
displayName: "Акция",
|
displayName: "Акция",
|
||||||
projectedAmount: { currency: "USD", value: "0" },
|
projectedAmount: { currency: "RUB", value: "0" },
|
||||||
type: "dividend",
|
type: "dividend",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1134,8 +1139,8 @@ describe("AccountPage states", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "События" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" }));
|
||||||
const section = screen.getByRole("tabpanel", { name: "События" });
|
const section = screen.getByRole("tabpanel", { name: "Предстоящие события" });
|
||||||
const content = within(section);
|
const content = within(section);
|
||||||
const text = section.textContent ?? "";
|
const text = section.textContent ?? "";
|
||||||
|
|
||||||
@@ -1156,7 +1161,7 @@ describe("AccountPage states", () => {
|
|||||||
expect(
|
expect(
|
||||||
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
|
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
|
for (const amount of ["850,50 ₽", "0 ₽", "1 000 ₽", "Сумма неизвестна"])
|
||||||
expect(content.getByText(amount)).toBeVisible();
|
expect(content.getByText(amount)).toBeVisible();
|
||||||
expect(content.getAllByText("Прогноз")).toHaveLength(4);
|
expect(content.getAllByText("Прогноз")).toHaveLength(4);
|
||||||
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
|
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
|
||||||
@@ -1176,15 +1181,15 @@ describe("AccountPage states", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "События" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" }));
|
||||||
|
|
||||||
const content = within(
|
const content = within(
|
||||||
screen.getByRole("tabpanel", { name: "События" }),
|
screen.getByRole("tabpanel", { name: "Предстоящие события" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (state === "loading") {
|
if (state === "loading") {
|
||||||
const skeleton = content.getByRole("status", {
|
const skeleton = content.getByRole("status", {
|
||||||
name: "Загрузка будущих событий",
|
name: "Загрузка предстоящих событий",
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
||||||
@@ -1195,7 +1200,7 @@ describe("AccountPage states", () => {
|
|||||||
else {
|
else {
|
||||||
// eslint-disable-next-line vitest/no-conditional-expect -- The table case deterministically selects the empty state.
|
// eslint-disable-next-line vitest/no-conditional-expect -- The table case deterministically selects the empty state.
|
||||||
expect(
|
expect(
|
||||||
content.getByText("На ближайшие 30 дней событий не найдено."),
|
content.getByText("На ближайшие 30 дней предстоящих событий не найдено."),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,7 +1221,7 @@ describe("AccountPage states", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "События" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Данные появятся здесь, когда чтение восстановится."),
|
screen.getByText("Данные появятся здесь, когда чтение восстановится."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -1224,6 +1229,110 @@ describe("AccountPage states", () => {
|
|||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"keeps cached totals, then warns and retries the same portfolio GET",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
let isFailing = false;
|
||||||
|
let requests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||||
|
requests += 1;
|
||||||
|
if (isFailing)
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return HttpResponse.json(resource(
|
||||||
|
requests === 1
|
||||||
|
? portfolio
|
||||||
|
: { ...portfolio, totals: { currency: "RUB", value: "999" } },
|
||||||
|
{ resourceKey: "portfolio" },
|
||||||
|
));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
renderAccountRoute(queryClient);
|
||||||
|
await screen.findByText("Вторая");
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
|
isFailing = true;
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const retry = await screen.findByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Повторить" },
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 234,50 ₽")).toBeInTheDocument();
|
||||||
|
// One cached read plus the three automatic attempts.
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
expect(await screen.findByText("999,00 ₽")).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(5);
|
||||||
|
expect(screen.queryByText("Не удалось обновить данные"))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"offers retry after an initial temporary failure and recovers in place",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
let isFailing = true;
|
||||||
|
let requests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||||
|
requests += 1;
|
||||||
|
|
||||||
|
return isFailing
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(resource(portfolio, { resourceKey: "portfolio" }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderAccountRoute(createTestQueryClient());
|
||||||
|
|
||||||
|
const retry = await screen.findByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Повторить" },
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requests).toBe(3);
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Вторая")).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["NOT_FOUND", "UNAUTHORIZED"])(
|
||||||
|
"does not offer a plain retry for %s after a cached portfolio",
|
||||||
|
async (code) => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json({ code }, { status: code === "NOT_FOUND" ? 404 : 401 })),
|
||||||
|
);
|
||||||
|
renderAccountRoute(createTestQueryClient());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("heading", { name: "Загрузка" })).not.toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each([503])(
|
it.each([503])(
|
||||||
"preserves totals quietly through refresh error %i and success",
|
"preserves totals quietly through refresh error %i and success",
|
||||||
async (status) => {
|
async (status) => {
|
||||||
@@ -1231,14 +1340,9 @@ describe("AccountPage states", () => {
|
|||||||
= {
|
= {
|
||||||
...portfolio,
|
...portfolio,
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
categories: [{ sharePercent: null, type: "BOND", value: "10" }],
|
categories: [{ sharePercent: null, type: "BOND", value: "10" }],
|
||||||
currency: "RUB",
|
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
totalValue: "10",
|
totalValue: "10",
|
||||||
},
|
|
||||||
],
|
|
||||||
unvaluedCategories: [{ count: 1, type: "STOCK" }],
|
unvaluedCategories: [{ count: 1, type: "STOCK" }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1276,7 +1380,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(structure).toHaveTextContent("10 ₽");
|
expect(structure).toHaveTextContent("10 ₽");
|
||||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||||
});
|
});
|
||||||
@@ -1289,7 +1393,7 @@ describe("AccountPage states", () => {
|
|||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("definition").map(node => node.textContent),
|
screen.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(["1 234,5 ₽"]);
|
).toEqual(["1 234,50 ₽"]);
|
||||||
expect(screen.queryByRole("button", { name: "Повторить" }))
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
@@ -1299,17 +1403,17 @@ describe("AccountPage states", () => {
|
|||||||
expect(structure).toHaveTextContent("10 ₽");
|
expect(structure).toHaveTextContent("10 ₽");
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("definition").map(node => node.textContent),
|
screen.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(["1 234,5 ₽"]);
|
).toEqual(["1 234,50 ₽"]);
|
||||||
resolveRetry?.(
|
resolveRetry?.(
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
resource(
|
resource(
|
||||||
{
|
{
|
||||||
...portfolio,
|
...portfolio,
|
||||||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
cash: { currency: "RUB", value: "2.50" },
|
||||||
isStale: false,
|
isStale: false,
|
||||||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
totals: { currency: "RUB", value: "-0.5" },
|
||||||
},
|
},
|
||||||
{ dataRevision: "2", resourceKey: "portfolio" },
|
{ dataRevision: "2", resourceKey: "portfolio" },
|
||||||
),
|
),
|
||||||
@@ -1318,9 +1422,9 @@ describe("AccountPage states", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("definition").map(node => node.textContent),
|
screen.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(["-0,5 $"]),
|
).toEqual(["-0,50 ₽"]),
|
||||||
);
|
);
|
||||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на");
|
||||||
expect(structure).not.toHaveTextContent("10 ₽");
|
expect(structure).not.toHaveTextContent("10 ₽");
|
||||||
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
||||||
expect(
|
expect(
|
||||||
@@ -1360,8 +1464,8 @@ describe("AccountPage states", () => {
|
|||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("definition").map(node => node.textContent),
|
screen.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(["1 234,5 ₽"]);
|
).toEqual(["1 234,50 ₽"]);
|
||||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("status", { name: "Загрузка" }),
|
screen.queryByRole("status", { name: "Загрузка" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
@@ -1383,8 +1487,8 @@ describe("AccountPage states", () => {
|
|||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
expect(screen.queryByText("Вторая")).not.toBeInTheDocument();
|
expect(screen.queryByText("Вторая")).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
|
fireEvent.click(screen.getByRole("tab", { name: "История операций" }));
|
||||||
expect(screen.getByText("Последних событий пока нет.")).toBeVisible();
|
expect(screen.getByText("Последних операций пока нет.")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("links operation, event and income previews to their detail pages", () => {
|
it("links operation, event and income previews to their detail pages", () => {
|
||||||
@@ -1399,8 +1503,8 @@ describe("AccountPage states", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const [tab, href] of [
|
for (const [tab, href] of [
|
||||||
["Операции", "/accounts/account-1/operations"],
|
["История операций", "/accounts/account-1/operations"],
|
||||||
["События", "/accounts/account-1/events"],
|
["Предстоящие события", "/accounts/account-1/events"],
|
||||||
["Доход", "/accounts/account-1/income"],
|
["Доход", "/accounts/account-1/income"],
|
||||||
]) {
|
]) {
|
||||||
fireEvent.click(screen.getByRole("tab", { name: tab }));
|
fireEvent.click(screen.getByRole("tab", { name: tab }));
|
||||||
@@ -1516,7 +1620,7 @@ describe("AccountPage states", () => {
|
|||||||
await screen.findByRole("heading", { level: 1, name: "Все счета" });
|
await screen.findByRole("heading", { level: 1, name: "Все счета" });
|
||||||
|
|
||||||
const operationGroup = screen.getByRole("list", {
|
const operationGroup = screen.getByRole("list", {
|
||||||
name: /Купон · BOND, Исполнено/,
|
name: /Выплата купонов BOND, Исполнено/,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("+500 ₽")).toBeInTheDocument();
|
expect(screen.getByText("+500 ₽")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,45 +1,46 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
import { useAccountPageVm } from "./AccountPage.vm";
|
||||||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||||||
import { AccountPageError } from "./components/AccountPageError.view";
|
import { AccountPageError } from "./components/AccountPageError.view";
|
||||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
||||||
|
|
||||||
const AccountPage: FC = () => {
|
const AccountPage: FC = () => {
|
||||||
const viewModel = useAccountPageViewModel();
|
const vm = useAccountPageVm();
|
||||||
if (viewModel.state === "loading") return <AccountPageLoading />;
|
if (vm.state === "loading") return <AccountPageLoading />;
|
||||||
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
if (vm.state === "error") return <AccountPageError {...vm} />;
|
||||||
if (viewModel.state === "empty")
|
if (vm.state === "empty")
|
||||||
return (
|
return (
|
||||||
<AccountPageEmpty
|
<AccountPageEmpty
|
||||||
account={viewModel.account}
|
account={vm.account}
|
||||||
accountId={viewModel.accountId}
|
accountId={vm.accountId}
|
||||||
incomeSummary={viewModel.incomeSummary}
|
incomeSummary={vm.incomeSummary}
|
||||||
recentEvents={viewModel.recentEvents}
|
readRetry={vm.readRetry}
|
||||||
recentOperations={viewModel.recentOperations}
|
recentEvents={vm.recentEvents}
|
||||||
|
recentOperations={vm.recentOperations}
|
||||||
|
source={vm.source}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
account={viewModel.account}
|
account={vm.account}
|
||||||
accountId={viewModel.accountId}
|
accountId={vm.accountId}
|
||||||
concentration={viewModel.concentration}
|
concentration={vm.concentration}
|
||||||
currency={viewModel.currency}
|
incomeSummary={vm.incomeSummary}
|
||||||
incomeSummary={viewModel.incomeSummary}
|
onSliceChange={vm.onSliceChange}
|
||||||
onCurrencyChange={viewModel.onCurrencyChange}
|
onTabChange={vm.onTabChange}
|
||||||
onSliceChange={viewModel.onSliceChange}
|
partial={vm.partial}
|
||||||
onTabChange={viewModel.onTabChange}
|
portfolio={vm.portfolio}
|
||||||
partial={viewModel.partial}
|
positionSections={vm.positionSections}
|
||||||
portfolio={viewModel.portfolio}
|
readRetry={vm.readRetry}
|
||||||
positionSections={viewModel.positionSections}
|
recentEvents={vm.recentEvents}
|
||||||
recentEvents={viewModel.recentEvents}
|
recentOperations={vm.recentOperations}
|
||||||
recentOperations={viewModel.recentOperations}
|
slice={vm.slice}
|
||||||
slice={viewModel.slice}
|
source={vm.source}
|
||||||
source={viewModel.source}
|
tab={vm.tab}
|
||||||
tab={viewModel.tab}
|
unrealizedReturns={vm.unrealizedReturns}
|
||||||
unrealizedReturns={viewModel.unrealizedReturns}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { resource } from "@/test/resource";
|
|||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { portfolio } from "../../model/positions.fixtures";
|
import { portfolio } from "../../model/positions.fixtures";
|
||||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
import { useAccountPageVm } from "./AccountPage.vm";
|
||||||
|
|
||||||
function configureTestAuth() {
|
function configureTestAuth() {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
@@ -102,12 +102,12 @@ function mockLoadedAccountPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useAccountPageViewModel", () => {
|
describe("useAccountPageVm", () => {
|
||||||
it("prepares recent operations and events state for presentation views", async () => {
|
it("prepares recent operations and events state for presentation views", async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
mockLoadedAccountPage();
|
mockLoadedAccountPage();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
const { result } = renderHook(() => useAccountPageVm(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ describe("useAccountPageViewModel", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
const { result } = renderHook(() => useAccountPageVm(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,14 +187,14 @@ it("keeps overview sections independent when their source data is incomplete", a
|
|||||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
resource(
|
resource(
|
||||||
{ events: [], incomeTotalsByCurrency: [] },
|
{ events: [], incomeTotal: { currency: "RUB", value: "0" } },
|
||||||
{ completeness: "incomplete" },
|
{ completeness: "incomplete" },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
const { result } = renderHook(() => useAccountPageVm(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,8 +213,8 @@ it("keeps overview sections loaded without exposing technical retry", async () =
|
|||||||
|
|
||||||
const sections: Record<string, unknown>
|
const sections: Record<string, unknown>
|
||||||
= {
|
= {
|
||||||
"events": { events: [], incomeTotalsByCurrency: [] },
|
"events": { events: [], incomeTotal: { currency: "RUB", value: "0" } },
|
||||||
"income-summary": { totalsByCurrency: [] },
|
"income-summary": { total: { coupon: { currency: "RUB", value: "0" }, dividend: { currency: "RUB", value: "0" }, total: { currency: "RUB", value: "0" } } },
|
||||||
"operations": { hasNext: false, items: [], nextCursor: null },
|
"operations": { hasNext: false, items: [], nextCursor: null },
|
||||||
portfolio,
|
portfolio,
|
||||||
};
|
};
|
||||||
@@ -232,13 +232,13 @@ it("keeps overview sections loaded without exposing technical retry", async () =
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
const { result } = renderHook(() => useAccountPageVm(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current).toMatchObject({
|
expect(result.current).toMatchObject({
|
||||||
incomeSummary: { state: "empty" },
|
incomeSummary: { state: "loaded" },
|
||||||
recentEvents: { state: "empty" },
|
recentEvents: { state: "empty" },
|
||||||
recentOperations: { state: "empty" },
|
recentOperations: { state: "empty" },
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
|
|||||||
@@ -16,12 +16,11 @@ import {
|
|||||||
import { useIncomeSummary } from "@/entities/income";
|
import { useIncomeSummary } from "@/entities/income";
|
||||||
import { usePortfolio } from "@/entities/portfolio";
|
import { usePortfolio } from "@/entities/portfolio";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api";
|
||||||
import { useInitialAvailability } from "@/shared/lib";
|
import { useInitialAvailability } from "@/shared/lib";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Concentration,
|
type Concentration,
|
||||||
concentrationCurrencies,
|
|
||||||
type ConcentrationLabels,
|
type ConcentrationLabels,
|
||||||
type Slice,
|
type Slice,
|
||||||
toConcentration,
|
toConcentration,
|
||||||
@@ -45,8 +44,10 @@ type AccountPageContent = {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
incomeSummary: AccountPageIncomeSummary;
|
incomeSummary: AccountPageIncomeSummary;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
|
readRetry: AccountPageReadRetry;
|
||||||
recentEvents: AccountPageRecentEvents;
|
recentEvents: AccountPageRecentEvents;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
source?: InvestmentResourceMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountPageEmptyViewModel = AccountPageContent & {
|
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||||
@@ -55,46 +56,40 @@ type AccountPageEmptyViewModel = AccountPageContent & {
|
|||||||
|
|
||||||
type AccountPageLoadedViewModel = AccountPageContent & {
|
type AccountPageLoadedViewModel = AccountPageContent & {
|
||||||
concentration: Concentration | null;
|
concentration: Concentration | null;
|
||||||
currency: null | string;
|
|
||||||
onCurrencyChange: (currency: null | string) => void;
|
|
||||||
onSliceChange: (slice: Slice) => void;
|
onSliceChange: (slice: Slice) => void;
|
||||||
onTabChange: (tab: null | string) => void;
|
onTabChange: (tab: null | string) => void;
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
positionSections: AccountPagePositionSection[];
|
positionSections: AccountPagePositionSection[];
|
||||||
slice: Slice;
|
slice: Slice;
|
||||||
source?: InvestmentResourceMeta;
|
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
tab: string;
|
tab: string;
|
||||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
unrealizedReturns:
|
||||||
|
| null
|
||||||
|
| { currency: string; value: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AccountPageReadRetry = ReturnType<typeof readRetry>;
|
||||||
|
|
||||||
type AccountPageViewModel
|
type AccountPageViewModel
|
||||||
= | AccountPageEmptyViewModel
|
= | AccountPageEmptyViewModel
|
||||||
| AccountPageLoadedViewModel
|
| AccountPageLoadedViewModel
|
||||||
| { code?: string; state: "error" }
|
| { code?: string; readRetry: AccountPageReadRetry; state: "error" }
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
function isPortfolioAccessFailure(code?: string) {
|
function useAccountPageVm(): AccountPageViewModel {
|
||||||
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
|
||||||
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|
|
||||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAccountPageViewModel(): AccountPageViewModel {
|
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [selection, setSelection] = useState<{
|
const [selection, setSelection] = useState<{
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
currency: null | string;
|
|
||||||
slice: Slice;
|
slice: Slice;
|
||||||
tab: string;
|
tab: string;
|
||||||
}>({ accountId, currency: null, slice: "stocks", tab: "positions" });
|
}>({ accountId, slice: "stocks", tab: "positions" });
|
||||||
|
|
||||||
const current = selection.accountId === accountId
|
const current = selection.accountId === accountId
|
||||||
? selection
|
? selection
|
||||||
: {
|
: {
|
||||||
accountId, currency: null, slice: "stocks" as const, tab: "positions",
|
accountId, slice: "stocks" as const, tab: "positions",
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSelection = (change: Partial<typeof selection>) =>
|
const updateSelection = (change: Partial<typeof selection>) =>
|
||||||
@@ -119,7 +114,9 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const code = apiErrorCode(portfolio.error);
|
const code = apiErrorCode(portfolio.error);
|
||||||
const isAccessFailure = isPortfolioAccessFailure(code);
|
// The portfolio drives this screen; the retry repeats only its GET.
|
||||||
|
const retry = readRetry([portfolio]);
|
||||||
|
const isAccessFailure = isBrokerageAccessLoss(code);
|
||||||
|
|
||||||
const availability = useInitialAvailability({
|
const availability = useInitialAvailability({
|
||||||
hasContent: !!portfolio.data,
|
hasContent: !!portfolio.data,
|
||||||
@@ -128,12 +125,13 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
isFetching: portfolio.isFetching,
|
isFetching: portfolio.isFetching,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isAccessFailure) return { code, state: "error" as const };
|
if (isAccessFailure)
|
||||||
|
return { code, readRetry: retry, state: "error" as const };
|
||||||
|
|
||||||
if (!portfolio.data)
|
if (!portfolio.data)
|
||||||
return {
|
return availability === "unavailable"
|
||||||
state: availability === "unavailable" ? "error" : "loading",
|
? { code, readRetry: retry, state: "error" as const }
|
||||||
} as const;
|
: { state: "loading" as const };
|
||||||
|
|
||||||
const account = accounts.data?.find(item => item.id === accountId);
|
const account = accounts.data?.find(item => item.id === accountId);
|
||||||
const recentOperations = toRecentOperations(accountId, operations);
|
const recentOperations = toRecentOperations(accountId, operations);
|
||||||
@@ -144,23 +142,20 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
if (
|
if (
|
||||||
portfolio.meta?.availability === "available"
|
portfolio.meta?.availability === "available"
|
||||||
&& portfolio.data.positions.length === 0
|
&& portfolio.data.positions.length === 0
|
||||||
&& portfolio.data.cashBalances.length === 0
|
&& Number(portfolio.data.cash.value) === 0
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
account,
|
account,
|
||||||
accountId,
|
accountId,
|
||||||
incomeSummary,
|
incomeSummary,
|
||||||
|
readRetry: retry,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
recentOperations,
|
recentOperations,
|
||||||
|
source: portfolio.meta,
|
||||||
state: "empty" as const,
|
state: "empty" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPartial = portfolio.meta?.completeness === "incomplete";
|
const isPartial = portfolio.meta?.completeness === "incomplete";
|
||||||
const currencies = concentrationCurrencies(portfolio.data);
|
|
||||||
|
|
||||||
const currency = current.currency && currencies.includes(current.currency)
|
|
||||||
? current.currency
|
|
||||||
: currencies[0] ?? null;
|
|
||||||
|
|
||||||
const labels: ConcentrationLabels = {
|
const labels: ConcentrationLabels = {
|
||||||
missingIssuer: t("portfolio.concentration.missingIssuer"),
|
missingIssuer: t("portfolio.concentration.missingIssuer"),
|
||||||
@@ -171,21 +166,20 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
|
|
||||||
const concentration = accountId !== "all"
|
const concentration = accountId !== "all"
|
||||||
&& current.tab === "concentration"
|
&& current.tab === "concentration"
|
||||||
? toConcentration(portfolio.data, current.slice, currency, labels)
|
? toConcentration(portfolio.data, current.slice, labels)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account,
|
account,
|
||||||
accountId,
|
accountId,
|
||||||
concentration,
|
concentration,
|
||||||
currency,
|
|
||||||
incomeSummary,
|
incomeSummary,
|
||||||
onCurrencyChange: value => updateSelection({ currency: value }),
|
|
||||||
onSliceChange: slice => updateSelection({ slice }),
|
onSliceChange: slice => updateSelection({ slice }),
|
||||||
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
|
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
|
||||||
partial: isPartial,
|
partial: isPartial,
|
||||||
portfolio: portfolio.data,
|
portfolio: portfolio.data,
|
||||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||||
|
readRetry: retry,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
recentOperations,
|
recentOperations,
|
||||||
slice: current.slice,
|
slice: current.slice,
|
||||||
@@ -196,4 +190,4 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useAccountPageViewModel };
|
export { useAccountPageVm };
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
|
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||||
|
|
||||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||||
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountPageIncomeSummary,
|
AccountPageIncomeSummary,
|
||||||
@@ -17,14 +18,17 @@ import type {
|
|||||||
|
|
||||||
import { AccountHeading } from "./AccountHeading.view";
|
import { AccountHeading } from "./AccountHeading.view";
|
||||||
import { IncomeSummary } from "./IncomeSummary.view";
|
import { IncomeSummary } from "./IncomeSummary.view";
|
||||||
|
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
|
||||||
import { RecentOperations } from "./RecentOperations.view";
|
import { RecentOperations } from "./RecentOperations.view";
|
||||||
|
|
||||||
type AccountPageEmptyProps = {
|
type AccountPageEmptyProps = {
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
incomeSummary?: AccountPageIncomeSummary;
|
incomeSummary?: AccountPageIncomeSummary;
|
||||||
|
readRetry?: ReadRetryProps;
|
||||||
recentEvents: AccountPageRecentEvents;
|
recentEvents: AccountPageRecentEvents;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
source?: InvestmentResourceMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||||
@@ -40,10 +44,23 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||||
|
|
||||||
|
<SourceSyncStatus
|
||||||
|
lastAttempt={props.source?.lastAttempt}
|
||||||
|
lastSuccessfulAt={props.source?.lastSuccessfulAt}
|
||||||
|
stale={props.source?.freshness === "stale"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
|
||||||
|
|
||||||
<StateView text={text} title={t("portfolio.emptyTitle")} />
|
<StateView text={text} title={t("portfolio.emptyTitle")} />
|
||||||
|
|
||||||
<PortfolioStructure
|
<PortfolioStructure
|
||||||
structure={{ currencies: [], unvaluedCategories: [] }}
|
structure={{
|
||||||
|
categories: [],
|
||||||
|
hasNegativeValues: false,
|
||||||
|
totalValue: "0",
|
||||||
|
unvaluedCategories: [],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RecentOperations
|
<RecentOperations
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
|
||||||
|
|
||||||
type AccountPageErrorProps = { code?: string };
|
type AccountPageErrorProps = {
|
||||||
|
code?: string;
|
||||||
|
readRetry?: ReadRetryProps;
|
||||||
|
};
|
||||||
|
|
||||||
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||||
const { code } = props;
|
const { code } = props;
|
||||||
@@ -43,8 +47,18 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const retry = props.readRetry?.exhausted
|
||||||
|
? (
|
||||||
|
<ReadRetryButton
|
||||||
|
onRetry={props.readRetry.onRetry}
|
||||||
|
retrying={props.readRetry.retrying}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
|
action={retry}
|
||||||
text={t("states.unavailableText")}
|
text={t("states.unavailableText")}
|
||||||
title={t("states.unavailableTitle")}
|
title={t("states.unavailableTitle")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import type { InvestmentResourceMeta } from "@/shared/api";
|
|||||||
|
|
||||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import { SourceSyncStatus } from "@/shared/ui";
|
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
|
||||||
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
|
||||||
|
|
||||||
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
|
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
|
||||||
import type { AccountPagePositionSection } from "../../../model/positions";
|
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||||
@@ -29,8 +28,9 @@ import type {
|
|||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
import { AccountHeading } from "./AccountHeading.view";
|
import { AccountHeading } from "./AccountHeading.view";
|
||||||
import { Concentration } from "./Concentration/Concentration.view";
|
import { Concentration } from "./Concentration/Concentration.view";
|
||||||
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
|
|
||||||
import { IncomeSummary } from "./IncomeSummary.view";
|
import { IncomeSummary } from "./IncomeSummary.view";
|
||||||
|
import { PerformanceTab } from "./Performance/PerformanceTab";
|
||||||
|
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
|
||||||
import { PositionSection } from "./PositionSection.view";
|
import { PositionSection } from "./PositionSection.view";
|
||||||
import { RecentOperations } from "./RecentOperations.view";
|
import { RecentOperations } from "./RecentOperations.view";
|
||||||
|
|
||||||
@@ -38,20 +38,21 @@ type AccountPageLoadedProps = {
|
|||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
concentration: ConcentrationModel | null;
|
concentration: ConcentrationModel | null;
|
||||||
currency: null | string;
|
|
||||||
incomeSummary?: AccountPageIncomeSummary;
|
incomeSummary?: AccountPageIncomeSummary;
|
||||||
onCurrencyChange: (currency: null | string) => void;
|
|
||||||
onSliceChange: (slice: Slice) => void;
|
onSliceChange: (slice: Slice) => void;
|
||||||
onTabChange: (tab: null | string) => void;
|
onTabChange: (tab: null | string) => void;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
positionSections: AccountPagePositionSection[];
|
positionSections: AccountPagePositionSection[];
|
||||||
|
readRetry?: ReadRetryProps;
|
||||||
recentEvents: AccountPageRecentEvents;
|
recentEvents: AccountPageRecentEvents;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
slice: Slice;
|
slice: Slice;
|
||||||
source?: InvestmentResourceMeta;
|
source?: InvestmentResourceMeta;
|
||||||
tab: string;
|
tab: string;
|
||||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
unrealizedReturns:
|
||||||
|
| null
|
||||||
|
| { currency: string; value: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||||
@@ -86,13 +87,29 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
stale={props.source?.freshness === "stale"}
|
stale={props.source?.freshness === "stale"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
|
||||||
|
|
||||||
{props.partial
|
{props.partial
|
||||||
? tab !== "concentration" && (
|
? tab !== "concentration" && (
|
||||||
<Alert role="status">{t("investment.partial")}</Alert>
|
<Alert role="status">{t("investment.partial")}</Alert>
|
||||||
)
|
)
|
||||||
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
|
: (
|
||||||
|
<Stack component="dl" gap={4} m={0}>
|
||||||
|
<dt>
|
||||||
|
<Text fw={600}>{t("portfolio.total")}</Text>
|
||||||
|
</dt>
|
||||||
|
|
||||||
{!props.partial && props.unrealizedReturns.length > 0 && (
|
<dd style={{ margin: 0 }}>
|
||||||
|
<Text className={classes.totalAmount}>
|
||||||
|
{formatMoney(props.portfolio.totals, {
|
||||||
|
withRoundedCents: true,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</dd>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!props.partial && props.unrealizedReturns && (
|
||||||
<Stack
|
<Stack
|
||||||
aria-label={t("portfolio.unrealizedResult")}
|
aria-label={t("portfolio.unrealizedResult")}
|
||||||
component="section"
|
component="section"
|
||||||
@@ -101,9 +118,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
<Text fw={600}>{t("portfolio.unrealizedResult")}</Text>
|
<Text fw={600}>{t("portfolio.unrealizedResult")}</Text>
|
||||||
|
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
{props.unrealizedReturns.map(total => (
|
<Text>{formatMoney(props.unrealizedReturns)}</Text>
|
||||||
<Text key={total.currency}>{formatMoney(total)}</Text>
|
|
||||||
))}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
@@ -145,6 +160,8 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
<Tabs.Tab value="events">{t("portfolio.tabs.events")}</Tabs.Tab>
|
<Tabs.Tab value="events">{t("portfolio.tabs.events")}</Tabs.Tab>
|
||||||
|
|
||||||
<Tabs.Tab value="income">{t("portfolio.tabs.income")}</Tabs.Tab>
|
<Tabs.Tab value="income">{t("portfolio.tabs.income")}</Tabs.Tab>
|
||||||
|
|
||||||
|
<Tabs.Tab value="performance">{t("portfolio.tabs.performance")}</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="positions">
|
<Tabs.Panel value="positions">
|
||||||
@@ -168,10 +185,8 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
<Tabs.Panel value="concentration">
|
<Tabs.Panel value="concentration">
|
||||||
{props.concentration && (
|
{props.concentration && (
|
||||||
<Concentration
|
<Concentration
|
||||||
currency={props.currency}
|
key={props.slice}
|
||||||
key={`${props.slice}-${props.currency}`}
|
|
||||||
model={props.concentration}
|
model={props.concentration}
|
||||||
onCurrencyChange={props.onCurrencyChange}
|
|
||||||
onSliceChange={props.onSliceChange}
|
onSliceChange={props.onSliceChange}
|
||||||
partial={!!props.partial}
|
partial={!!props.partial}
|
||||||
slice={props.slice}
|
slice={props.slice}
|
||||||
@@ -181,7 +196,17 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs.Panel value="cash">
|
<Tabs.Panel value="cash">
|
||||||
<CurrencyCards amounts={props.portfolio.cashBalances} />
|
<Stack component="dl" gap={4} m={0}>
|
||||||
|
<dt>
|
||||||
|
<Text fw={600}>{t("portfolio.cashTitle")}</Text>
|
||||||
|
</dt>
|
||||||
|
|
||||||
|
<dd style={{ margin: 0 }}>
|
||||||
|
<Text>
|
||||||
|
{formatMoney(props.portfolio.cash, { withRoundedCents: true })}
|
||||||
|
</Text>
|
||||||
|
</dd>
|
||||||
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="operations">
|
<Tabs.Panel value="operations">
|
||||||
@@ -208,6 +233,10 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="performance">
|
||||||
|
{props.accountId && <PerformanceTab accountId={props.accountId} />}
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DonutChart } from "@mantine/charts";
|
import { DonutChart } from "@mantine/charts";
|
||||||
import {
|
import {
|
||||||
Alert, Box, Group, Paper, SegmentedControl, Select, Stack,
|
Alert, Box, Group, Paper, SegmentedControl, Stack,
|
||||||
Text, Title,
|
Text, Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { type FC, useId, useState } from "react";
|
import { type FC, useId, useState } from "react";
|
||||||
@@ -18,9 +18,7 @@ import { PositionLogo } from "../PositionLogo.view";
|
|||||||
import classes from "./Concentration.module.css";
|
import classes from "./Concentration.module.css";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currency: null | string;
|
|
||||||
model: ConcentrationModel;
|
model: ConcentrationModel;
|
||||||
onCurrencyChange: (currency: null | string) => void;
|
|
||||||
onSliceChange: (slice: Slice) => void;
|
onSliceChange: (slice: Slice) => void;
|
||||||
partial: boolean;
|
partial: boolean;
|
||||||
slice: Slice;
|
slice: Slice;
|
||||||
@@ -35,7 +33,6 @@ const sliceLabels = {
|
|||||||
|
|
||||||
type RowProps = {
|
type RowProps = {
|
||||||
color: string;
|
color: string;
|
||||||
currency: string;
|
|
||||||
index: number;
|
index: number;
|
||||||
item: ConcentrationItem;
|
item: ConcentrationItem;
|
||||||
logo?: ConcentrationItem["logo"];
|
logo?: ConcentrationItem["logo"];
|
||||||
@@ -50,8 +47,8 @@ const colors = [
|
|||||||
const PERCENT = 100;
|
const PERCENT = 100;
|
||||||
const OTHER_START = 6;
|
const OTHER_START = 6;
|
||||||
|
|
||||||
function amountFor(item: ConcentrationItem, currency: string): string {
|
function amountFor(item: ConcentrationItem): string {
|
||||||
return formatMoney({ currency, value: item.amount });
|
return formatMoney({ currency: "RUB", value: item.amount });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConcentrationRow: FC<RowProps> = props => (
|
const ConcentrationRow: FC<RowProps> = props => (
|
||||||
@@ -83,7 +80,7 @@ const ConcentrationRow: FC<RowProps> = props => (
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={classes.value}>
|
<span className={classes.value}>
|
||||||
<strong>{amountFor(props.item, props.currency)}</strong>
|
<strong>{amountFor(props.item)}</strong>
|
||||||
|
|
||||||
<Text c="dimmed" size="sm">{props.item.share}</Text>
|
<Text c="dimmed" size="sm">{props.item.share}</Text>
|
||||||
</span>
|
</span>
|
||||||
@@ -131,7 +128,6 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const otherId = useId();
|
const otherId = useId();
|
||||||
const chartId = useId();
|
const chartId = useId();
|
||||||
const currency = props.currency ?? "";
|
|
||||||
|
|
||||||
const rows = props.model.other
|
const rows = props.model.other
|
||||||
? [...props.model.items, props.model.other]
|
? [...props.model.items, props.model.other]
|
||||||
@@ -147,10 +143,8 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
props.model.top5Share && t("portfolio.concentration.top5", {
|
props.model.top5Share && t("portfolio.concentration.top5", {
|
||||||
share: props.model.top5Share,
|
share: props.model.top5Share,
|
||||||
}),
|
}),
|
||||||
props.model.total && formatMoney({
|
props.model.total && formatMoney({ currency: "RUB", value: props.model.total }),
|
||||||
currency, value: props.model.total,
|
...rows.map(item => `${item.label}: ${amountFor(item)}, ${item.share}`),
|
||||||
}),
|
|
||||||
...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`),
|
|
||||||
].filter(Boolean).join("; ");
|
].filter(Boolean).join("; ");
|
||||||
|
|
||||||
const slices = [
|
const slices = [
|
||||||
@@ -170,7 +164,7 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
const pieProps = { isAnimationActive: false, label: rankLabel };
|
const pieProps = { isAnimationActive: false, label: rankLabel };
|
||||||
|
|
||||||
const tooltipRows = rows.map(item => ({
|
const tooltipRows = rows.map(item => ({
|
||||||
amount: amountFor(item, currency),
|
amount: amountFor(item),
|
||||||
label: item.label,
|
label: item.label,
|
||||||
share: item.share,
|
share: item.share,
|
||||||
}));
|
}));
|
||||||
@@ -178,7 +172,7 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
|
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
|
||||||
|
|
||||||
const totalText = props.model.total
|
const totalText = props.model.total
|
||||||
? formatMoney({ currency, value: props.model.total })
|
? formatMoney({ currency: "RUB", value: props.model.total })
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -192,16 +186,6 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
value={props.slice}
|
value={props.slice}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{props.model.currencies.length > 1 && (
|
|
||||||
<Select
|
|
||||||
allowDeselect={false}
|
|
||||||
aria-label={t("portfolio.concentration.currency")}
|
|
||||||
data={props.model.currencies}
|
|
||||||
onChange={props.onCurrencyChange}
|
|
||||||
value={props.currency}
|
|
||||||
w={130}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{props.partial && (
|
{props.partial && (
|
||||||
@@ -211,9 +195,7 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
{props.model.empty
|
{props.model.empty
|
||||||
? (
|
? (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text fw={600}>{t(sliceLabels[props.slice])}
|
<Text fw={600}>{t(sliceLabels[props.slice])}</Text>
|
||||||
{props.currency && ` · ${props.currency}`}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text>{t("portfolio.concentration.empty")}</Text>
|
<Text>{t("portfolio.concentration.empty")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -260,7 +242,6 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
{props.model.items.map((item, index) => (
|
{props.model.items.map((item, index) => (
|
||||||
<ConcentrationRow
|
<ConcentrationRow
|
||||||
color={colors[index % colors.length] ?? "blue"}
|
color={colors[index % colors.length] ?? "blue"}
|
||||||
currency={currency}
|
|
||||||
index={index + 1}
|
index={index + 1}
|
||||||
item={item}
|
item={item}
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@@ -285,7 +266,7 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span>{amountFor(props.model.other, currency)} · {
|
<span>{amountFor(props.model.other)} · {
|
||||||
props.model.other.share
|
props.model.other.share
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
@@ -301,7 +282,6 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
<ConcentrationRow
|
<ConcentrationRow
|
||||||
color={colors[(index + OTHER_START) % colors.length]
|
color={colors[(index + OTHER_START) % colors.length]
|
||||||
?? "blue"}
|
?? "blue"}
|
||||||
currency={currency}
|
|
||||||
index={index + OTHER_START}
|
index={index + OTHER_START}
|
||||||
item={item}
|
item={item}
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { CurrencyCards } from "./CurrencyCards.view";
|
|
||||||
|
|
||||||
describe("CurrencyCards currency presentation", () => {
|
|
||||||
it.each([0, 5])(
|
|
||||||
"renders only the %i supplied totals in upstream order",
|
|
||||||
(count) => {
|
|
||||||
const totals = [
|
|
||||||
{ currency: "EUR", value: "3.00" },
|
|
||||||
{ currency: "USD", value: "2.00" },
|
|
||||||
{ currency: "RUB", value: "1.00" },
|
|
||||||
{ currency: "CAD", value: "4.00" },
|
|
||||||
{ currency: "XYZ", value: "5.00" },
|
|
||||||
].slice(0, count);
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<MantineProvider>
|
|
||||||
<CurrencyCards amounts={totals} />
|
|
||||||
</MantineProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.queryAllByRole("definition").map(node => node.textContent),
|
|
||||||
).toEqual(
|
|
||||||
["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
expect(container.querySelectorAll("dl")).toHaveLength(count);
|
|
||||||
expect(
|
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
container.querySelector("[tabindex], button, a, input"),
|
|
||||||
).toBeNull();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["RUB", "125000.50", "125 000,50 ₽", "Российский рубль", "₽"],
|
|
||||||
["USD", "320.00", "320,00 $", "Доллар США", "$"],
|
|
||||||
["EUR", "9876.54321", "9 876,54321 €", "Евро", "€"],
|
|
||||||
[
|
|
||||||
"RUB",
|
|
||||||
"123456789012.345678901",
|
|
||||||
"123 456 789 012,345678901 ₽",
|
|
||||||
"Российский рубль",
|
|
||||||
"₽",
|
|
||||||
],
|
|
||||||
["USD", "-1234567.890123", "-1 234 567,890123 $", "Доллар США", "$"],
|
|
||||||
["EUR", "987654321.000001", "987 654 321,000001 €", "Евро", "€"],
|
|
||||||
["USD", "-0.5", "-0,5 $", "Доллар США", "$"],
|
|
||||||
["CAD", "20.00", "20,00 CAD", "CAD", "CAD"],
|
|
||||||
["XYZ", "1.000", "1,000 XYZ", "XYZ", "XYZ"],
|
|
||||||
])(
|
|
||||||
"preserves %s %s and its currency identity",
|
|
||||||
(currency, value, expected, name, symbol) => {
|
|
||||||
render(
|
|
||||||
<MantineProvider>
|
|
||||||
<CurrencyCards amounts={[{ currency, value }]} />
|
|
||||||
</MantineProvider>,
|
|
||||||
);
|
|
||||||
const term = screen.getByRole("term");
|
|
||||||
expect(term).toHaveTextContent(name);
|
|
||||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
expect(term.querySelector("[aria-hidden=\"true\"]")).toHaveTextContent(
|
|
||||||
symbol,
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("definition")).toHaveTextContent(expected);
|
|
||||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
expect(term.parentElement).toBe(
|
|
||||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
screen.getByRole("definition").parentElement,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
|
||||||
|
|
||||||
import { formatDecimal } from "@/shared/lib";
|
|
||||||
|
|
||||||
import classes from "../../AccountPage.module.css";
|
|
||||||
|
|
||||||
const SIGN_PREFIX_LENGTH = 2;
|
|
||||||
|
|
||||||
type CurrencyCardsProps = {
|
|
||||||
amounts: Portfolio["totalsByCurrency"];
|
|
||||||
};
|
|
||||||
|
|
||||||
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { amounts } = props;
|
|
||||||
|
|
||||||
const currencies: Record<string, { name: string; symbol: string }> = {
|
|
||||||
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
|
|
||||||
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
|
|
||||||
USD: { name: t("portfolio.currencies.USD"), symbol: "$" },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
|
|
||||||
{amounts.map((money) => {
|
|
||||||
const amount = formatDecimal(money.value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
className={classes.total}
|
|
||||||
component="dl"
|
|
||||||
key={money.currency}
|
|
||||||
m={0}
|
|
||||||
withBorder
|
|
||||||
>
|
|
||||||
<Text className={classes.totalBadge} component="dt">
|
|
||||||
<span aria-hidden="true">
|
|
||||||
{currencies[money.currency]?.symbol ?? money.currency}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<VisuallyHidden>
|
|
||||||
{currencies[money.currency]?.name ?? money.currency}
|
|
||||||
</VisuallyHidden>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className={classes.totalAmount} component="dd" m={0}>
|
|
||||||
{amount.startsWith("-")
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
<span className={classes.totalLeadingDigits}>
|
|
||||||
{amount.slice(0, SIGN_PREFIX_LENGTH)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{amount.slice(SIGN_PREFIX_LENGTH)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
amount
|
|
||||||
)}{" "}
|
|
||||||
{currencies[money.currency]?.symbol ?? money.currency}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CurrencyCards };
|
|
||||||
@@ -64,16 +64,14 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{props.summary.totalsByCurrency.map(total => (
|
<Paper p="sm" withBorder>
|
||||||
<Paper key={total.currency} p="sm" withBorder>
|
<Text fw={700}>{formatMoney(props.summary.total.total)}</Text>
|
||||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
|
||||||
|
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
|
{t("income.coupon")}: {formatMoney(props.summary.total.coupon)} ·{" "}
|
||||||
{t("income.dividend")}: {formatMoney(total.dividend)}
|
{t("income.dividend")}: {formatMoney(props.summary.total.dividend)}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
|
||||||
|
|
||||||
<Button component={Link} to={props.to} variant="subtle">
|
<Button component={Link} to={props.to} variant="subtle">
|
||||||
{props.moreLabel ?? t("income.open")}
|
{props.moreLabel ?? t("income.open")}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.positive { color: var(--wl-color-positive); }
|
||||||
|
.negative { color: var(--wl-color-negative); }
|
||||||
|
.neutral { color: var(--mantine-color-dark-7); }
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
|
||||||
|
import type { Performance } from "@/entities/performance";
|
||||||
|
|
||||||
|
import { PerformanceView } from "./Performance.view";
|
||||||
|
|
||||||
|
const rub = (value: string) => ({ currency: "RUB", value });
|
||||||
|
|
||||||
|
const series = Array.from({ length: 24 }, (_, month) => {
|
||||||
|
const date = new Date(Date.UTC(2024, month, 1)).toISOString().slice(0, 10);
|
||||||
|
const invested = 300_000 + Math.floor(month / 6) * 150_000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
benchmarkValue: String(Math.round(invested * (1 + month * 0.009))),
|
||||||
|
date,
|
||||||
|
netInvested: String(invested),
|
||||||
|
referenceValue: String(Math.round(invested * (1 + month * 0.004))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: Performance = {
|
||||||
|
accountId: "account-1",
|
||||||
|
benchmark: {
|
||||||
|
asOf: "2025-12-01",
|
||||||
|
code: "MCFTRR",
|
||||||
|
return: { basis: "annual", unavailableReason: null, value: "0.108" },
|
||||||
|
unavailableReason: null,
|
||||||
|
value: rub("1387973"),
|
||||||
|
},
|
||||||
|
calculatedAt: "2025-12-01T00:00:00Z",
|
||||||
|
completeness: "complete",
|
||||||
|
currentValue: rub("1536400"),
|
||||||
|
excludedAccounts: [],
|
||||||
|
netInvested: rub("1060000"),
|
||||||
|
outperformance: { money: rub("148427"), percentagePoints: "0.043" },
|
||||||
|
period: { days: 700, from: "2024-01-01", to: "2025-12-01" },
|
||||||
|
profit: rub("476400"),
|
||||||
|
reference: {
|
||||||
|
asOf: "2025-12-01",
|
||||||
|
code: "IMOEX",
|
||||||
|
return: { basis: "annual", unavailableReason: null, value: "0.047" },
|
||||||
|
unavailableReason: null,
|
||||||
|
value: rub("1150000"),
|
||||||
|
},
|
||||||
|
return: { basis: "annual", unavailableReason: null, value: "0.151" },
|
||||||
|
series,
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
args: { data },
|
||||||
|
component: PerformanceView,
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
"Вкладка «Результат»: доходность, прибыль, те же деньги в индексе Мосбиржи и опережение. Синтетический серверный ответ без расчётов на клиенте.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ["autodocs"],
|
||||||
|
title: "Product/Performance",
|
||||||
|
} satisfies Meta<typeof PerformanceView>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Ahead: Story = {};
|
||||||
|
|
||||||
|
export const Behind: Story = {
|
||||||
|
args: {
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
currentValue: rub("1172400"),
|
||||||
|
outperformance: { money: rub("-215573"), percentagePoints: "-0.068" },
|
||||||
|
profit: rub("112400"),
|
||||||
|
return: { basis: "annual", unavailableReason: null, value: "0.04" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShortHistory: Story = {
|
||||||
|
args: {
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
benchmark: {
|
||||||
|
...data.benchmark,
|
||||||
|
return: { basis: "period", unavailableReason: null, value: "0.112" },
|
||||||
|
},
|
||||||
|
period: { days: 277, from: "2025-02-27", to: "2025-12-01" },
|
||||||
|
return: { basis: "period", unavailableReason: null, value: "0.074" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Incomplete: Story = {
|
||||||
|
args: {
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
completeness: "incomplete",
|
||||||
|
excludedAccounts: [
|
||||||
|
{ accountId: "usd", name: "Валютный", reason: "PORTFOLIO_VALUATION_INCOMPLETE" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { LineChart } from "@mantine/charts";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { Performance } from "@/entities/performance";
|
||||||
|
|
||||||
|
import { formatChartValue, formatDate } from "@/shared/lib";
|
||||||
|
|
||||||
|
import { performanceVm } from "../../../../model/performance";
|
||||||
|
import classes from "./Performance.module.css";
|
||||||
|
|
||||||
|
const formatRub = (value: number) => `${formatChartValue(Math.round(value))} ₽`;
|
||||||
|
|
||||||
|
function PerformanceView({ data }: Readonly<{ data: Performance }>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const vm = performanceVm(data);
|
||||||
|
|
||||||
|
const basis = vm.basis === "annual"
|
||||||
|
? t("portfolio.performance.yearly")
|
||||||
|
: t("portfolio.performance.periodLabel", { days: vm.days });
|
||||||
|
|
||||||
|
const comparisonTone = vm.comparison?.points.tone ?? "neutral";
|
||||||
|
let comparisonTitle = t("portfolio.performance.marketEqual");
|
||||||
|
if (comparisonTone === "positive") comparisonTitle = t("portfolio.performance.marketAhead");
|
||||||
|
else if (comparisonTone === "negative") comparisonTitle = t("portfolio.performance.marketBehind");
|
||||||
|
const percentText = vm.returnValue.value;
|
||||||
|
const indexText = vm.indexReturn.value;
|
||||||
|
|
||||||
|
const currentLines = vm.currentPoint
|
||||||
|
? [{ color: "blue.6", label: t("portfolio.performance.current"), x: vm.currentPoint.date }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const profitCaption = t("portfolio.performance.profitCaption", { current: vm.current, invested: vm.invested });
|
||||||
|
|
||||||
|
const chartLabel = [
|
||||||
|
`${t("portfolio.performance.invested")}: ${vm.invested}`,
|
||||||
|
`${t("portfolio.performance.benchmark")}: ${vm.indexValue}`,
|
||||||
|
`${t("portfolio.performance.current")}: ${vm.current}`,
|
||||||
|
`${t("portfolio.performance.returnLabel")}: ${percentText}`,
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{vm.excluded.length > 0 && (
|
||||||
|
<Alert role="status" title={t("portfolio.performance.incomplete")}>
|
||||||
|
{vm.excluded.map(item => (
|
||||||
|
<Text key={item.id} size="sm">
|
||||||
|
{t("portfolio.performance.unavailableReason", item)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vm.basis === "period" && (
|
||||||
|
<Alert role="status">
|
||||||
|
{t("portfolio.performance.periodNote", { days: vm.days })}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 4, sm: 2 }}>
|
||||||
|
<Paper component="dl" m={0} p="md" withBorder>
|
||||||
|
<Text component="dt" size="sm">{t("portfolio.performance.returnLabel")}</Text>
|
||||||
|
|
||||||
|
<Text className={classes[vm.returnValue.tone]} component="dd" fw={700} m={0} size="xl">
|
||||||
|
{percentText}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">{vm.returnReason ?? basis}</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper component="dl" m={0} p="md" withBorder>
|
||||||
|
<Text component="dt" size="sm">{t("portfolio.performance.profit")}</Text>
|
||||||
|
|
||||||
|
<Text className={classes[vm.profit.tone]} component="dd" fw={700} m={0} size="xl">
|
||||||
|
{vm.profit.value}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">{profitCaption}</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper component="dl" m={0} p="md" withBorder>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text component="dt" size="sm">{t("portfolio.performance.benchmark")}</Text>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
events={{ focus: true, hover: true, touch: true }}
|
||||||
|
label={t("portfolio.performance.benchmarkHint")}
|
||||||
|
multiline
|
||||||
|
w={280}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
aria-label={t("portfolio.performance.benchmarkHint")}
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
>?
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text component="dd" fw={700} m={0} size="xl">{indexText}</Text>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{vm.indexReason ?? `${basis} · ${vm.indexValue}`}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper component="dl" m={0} p="md" withBorder>
|
||||||
|
<Text component="dt" size="sm">
|
||||||
|
{comparisonTitle}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
className={classes[vm.comparison?.points.tone ?? "neutral"]}
|
||||||
|
component="dd"
|
||||||
|
fw={700}
|
||||||
|
m={0}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
{vm.comparison?.points.value ?? "—"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">{vm.comparison?.money.value ?? vm.indexReason ?? vm.returnReason}</Text>
|
||||||
|
</Paper>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Paper component="section" p="md" withBorder>
|
||||||
|
<Title mb="sm" order={2} size="h4">{t("portfolio.performance.chartTitle")}</Title>
|
||||||
|
|
||||||
|
{vm.series.length > 0
|
||||||
|
? (
|
||||||
|
<LineChart
|
||||||
|
aria-label={chartLabel}
|
||||||
|
curveType="linear"
|
||||||
|
data={vm.series}
|
||||||
|
dataKey="label"
|
||||||
|
gridAxis="y"
|
||||||
|
h={{ base: 220, sm: 300 }}
|
||||||
|
lineProps={{ isAnimationActive: false }}
|
||||||
|
referenceLines={currentLines}
|
||||||
|
series={[
|
||||||
|
{ color: "dark.4", label: t("portfolio.performance.invested"), name: "netInvested" },
|
||||||
|
{ color: "orange.6", label: t("portfolio.performance.benchmark"), name: "benchmarkValue" },
|
||||||
|
{ color: "gray.5", label: t("portfolio.performance.reference"), name: "referenceValue", strokeDasharray: "4 3" },
|
||||||
|
]}
|
||||||
|
tooltipAnimationDuration={0}
|
||||||
|
valueFormatter={formatRub}
|
||||||
|
yAxisProps={{ domain: [0, "auto"], width: 70 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: <Text c="dimmed">{t("portfolio.performance.benchmarkUnavailable")}</Text>}
|
||||||
|
|
||||||
|
<Text c="dimmed" mt="sm" size="sm">
|
||||||
|
{t("portfolio.performance.missingHistory")}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Accordion variant="contained">
|
||||||
|
<Accordion.Item value="method">
|
||||||
|
<Accordion.Control>{t("portfolio.performance.calculation")}</Accordion.Control>
|
||||||
|
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Text size="sm">
|
||||||
|
{t("portfolio.performance.datePeriod", { from: formatDate(`${vm.from}T00:00:00Z`), to: formatDate(`${vm.to}T00:00:00Z`) })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text mt="sm" size="sm">{t("portfolio.performance.method")}</Text>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PerformanceView };
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { http, HttpResponse, type JsonBodyType } from "msw";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { Performance } from "@/entities/performance";
|
||||||
|
|
||||||
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
|
import { PerformanceTab } from "./PerformanceTab";
|
||||||
|
|
||||||
|
const url = "*/api/v1/brokerage-accounts/:accountId/performance";
|
||||||
|
|
||||||
|
const rub = (value: string) => ({ currency: "RUB", value });
|
||||||
|
|
||||||
|
function performance(overrides: Partial<Performance> = {}): Performance {
|
||||||
|
return {
|
||||||
|
accountId: "account-1",
|
||||||
|
benchmark: {
|
||||||
|
asOf: "2025-12-31",
|
||||||
|
code: "MCFTRR",
|
||||||
|
return: { basis: "annual", unavailableReason: null, value: "0.048809" },
|
||||||
|
unavailableReason: null,
|
||||||
|
value: rub("110000"),
|
||||||
|
},
|
||||||
|
calculatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
completeness: "complete",
|
||||||
|
currentValue: rub("121000"),
|
||||||
|
excludedAccounts: [],
|
||||||
|
netInvested: rub("100000"),
|
||||||
|
outperformance: { money: rub("11000"), percentagePoints: "0.051191" },
|
||||||
|
period: { days: 730, from: "2024-01-01", to: "2025-12-31" },
|
||||||
|
profit: rub("21000"),
|
||||||
|
reference: {
|
||||||
|
asOf: "2025-12-31",
|
||||||
|
code: "IMOEX",
|
||||||
|
return: { basis: "annual", unavailableReason: null, value: "0.02" },
|
||||||
|
unavailableReason: null,
|
||||||
|
value: rub("105000"),
|
||||||
|
},
|
||||||
|
return: { basis: "annual", unavailableReason: null, value: "0.1" },
|
||||||
|
series: [
|
||||||
|
{ benchmarkValue: "100000", date: "2024-01-01", netInvested: "100000", referenceValue: "100000" },
|
||||||
|
{ benchmarkValue: "110000", date: "2025-12-31", netInvested: "100000", referenceValue: "105000" },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTab(body: JsonBodyType, status = 200) {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "test-token",
|
||||||
|
clear: vi.fn(),
|
||||||
|
refresh: () => Promise.resolve(true),
|
||||||
|
});
|
||||||
|
server.use(http.get(url, () => HttpResponse.json(body, { status })));
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MantineProvider>
|
||||||
|
<PerformanceTab accountId="account-1" />
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = { dependencies: [], unavailable: [] };
|
||||||
|
|
||||||
|
describe("PerformanceTab", () => {
|
||||||
|
it("shows return, profit, the same money in the index and outperformance with signs", async () => {
|
||||||
|
renderTab({ data: performance(), meta });
|
||||||
|
|
||||||
|
expect(await screen.findByText("+10,0 %")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("+21 000 ₽")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("+4,9 %")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Опережение рынка")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("+5,1 п.п.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("+11 000 ₽")).toBeInTheDocument();
|
||||||
|
// Index codes are allowed only inside "Как считали", not as captions.
|
||||||
|
expect(screen.getByText("Те же деньги в индексе Мосбиржи")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("MCFTRR")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels lagging the market without colour-only meaning", async () => {
|
||||||
|
renderTab({
|
||||||
|
data: performance({
|
||||||
|
outperformance: { money: rub("-5000"), percentagePoints: "-0.02" },
|
||||||
|
}),
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Отставание от рынка")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("−2,0 п.п.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never says annual when history is shorter than a year", async () => {
|
||||||
|
renderTab({
|
||||||
|
data: performance({
|
||||||
|
benchmark: { ...performance().benchmark, return: { basis: "period", unavailableReason: null, value: "0.05" } },
|
||||||
|
period: { days: 200, from: "2025-06-14", to: "2025-12-31" },
|
||||||
|
return: { basis: "period", unavailableReason: null, value: "0.07" },
|
||||||
|
}),
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText(/проценты показаны за 200 дн\./)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/годовых/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists excluded accounts when the all-accounts result is incomplete", async () => {
|
||||||
|
renderTab({
|
||||||
|
data: performance({
|
||||||
|
completeness: "incomplete",
|
||||||
|
excludedAccounts: [{ accountId: "usd", name: "Валютный", reason: "PORTFOLIO_VALUATION_INCOMPLETE" }],
|
||||||
|
}),
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Результат неполный")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Валютный: Нет полной оценки портфеля")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains unavailability instead of showing zeros, including unknown reasons", async () => {
|
||||||
|
renderTab({
|
||||||
|
data: null,
|
||||||
|
meta: {
|
||||||
|
dependencies: [],
|
||||||
|
unavailable: [
|
||||||
|
{ accountId: "iis", name: "ИИС", reason: "HISTORY_START_UNKNOWN" },
|
||||||
|
{ accountId: "x", name: "Новый", reason: "SOMETHING_NEW" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Доходность недоступна")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("ИИС: Начало истории счёта неизвестно")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Новый: Причину недоступности не удалось определить")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/0,0 %/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps profit and hides the comparison when the index is unavailable", async () => {
|
||||||
|
renderTab({
|
||||||
|
data: performance({
|
||||||
|
benchmark: {
|
||||||
|
asOf: null,
|
||||||
|
code: "MCFTRR",
|
||||||
|
return: { basis: "annual", unavailableReason: "INDEX_HISTORY_UNAVAILABLE", value: null },
|
||||||
|
unavailableReason: "INDEX_HISTORY_UNAVAILABLE",
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
outperformance: null,
|
||||||
|
series: [],
|
||||||
|
}),
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("+21 000 ₽")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Сравнение с индексом сейчас недоступно")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not colour or call a rounded-to-zero difference outperformance", async () => {
|
||||||
|
renderTab({
|
||||||
|
data: performance({
|
||||||
|
outperformance: { money: rub("0"), percentagePoints: "-0.0004" },
|
||||||
|
}),
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Сравнение с рынком")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("0,0 п.п.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains a pending sync when no account reasons are available", async () => {
|
||||||
|
renderTab({ data: null, meta });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/ещё синхронизируются/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a local error with retry", async () => {
|
||||||
|
renderTab({ code: "INTERNAL_SERVER_ERROR", status: 500 }, 500);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Не удалось загрузить результат инвестирования", {}, { timeout: 8000 })).toBeInTheDocument();
|
||||||
|
server.use(http.get(
|
||||||
|
url,
|
||||||
|
() => HttpResponse.json({ data: performance(), meta }),
|
||||||
|
));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
|
expect(await screen.findByText("+10,0 %")).toBeInTheDocument();
|
||||||
|
}, 10_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Button, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { usePerformance } from "@/entities/performance";
|
||||||
|
|
||||||
|
import { reasonText } from "../../../../model/performance";
|
||||||
|
import { PerformanceView } from "./Performance.view";
|
||||||
|
|
||||||
|
function PerformanceTab({ accountId }: Readonly<{ accountId: string }>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const query = usePerformance(accountId);
|
||||||
|
|
||||||
|
if (query.isPending) return (
|
||||||
|
<Stack aria-label={t("portfolio.performance.returnLabel")} gap="md">
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 4, sm: 2 }}>
|
||||||
|
{["rate", "profit", "benchmark", "difference"].map(key => (
|
||||||
|
<Paper key={key} p="md" withBorder>
|
||||||
|
<Skeleton h={14} mb="sm" w="60%" />
|
||||||
|
|
||||||
|
<Skeleton h={28} w="80%" />
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Skeleton h={220} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRetry = () => {
|
||||||
|
void query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.isError) return (
|
||||||
|
<Paper p="lg" withBorder>
|
||||||
|
<Stack align="flex-start" gap="sm">
|
||||||
|
<Text>{t("portfolio.performance.error")}</Text>
|
||||||
|
|
||||||
|
<Button onClick={onRetry} variant="light">
|
||||||
|
{t("portfolio.performance.retry")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
if (!query.data.data) return (
|
||||||
|
<Paper p="xl" withBorder>
|
||||||
|
<Title order={2} size="h4">{t("portfolio.performance.unavailable")}</Title>
|
||||||
|
|
||||||
|
{query.data.meta.unavailable.length === 0 && (
|
||||||
|
<Text c="dimmed" mt="sm">{t("portfolio.performance.syncing")}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query.data.meta.unavailable.map(item => (
|
||||||
|
<Text c="dimmed" key={item.accountId} mt="sm">
|
||||||
|
{t("portfolio.performance.unavailableReason", {
|
||||||
|
name: item.name,
|
||||||
|
reason: reasonText(item.reason),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <PerformanceView data={query.data.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PerformanceTab };
|
||||||
+19
-52
@@ -4,20 +4,20 @@ import type { Portfolio } from "@/entities/portfolio";
|
|||||||
|
|
||||||
import { PortfolioStructure } from "./PortfolioStructure";
|
import { PortfolioStructure } from "./PortfolioStructure";
|
||||||
|
|
||||||
const rub: Portfolio["structure"]["currencies"][number] = {
|
const rub: Portfolio["structure"] = {
|
||||||
categories: [
|
categories: [
|
||||||
{ sharePercent: "52", type: "STOCK", value: "650000" },
|
{ sharePercent: "52", type: "STOCK", value: "650000" },
|
||||||
{ sharePercent: "30", type: "BOND", value: "375000" },
|
{ sharePercent: "30", type: "BOND", value: "375000" },
|
||||||
{ sharePercent: "10", type: "OTHER", value: "125000" },
|
{ sharePercent: "10", type: "OTHER", value: "125000" },
|
||||||
{ sharePercent: "8", type: "CASH", value: "100000" },
|
{ sharePercent: "8", type: "CASH", value: "100000" },
|
||||||
],
|
],
|
||||||
currency: "RUB",
|
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
totalValue: "1250000",
|
totalValue: "1250000",
|
||||||
|
unvaluedCategories: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
|
args: { structure: rub },
|
||||||
component: PortfolioStructure,
|
component: PortfolioStructure,
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
@@ -28,7 +28,7 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
title: "Widgets/PortfolioStructure",
|
title: "Product/PortfolioStructure",
|
||||||
} satisfies Meta<typeof PortfolioStructure>;
|
} satisfies Meta<typeof PortfolioStructure>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -36,60 +36,34 @@ export default meta;
|
|||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Regular: Story = {};
|
export const Regular: Story = {};
|
||||||
export const MultipleCurrencies: Story = {
|
|
||||||
args: {
|
|
||||||
structure: {
|
|
||||||
currencies: [
|
|
||||||
rub,
|
|
||||||
{
|
|
||||||
categories: [{ sharePercent: "100", type: "CASH", value: "500" }],
|
|
||||||
currency: "USD",
|
|
||||||
hasNegativeValues: false,
|
|
||||||
totalValue: "500",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
unvaluedCategories: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
export const CashOnly: Story = {
|
export const CashOnly: Story = {
|
||||||
args: {
|
args: {
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [
|
...rub,
|
||||||
{
|
|
||||||
categories: rub.categories.map(row => ({
|
categories: rub.categories.map(row => ({
|
||||||
...row,
|
...row,
|
||||||
sharePercent: row.type === "CASH" ? "100" : "0",
|
sharePercent: row.type === "CASH" ? "100" : "0",
|
||||||
value: row.type === "CASH" ? "100000" : "0",
|
value: row.type === "CASH" ? "100000" : "0",
|
||||||
})),
|
})),
|
||||||
currency: "RUB",
|
|
||||||
hasNegativeValues: false,
|
|
||||||
totalValue: "100000",
|
totalValue: "100000",
|
||||||
},
|
},
|
||||||
],
|
|
||||||
unvaluedCategories: [],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
args: { structure: { currencies: [], unvaluedCategories: [] } },
|
args: {
|
||||||
|
structure: {
|
||||||
|
categories: [],
|
||||||
|
hasNegativeValues: false,
|
||||||
|
totalValue: "0",
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
export const Incomplete: Story = {
|
export const Incomplete: Story = {
|
||||||
args: {
|
args: {
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
...rub,
|
...rub,
|
||||||
categories: rub.categories.map(
|
categories: rub.categories.map(row => ({ ...row, sharePercent: null })),
|
||||||
|
|
||||||
row =>
|
|
||||||
({
|
|
||||||
...row,
|
|
||||||
sharePercent: null,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
unvaluedCategories: [{ count: 2, type: "STOCK" }],
|
unvaluedCategories: [{ count: 2, type: "STOCK" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -97,7 +71,9 @@ export const Incomplete: Story = {
|
|||||||
export const UnknownOnly: Story = {
|
export const UnknownOnly: Story = {
|
||||||
args: {
|
args: {
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [],
|
categories: [],
|
||||||
|
hasNegativeValues: false,
|
||||||
|
totalValue: "0",
|
||||||
unvaluedCategories: [{ count: 1, type: "BOND" }],
|
unvaluedCategories: [{ count: 1, type: "BOND" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -105,23 +81,14 @@ export const UnknownOnly: Story = {
|
|||||||
export const Negative: Story = {
|
export const Negative: Story = {
|
||||||
args: {
|
args: {
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
...rub,
|
...rub,
|
||||||
categories: rub.categories.map(
|
categories: rub.categories.map(row => ({
|
||||||
|
|
||||||
row =>
|
|
||||||
({
|
|
||||||
...row,
|
...row,
|
||||||
sharePercent: null,
|
sharePercent: null,
|
||||||
value: row.type === "OTHER" ? "25000" : row.value,
|
value: row.type === "OTHER" ? "25000" : row.value,
|
||||||
}),
|
})),
|
||||||
),
|
|
||||||
hasNegativeValues: true,
|
hasNegativeValues: true,
|
||||||
totalValue: "1150000",
|
totalValue: "1150000",
|
||||||
},
|
},
|
||||||
],
|
|
||||||
unvaluedCategories: [],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+15
-42
@@ -1,21 +1,21 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
|
||||||
import { PortfolioStructure } from "./PortfolioStructure";
|
import { PortfolioStructure } from "./PortfolioStructure";
|
||||||
|
|
||||||
const rub: Portfolio["structure"]["currencies"][number] = {
|
const rub: Portfolio["structure"] = {
|
||||||
categories: [
|
categories: [
|
||||||
{ sharePercent: "60", type: "STOCK", value: "60" },
|
{ sharePercent: "60", type: "STOCK", value: "60" },
|
||||||
{ sharePercent: "20", type: "BOND", value: "20" },
|
{ sharePercent: "20", type: "BOND", value: "20" },
|
||||||
{ sharePercent: "0", type: "OTHER", value: "0" },
|
{ sharePercent: "0", type: "OTHER", value: "0" },
|
||||||
{ sharePercent: "20", type: "CASH", value: "20" },
|
{ sharePercent: "20", type: "CASH", value: "20" },
|
||||||
],
|
],
|
||||||
currency: "RUB",
|
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
totalValue: "100",
|
totalValue: "100",
|
||||||
|
unvaluedCategories: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ui = (structure: null | Portfolio["structure"] | undefined) => (
|
const ui = (structure: null | Portfolio["structure"] | undefined) => (
|
||||||
@@ -39,7 +39,7 @@ describe("portfolio structure", () => {
|
|||||||
screen.queryByText(/Нет положительной стоимости/),
|
screen.queryByText(/Нет положительной стоимости/),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||||
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
|
view.rerender(ui(rub));
|
||||||
expect(screen.getByRole("list")).toHaveTextContent("Акции60 ₽60 %");
|
expect(screen.getByRole("list")).toHaveTextContent("Акции60 ₽60 %");
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("Данные о структуре портфеля пока недоступны."),
|
screen.queryByText("Данные о структуре портфеля пока недоступны."),
|
||||||
@@ -47,41 +47,23 @@ describe("portfolio structure", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
it("shows exact server amounts and cash-inclusive shares without hovering", () => {
|
it("shows exact server amounts and cash-inclusive shares without hovering", () => {
|
||||||
render(ui({ currencies: [rub], unvaluedCategories: [] }));
|
render(ui(rub));
|
||||||
const list = screen.getByRole("list", { name: "Категории активов" });
|
const list = screen.getByRole("list", { name: "Категории активов" });
|
||||||
expect(list).toHaveTextContent("Акции60 ₽60 %");
|
expect(list).toHaveTextContent("Акции60 ₽60 %");
|
||||||
expect(list).toHaveTextContent("Свободные деньги20 ₽20 %");
|
expect(list).toHaveTextContent("Свободные деньги20 ₽20 %");
|
||||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it("switches currencies and falls back when a refresh removes the selection", () => {
|
|
||||||
const usd = { ...rub, currency: "USD" };
|
|
||||||
const view = render(ui({ currencies: [rub, usd], unvaluedCategories: [] }));
|
|
||||||
fireEvent.click(
|
|
||||||
screen.getByRole("combobox", { name: "Валюта структуры портфеля" }),
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByRole("option", { name: "USD" }));
|
|
||||||
expect(screen.getByRole("list")).toHaveTextContent("60 USD");
|
|
||||||
expect(screen.getByRole("list")).not.toHaveTextContent("₽");
|
|
||||||
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
|
|
||||||
expect(screen.getByRole("list")).toHaveTextContent("60 ₽");
|
|
||||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it("retains known amounts and names unvalued categories without inventing a zero", () => {
|
it("retains known amounts and names unvalued categories without inventing a zero", () => {
|
||||||
render(
|
render(
|
||||||
ui({
|
ui({
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
...rub,
|
...rub,
|
||||||
categories: rub.categories.map(
|
categories: rub.categories.map(
|
||||||
|
|
||||||
row =>
|
row =>
|
||||||
({
|
({
|
||||||
...row,
|
...row,
|
||||||
sharePercent: null,
|
sharePercent: null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
|
||||||
],
|
|
||||||
unvaluedCategories: [
|
unvaluedCategories: [
|
||||||
{ count: 2, type: "OTHER" },
|
{ count: 2, type: "OTHER" },
|
||||||
{ count: 1, type: "STOCK" },
|
{ count: 1, type: "STOCK" },
|
||||||
@@ -89,10 +71,8 @@ describe("portfolio structure", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/Оценка неполная/)).toBeInTheDocument();
|
expect(screen.getByText(/Оценка неполная/)).toBeInTheDocument();
|
||||||
expect(screen.getByRole("list")).toHaveTextContent("Известно: 60 ₽");
|
expect(screen.getByRole("list")).toHaveTextContent("АкцииИзвестно: 60 ₽");
|
||||||
expect(screen.getByRole("list")).toHaveTextContent(
|
expect(screen.getByRole("list")).toHaveTextContent("Другие активы");
|
||||||
"Другие активыНет оценки",
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.queryByLabelText(/Структура по типам/),
|
screen.queryByLabelText(/Структура по типам/),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
@@ -100,11 +80,8 @@ describe("portfolio structure", () => {
|
|||||||
it("keeps negative amounts visible without a donut or invented positive shares", () => {
|
it("keeps negative amounts visible without a donut or invented positive shares", () => {
|
||||||
render(
|
render(
|
||||||
ui({
|
ui({
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
...rub,
|
...rub,
|
||||||
categories: rub.categories.map(
|
categories: rub.categories.map(
|
||||||
|
|
||||||
row =>
|
row =>
|
||||||
({
|
({
|
||||||
...row,
|
...row,
|
||||||
@@ -113,9 +90,6 @@ describe("portfolio structure", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
hasNegativeValues: true,
|
hasNegativeValues: true,
|
||||||
},
|
|
||||||
],
|
|
||||||
unvaluedCategories: [],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/Есть отрицательные/)).toBeInTheDocument();
|
expect(screen.getByText(/Есть отрицательные/)).toBeInTheDocument();
|
||||||
@@ -128,20 +102,21 @@ describe("portfolio structure", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it("handles unknown-only and empty compositions", () => {
|
it("handles unknown-only and empty compositions", () => {
|
||||||
const view = render(
|
render(
|
||||||
ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }),
|
ui({
|
||||||
|
categories: [],
|
||||||
|
hasNegativeValues: false,
|
||||||
|
totalValue: "0",
|
||||||
|
unvaluedCategories: [{ count: 1, type: "BOND" }],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
|
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||||
view.rerender(ui({ currencies: [], unvaluedCategories: [] }));
|
|
||||||
expect(screen.getByText(/Нет положительной стоимости/)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
it("keeps full decimal amounts and describes tiny shares", () => {
|
it("keeps full decimal amounts and describes tiny shares", () => {
|
||||||
render(
|
render(
|
||||||
ui({
|
ui({
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
...rub,
|
...rub,
|
||||||
categories: [
|
categories: [
|
||||||
{
|
{
|
||||||
@@ -150,9 +125,7 @@ describe("portfolio structure", () => {
|
|||||||
value: "9007199254740993.000000001",
|
value: "9007199254740993.000000001",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
totalValue: "9007199254740993.000000001",
|
||||||
],
|
|
||||||
unvaluedCategories: [],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("list")).toHaveTextContent(
|
expect(screen.getByRole("list")).toHaveTextContent(
|
||||||
+3
-17
@@ -1,5 +1,5 @@
|
|||||||
import { DonutChart } from "@mantine/charts";
|
import { DonutChart } from "@mantine/charts";
|
||||||
import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core";
|
import { Box, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||||
import { type FC, useId } from "react";
|
import { type FC, useId } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -7,11 +7,8 @@ import classes from "./PortfolioStructure.module.css";
|
|||||||
|
|
||||||
type PortfolioStructureViewProps = {
|
type PortfolioStructureViewProps = {
|
||||||
chartData: StructureRow[];
|
chartData: StructureRow[];
|
||||||
currencies: string[];
|
|
||||||
currency: null | string;
|
|
||||||
incomplete: boolean;
|
incomplete: boolean;
|
||||||
negative: boolean;
|
negative: boolean;
|
||||||
onCurrencyChange: (currency: null | string) => void;
|
|
||||||
rows: StructureRow[];
|
rows: StructureRow[];
|
||||||
unavailable: boolean;
|
unavailable: boolean;
|
||||||
unvalued: string[];
|
unvalued: string[];
|
||||||
@@ -64,17 +61,6 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
|||||||
<Title id={titleId} order={2} size="h4">
|
<Title id={titleId} order={2} size="h4">
|
||||||
{t("portfolio.structure.title")}
|
{t("portfolio.structure.title")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{props.currencies.length > 1 && (
|
|
||||||
<Select
|
|
||||||
allowDeselect={false}
|
|
||||||
aria-label={t("portfolio.structure.currency")}
|
|
||||||
data={props.currencies}
|
|
||||||
onChange={props.onCurrencyChange}
|
|
||||||
value={props.currency}
|
|
||||||
w={112}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
@@ -110,11 +96,11 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={appearanceClass} key={props.currency}>
|
<div className={appearanceClass}>
|
||||||
{props.chartData.length > 0 && (
|
{props.chartData.length > 0 && (
|
||||||
<DonutChart
|
<DonutChart
|
||||||
aria-label={t("portfolio.structure.chartLabel")}
|
aria-label={t("portfolio.structure.chartLabel")}
|
||||||
chartLabel={props.currency ?? undefined}
|
chartLabel="₽"
|
||||||
data={props.chartData}
|
data={props.chartData}
|
||||||
endAngle={-270}
|
endAngle={-270}
|
||||||
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
|
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
|
||||||
+7
-22
@@ -1,6 +1,5 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
@@ -15,24 +14,15 @@ type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
|
|||||||
const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
|
const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
|
||||||
const { structure } = props;
|
const { structure } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [currency, setCurrency] = useState<null | string>(null);
|
|
||||||
|
|
||||||
const selected
|
|
||||||
= structure?.currencies.find(item => item.currency === currency)
|
|
||||||
?? structure?.currencies[0];
|
|
||||||
|
|
||||||
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
|
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
|
||||||
const rows = portfolioRows(selected, structure, t);
|
const rows = portfolioRows(structure, t);
|
||||||
const chartData = rows.filter(row => row.value > 0);
|
const chartData = rows.filter(row => row.value > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chartData,
|
chartData,
|
||||||
currencies: structure?.currencies.map(item => item.currency) ?? [],
|
|
||||||
|
|
||||||
currency: selected?.currency ?? null,
|
|
||||||
incomplete: isIncomplete,
|
incomplete: isIncomplete,
|
||||||
negative: selected?.hasNegativeValues ?? false,
|
negative: structure?.hasNegativeValues ?? false,
|
||||||
onCurrencyChange: setCurrency,
|
|
||||||
rows,
|
rows,
|
||||||
unavailable: !structure,
|
unavailable: !structure,
|
||||||
unvalued:
|
unvalued:
|
||||||
@@ -46,14 +36,12 @@ const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function portfolioRows(
|
function portfolioRows(
|
||||||
selected:
|
|
||||||
NonNullable<Portfolio["structure"]>["currencies"][number] | undefined,
|
|
||||||
structure: PortfolioStructureProps["structure"],
|
structure: PortfolioStructureProps["structure"],
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
) {
|
) {
|
||||||
if (!selected) return [];
|
if (!structure) return [];
|
||||||
|
|
||||||
return selected.categories.map((category) => {
|
return structure.categories.map((category) => {
|
||||||
const presentation = portfolioCategoryPresentation[category.type];
|
const presentation = portfolioCategoryPresentation[category.type];
|
||||||
|
|
||||||
const percentage
|
const percentage
|
||||||
@@ -61,16 +49,13 @@ function portfolioRows(
|
|||||||
? undefined
|
? undefined
|
||||||
: Number(category.sharePercent);
|
: Number(category.sharePercent);
|
||||||
|
|
||||||
const unknown = structure?.unvaluedCategories.some(
|
const isUnknown = structure.unvaluedCategories.some(
|
||||||
item => item.type === category.type,
|
item => item.type === category.type,
|
||||||
);
|
);
|
||||||
|
|
||||||
let amount = formatMoney({
|
let amount = formatMoney({ currency: "RUB", value: category.value });
|
||||||
currency: selected.currency,
|
|
||||||
value: category.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (unknown) {
|
if (isUnknown) {
|
||||||
amount
|
amount
|
||||||
= category.value === "0"
|
= category.value === "0"
|
||||||
? t("portfolio.structure.unvalued")
|
? t("portfolio.structure.unvalued")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -31,16 +31,26 @@ function configureTestAuth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function portfolioSummary(totals: { currency: string; value: string }[]) {
|
function portfolioSummary(totals: { currency: string; value: string }) {
|
||||||
return {
|
return {
|
||||||
bondPositionCount: 1,
|
bondPositionCount: 1,
|
||||||
cashBalances: [],
|
cash: { currency: "RUB", value: "0" },
|
||||||
fundPositionCount: 0,
|
fundPositionCount: 0,
|
||||||
stockPositionCount: 0,
|
stockPositionCount: 0,
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: {
|
||||||
|
categories: [],
|
||||||
|
hasNegativeValues: false,
|
||||||
|
totalValue: "0",
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
totalPositionCount: 1,
|
totalPositionCount: 1,
|
||||||
totalsByCurrency: totals,
|
totals,
|
||||||
unrealizedReturnByCurrency: [],
|
unrealizedReturn: {
|
||||||
|
absolute: "0",
|
||||||
|
costBasis: "0",
|
||||||
|
partial: false,
|
||||||
|
percent: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,23 +69,18 @@ function renderPage(queryClient: QueryClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountsPage", () => {
|
describe("AccountsPage", () => {
|
||||||
it("summarizes currencies separately and reports excluded accounts", async () => {
|
it("summarizes account totals and reports excluded accounts", async () => {
|
||||||
server.use(http.get("*/api/v1/brokerage-accounts", () =>
|
server.use(http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
HttpResponse.json(resource([
|
HttpResponse.json(resource([
|
||||||
{
|
{
|
||||||
...account,
|
...account,
|
||||||
portfolioSummary: portfolioSummary([
|
portfolioSummary: portfolioSummary({ currency: "RUB", value: "100.10" }),
|
||||||
{ currency: "RUB", value: "100.10" },
|
|
||||||
{ currency: "USD", value: "20" },
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...account,
|
...account,
|
||||||
id: "account-2",
|
id: "account-2",
|
||||||
name: "Второй",
|
name: "Второй",
|
||||||
portfolioSummary: portfolioSummary([
|
portfolioSummary: portfolioSummary({ currency: "RUB", value: "50.20" }),
|
||||||
{ currency: "RUB", value: "50.20" },
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...account,
|
...account,
|
||||||
@@ -88,9 +93,9 @@ describe("AccountsPage", () => {
|
|||||||
id: "account-4",
|
id: "account-4",
|
||||||
name: "Только деньги",
|
name: "Только деньги",
|
||||||
portfolioSummary: {
|
portfolioSummary: {
|
||||||
...portfolioSummary([{ currency: "RUB", value: "25" }]),
|
...portfolioSummary({ currency: "RUB", value: "25" }),
|
||||||
bondPositionCount: 0,
|
bondPositionCount: 0,
|
||||||
cashBalances: [{ currency: "RUB", value: "25" }],
|
cash: { currency: "RUB", value: "25" },
|
||||||
totalPositionCount: 0,
|
totalPositionCount: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,7 +104,7 @@ describe("AccountsPage", () => {
|
|||||||
id: "account-5",
|
id: "account-5",
|
||||||
name: "Без сумм",
|
name: "Без сумм",
|
||||||
portfolioSummary: {
|
portfolioSummary: {
|
||||||
...portfolioSummary([]),
|
...portfolioSummary({ currency: "RUB", value: "0" }),
|
||||||
totalPositionCount: 0,
|
totalPositionCount: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -110,7 +115,8 @@ describe("AccountsPage", () => {
|
|||||||
renderPage(client);
|
renderPage(client);
|
||||||
|
|
||||||
expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
|
expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("20,00 USD")).not.toHaveLength(0);
|
expect(screen.queryByText("Ваши инвестиции по счетам"))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
expect(screen.getByText("5 счетов")).toBeInTheDocument();
|
expect(screen.getByText("5 счетов")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
|
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
|
||||||
expect(screen.getByText(
|
expect(screen.getByText(
|
||||||
@@ -261,6 +267,101 @@ describe("AccountsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Automatic read retries wait 1s + 2s before the failure is final.
|
||||||
|
const RETRIES_EXHAUSTED_MS = 6000;
|
||||||
|
const TEST_MS = 10_000;
|
||||||
|
|
||||||
|
describe("exhausted read retries", () => {
|
||||||
|
it(
|
||||||
|
"keeps the cached list, warns and retries the same accounts GET",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
let isFailing = false;
|
||||||
|
let requests = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", () => {
|
||||||
|
requests += 1;
|
||||||
|
if (isFailing)
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return HttpResponse.json(resource([account]));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
renderPage(queryClient);
|
||||||
|
await screen.findByText("Основной");
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
|
isFailing = true;
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: brokerageAccountsQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const retry = await screen.findByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Повторить" },
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Основной")).toBeInTheDocument();
|
||||||
|
expect(requests).toBe(4);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("Не удалось обновить данные"))
|
||||||
|
.not.toBeInTheDocument());
|
||||||
|
expect(requests).toBe(5);
|
||||||
|
expect(screen.getByText("Основной")).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"offers retry on the unavailable state after an initial failure",
|
||||||
|
{ timeout: TEST_MS },
|
||||||
|
async () => {
|
||||||
|
let isFailing = true;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
isFailing
|
||||||
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
|
: HttpResponse.json(resource([account]))),
|
||||||
|
);
|
||||||
|
renderPage(createTestQueryClient());
|
||||||
|
|
||||||
|
const retry = await screen.findByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Повторить" },
|
||||||
|
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
isFailing = false;
|
||||||
|
fireEvent.click(retry);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Основной")).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not offer a plain retry when the connection is lost", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
HttpResponse.json({ code: "TBANK_NOT_CONNECTED" }, { status: 409 })),
|
||||||
|
);
|
||||||
|
renderPage(createTestQueryClient());
|
||||||
|
|
||||||
|
expect(await screen.findByRole("link", { name: "Открыть настройки T-Bank" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["none", "failed"] as const)(
|
it.each(["none", "failed"] as const)(
|
||||||
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
|
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
|
||||||
async (state) => {
|
async (state) => {
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useAccountsPageViewModel } from "./AccountsPage.vm";
|
import { useAccountsPageVm } from "./AccountsPage.vm";
|
||||||
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
||||||
import { AccountsPageError } from "./components/AccountsPageError.view";
|
import { AccountsPageError } from "./components/AccountsPageError.view";
|
||||||
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
|
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
|
||||||
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
|
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
|
||||||
|
|
||||||
const AccountsPage: FC = () => {
|
const AccountsPage: FC = () => {
|
||||||
const viewModel = useAccountsPageViewModel();
|
const vm = useAccountsPageVm();
|
||||||
if (viewModel.state === "loading") return <AccountsPageLoading />;
|
if (vm.state === "loading") return <AccountsPageLoading />;
|
||||||
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
|
if (vm.state === "error") return <AccountsPageError {...vm} />;
|
||||||
if (viewModel.state === "empty") return <AccountsPageEmpty />;
|
if (vm.state === "empty") return <AccountsPageEmpty />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountsPageLoaded
|
<AccountsPageLoaded
|
||||||
accounts={viewModel.accounts}
|
accounts={vm.accounts}
|
||||||
meta={viewModel.meta}
|
meta={vm.meta}
|
||||||
|
readRetry={vm.readRetry}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,24 @@ import {
|
|||||||
useBrokerageAccounts,
|
useBrokerageAccounts,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
import {
|
||||||
|
ApiErrorCode,
|
||||||
|
apiErrorCode,
|
||||||
|
isApiErrorCode,
|
||||||
|
readRetry,
|
||||||
|
} from "@/shared/api";
|
||||||
import { useInitialAvailability } from "@/shared/lib";
|
import { useInitialAvailability } from "@/shared/lib";
|
||||||
|
|
||||||
|
type AccountsPageReadRetry = ReturnType<typeof readRetry>;
|
||||||
|
|
||||||
type AccountsPageViewModel
|
type AccountsPageViewModel
|
||||||
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
|
= | {
|
||||||
| { code?: string; state: "error" }
|
accounts: BrokerageAccountCard[];
|
||||||
|
meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>;
|
||||||
|
readRetry: AccountsPageReadRetry;
|
||||||
|
state: "loaded";
|
||||||
|
}
|
||||||
|
| { code?: string; readRetry: AccountsPageReadRetry; state: "error" }
|
||||||
| { state: "empty" }
|
| { state: "empty" }
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
@@ -18,11 +30,12 @@ function isAccountsAccessFailure(code?: string) {
|
|||||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAccountsPageViewModel(): AccountsPageViewModel {
|
function useAccountsPageVm(): AccountsPageViewModel {
|
||||||
const accounts = useBrokerageAccounts();
|
const accounts = useBrokerageAccounts();
|
||||||
useScreenSync({ screen: "accounts" }, [accounts]);
|
useScreenSync({ screen: "accounts" }, [accounts]);
|
||||||
|
|
||||||
const code = apiErrorCode(accounts.error);
|
const code = apiErrorCode(accounts.error);
|
||||||
|
const retry = readRetry([accounts]);
|
||||||
const isAccessFailure = isAccountsAccessFailure(code);
|
const isAccessFailure = isAccountsAccessFailure(code);
|
||||||
|
|
||||||
const hasResult = !!accounts.data
|
const hasResult = !!accounts.data
|
||||||
@@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
|||||||
isFetching: accounts.isFetching,
|
isFetching: accounts.isFetching,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isAccessFailure) return { code, state: "error" as const };
|
if (isAccessFailure)
|
||||||
|
return { code, readRetry: retry, state: "error" as const };
|
||||||
|
|
||||||
if (!hasResult || !accounts.data)
|
if (!hasResult || !accounts.data)
|
||||||
return {
|
return availability === "unavailable"
|
||||||
state: availability === "unavailable" ? "error" : "loading",
|
? { code, readRetry: retry, state: "error" as const }
|
||||||
} as const;
|
: { state: "loading" as const };
|
||||||
|
|
||||||
if (accounts.data.length === 0)
|
if (accounts.data.length === 0)
|
||||||
return {
|
return {
|
||||||
@@ -54,8 +68,9 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
|||||||
return {
|
return {
|
||||||
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
|
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
|
||||||
meta,
|
meta,
|
||||||
|
readRetry: retry,
|
||||||
state: "loaded" as const,
|
state: "loaded" as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useAccountsPageViewModel };
|
export { useAccountsPageVm };
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
|
||||||
|
|
||||||
type AccountsPageErrorProps = { code?: string };
|
type AccountsPageErrorProps = {
|
||||||
|
code?: string;
|
||||||
|
readRetry?: ReadRetryProps;
|
||||||
|
};
|
||||||
|
|
||||||
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||||
const { code } = props;
|
const { code } = props;
|
||||||
@@ -38,8 +42,18 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const retry = props.readRetry?.exhausted
|
||||||
|
? (
|
||||||
|
<ReadRetryButton
|
||||||
|
onRetry={props.readRetry.onRetry}
|
||||||
|
retrying={props.readRetry.retrying}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
|
action={retry}
|
||||||
text={t("states.unavailableText")}
|
text={t("states.unavailableText")}
|
||||||
title={t("states.unavailableTitle")}
|
title={t("states.unavailableTitle")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.summary {
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: var(--mantine-spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--wl-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryTotal {
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otherTotal {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
padding-top: var(--mantine-spacing-xs);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { Button, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
BrokerageAccountList,
|
BrokerageAccountList,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import { SourceSyncStatus } from "@/shared/ui";
|
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
|
import classes from "./AccountsPageLoaded.module.css";
|
||||||
|
|
||||||
const DECIMAL_BASE = 10n;
|
const DECIMAL_BASE = 10n;
|
||||||
const DECIMAL_PART_COUNT = 2;
|
const DECIMAL_PART_COUNT = 2;
|
||||||
@@ -20,36 +22,19 @@ const ZERO = 0n;
|
|||||||
type AccountsPageLoadedProps = {
|
type AccountsPageLoadedProps = {
|
||||||
accounts: BrokerageAccountCard[];
|
accounts: BrokerageAccountCard[];
|
||||||
meta: InvestmentResourceMeta;
|
meta: InvestmentResourceMeta;
|
||||||
|
readRetry?: ReadRetryProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Money = { currency: string; value: string };
|
function accountTotals(accounts: BrokerageAccountCard[]): null | string {
|
||||||
|
const values: string[] = [];
|
||||||
function accountTotals(accounts: BrokerageAccountCard[]) {
|
|
||||||
const values = new Map<string, string[]>();
|
|
||||||
let excludedCount = 0;
|
|
||||||
|
|
||||||
for (const { summary } of accounts) {
|
for (const { summary } of accounts) {
|
||||||
if (summary.state === "empty") continue;
|
if (summary.state !== "available") continue;
|
||||||
|
|
||||||
if (summary.state === "unavailable") {
|
values.push(summary.portfolio.totals.value);
|
||||||
excludedCount += 1;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const total of summary.portfolio.totalsByCurrency) {
|
return values.length > 0 ? sumDecimals(values) : null;
|
||||||
values.set(total.currency, [
|
|
||||||
...values.get(total.currency) ?? [],
|
|
||||||
total.value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals: Money[] = Array.from(values, ([currency, amounts]) => ({
|
|
||||||
currency,
|
|
||||||
value: sumDecimals(amounts),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { excludedCount, totals };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sumDecimals(values: string[]) {
|
function sumDecimals(values: string[]) {
|
||||||
@@ -85,53 +70,50 @@ function sumDecimals(values: string[]) {
|
|||||||
|
|
||||||
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { excludedCount, totals } = accountTotals(props.accounts);
|
const total = accountTotals(props.accounts);
|
||||||
|
|
||||||
|
const excludedCount = props.accounts.filter(
|
||||||
|
account => account.summary.state === "unavailable",
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Stack gap={4}>
|
|
||||||
<Title order={1}>{t("accounts.title")}</Title>
|
<Title order={1}>{t("accounts.title")}</Title>
|
||||||
|
|
||||||
<Text c="dimmed" size="sm">
|
<Stack className={classes.summary} gap="xs">
|
||||||
{t("accounts.subtitle")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="xs">
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="xs">
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{t("accounts.currentValue")}
|
{t("accounts.currentValue")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{totals.length > 0
|
{total === null
|
||||||
? totals.map(total => (
|
? (
|
||||||
<Text fw={700} key={total.currency} size="lg">
|
<Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>
|
||||||
{formatMoney(total, { withRoundedCents: true })}
|
)
|
||||||
</Text>
|
: (
|
||||||
))
|
<Text className={classes.primaryTotal} fw={700}>
|
||||||
: <Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>}
|
{formatMoney({ currency: "RUB", value: total }, { withRoundedCents: true })}
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Text fw={600}>{t("accounts.count", {
|
|
||||||
count: props.accounts.length,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{excludedCount > 0 && (
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{t("accounts.summaryExcluded", { count: excludedCount })}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
|
||||||
</SimpleGrid>
|
<Group className={classes.meta} gap="xs" wrap="wrap">
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("accounts.count", { count: props.accounts.length })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<SourceSyncStatus
|
<SourceSyncStatus
|
||||||
lastAttempt={props.meta.lastAttempt}
|
lastAttempt={props.meta.lastAttempt}
|
||||||
lastSuccessfulAt={props.meta.lastSuccessfulAt}
|
lastSuccessfulAt={props.meta.lastSuccessfulAt}
|
||||||
stale={props.meta.freshness === "stale"}
|
stale={props.meta.freshness === "stale"}
|
||||||
/>
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{excludedCount > 0 && (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("accounts.summaryExcluded", { count: excludedCount })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user