Compare commits
19
Commits
19fe99695c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd7d59e65 | ||
|
|
668f2ef70e | ||
|
|
cadba8d075 | ||
|
|
2db39f25d9 | ||
|
|
2d070ce2b5 | ||
|
|
5077bd4f0b | ||
|
|
e2d143df46 | ||
|
|
ad93725a84 | ||
|
|
8ce1e8e444 | ||
|
|
aaee9591d7 | ||
|
|
9619dc9791 | ||
|
|
71fbe7774d | ||
|
|
870b7e6327 | ||
|
|
70a2c335fd | ||
|
|
7242240b21 | ||
|
|
71126c91f0 | ||
|
|
6792f5b70a | ||
|
|
32e8fe9402 | ||
|
|
5796d2d789 |
+6
-7
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
|
||||
[package.json](../package.json), реализация — source of truth.
|
||||
|
||||
```text
|
||||
app → pages → widgets → features → entities → shared
|
||||
app → pages → features → entities → shared
|
||||
page container → page VM → entity/feature query → shared/api → backend /api/v1
|
||||
↓
|
||||
props → state/section views
|
||||
@@ -30,14 +30,13 @@ contract; frontend не читает его checkout для генерации
|
||||
| --- | --- |
|
||||
| Bootstrap, глобальные providers, routes | `app` |
|
||||
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
|
||||
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
|
||||
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
|
||||
| Представление/поведение бизнес-сущности | `entities/<entity>` |
|
||||
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
|
||||
|
||||
Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
|
||||
Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
|
||||
одной page остаётся у page. Form, mutation или click handler сами по себе не
|
||||
Локальный FSD profile: `app → pages → features → entities → shared`.
|
||||
Слой `widgets` не используется: композиция экрана и компонент для одной page
|
||||
остаются у page. Form, mutation или click handler сами по себе не
|
||||
требуют feature. Два callers не делают domain helper generic shared.
|
||||
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
||||
|
||||
@@ -49,12 +48,12 @@ Slice именуется по ответственности, а не `helpers`,
|
||||
|
||||
Cross-slice consumer использует `@/` и `index.ts` владельца, например
|
||||
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
|
||||
импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
|
||||
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя;
|
||||
композиция принадлежит вышележащему owner. Existing меж-slice исключения
|
||||
не являются разрешением добавлять новые.
|
||||
|
||||
Направление зависимостей, public imports и запрет соседних slices в
|
||||
`pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
|
||||
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
|
||||
одного slice и связи shared segments этот запрет не ограничивает.
|
||||
Knip проверяет использование exports, а не смысл placement.
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
!public/**
|
||||
!docker/
|
||||
!docker/**
|
||||
!scripts/
|
||||
!scripts/minimatch-compat/
|
||||
!scripts/minimatch-compat/**
|
||||
!packages/
|
||||
!packages/tokens/
|
||||
!packages/tokens/package.json
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# 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)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -5,6 +5,7 @@ COPY package.json package-lock.json ./
|
||||
COPY packages/tokens/package.json ./packages/tokens/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/icons/package.json ./packages/icons/
|
||||
COPY scripts/minimatch-compat ./scripts/minimatch-compat
|
||||
RUN npm ci
|
||||
|
||||
FROM dependencies AS development
|
||||
|
||||
+5
-11
@@ -29,17 +29,17 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
from: {
|
||||
path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
|
||||
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
|
||||
},
|
||||
name: "slices-must-use-public-api",
|
||||
severity: "error",
|
||||
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/",
|
||||
},
|
||||
},
|
||||
{
|
||||
from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
|
||||
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
|
||||
name: "no-same-layer-cross-slice-imports",
|
||||
severity: "error",
|
||||
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
|
||||
@@ -48,24 +48,18 @@ module.exports = {
|
||||
from: { path: "^src/shared/" },
|
||||
name: "shared-must-not-depend-on-layers",
|
||||
severity: "error",
|
||||
to: { path: "^src/(entities|features|widgets|pages|app)/" },
|
||||
to: { path: "^src/(entities|features|pages|app)/" },
|
||||
},
|
||||
{
|
||||
from: { path: "^src/entities/" },
|
||||
name: "entities-must-not-depend-on-higher-layers",
|
||||
severity: "error",
|
||||
to: { path: "^src/(features|widgets|pages|app)/" },
|
||||
to: { path: "^src/(features|pages|app)/" },
|
||||
},
|
||||
{
|
||||
from: { path: "^src/features/" },
|
||||
name: "features-must-not-depend-on-higher-layers",
|
||||
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)/" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,12 +17,21 @@ http {
|
||||
default $http_x_forwarded_proto;
|
||||
'' $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 {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||
location /api/ {
|
||||
limit_req zone=auth_admission burst=5 nodelay;
|
||||
set $backend http://backend:3000;
|
||||
proxy_pass $backend$request_uri;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
+6
-1
@@ -25,7 +25,11 @@ const readability = {
|
||||
)
|
||||
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,
|
||||
ImportDeclaration(node) {
|
||||
if (
|
||||
@@ -95,6 +99,7 @@ const readability = {
|
||||
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
||||
svg: "Own SVG markup in packages/icons, and import its public component.",
|
||||
union: "Put complex union members on separate lines.",
|
||||
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
|
||||
},
|
||||
schema: [],
|
||||
type: "suggestion",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ignoreDependencies": ["conventional-changelog-conventionalcommits"],
|
||||
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
|
||||
"workspaces": {
|
||||
".": {
|
||||
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
|
||||
|
||||
Generated
+42
-138
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wealth-ledger-frontend",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wealth-ledger-frontend",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -80,6 +80,7 @@
|
||||
"jsdom": "30.1.3",
|
||||
"knip": "6.43.0",
|
||||
"lint-staged": "17.6.0",
|
||||
"minimatch": "file:scripts/minimatch-compat",
|
||||
"msw": "2.15.0",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"playwright": "1.64.1",
|
||||
@@ -88,7 +89,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-pwa": "2.0.0",
|
||||
"vitest": "5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2511,14 +2512,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
||||
"node_modules/@isaacs/nonrecursive-glob-match": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
|
||||
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript": {
|
||||
"version": "0.7.0",
|
||||
@@ -3720,33 +3719,6 @@
|
||||
"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": {
|
||||
"version": "2.12.0",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
@@ -6448,6 +6420,7 @@
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -6495,6 +6468,7 @@
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -9097,33 +9071,6 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
@@ -9194,22 +9141,6 @@
|
||||
"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": {
|
||||
"version": "0.11.4",
|
||||
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.11.4.tgz",
|
||||
@@ -9488,14 +9419,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz",
|
||||
"integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minimatch": "^11.0.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
"path-scurry": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -10670,21 +10602,6 @@
|
||||
"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": {
|
||||
"version": "10.9.4",
|
||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||
@@ -11473,12 +11390,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"resolved": "scripts/minimatch-compat",
|
||||
"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,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
"@isaacs/nonrecursive-glob-match": "^1.0.2",
|
||||
"brace-expansion": "^5.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -12212,12 +12136,6 @@
|
||||
"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": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz",
|
||||
@@ -12314,8 +12232,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz",
|
||||
"integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -15929,8 +15848,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-pwa": {
|
||||
"version": "1.3.0",
|
||||
"integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-2.0.0.tgz",
|
||||
"integrity": "sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15941,13 +15861,13 @@
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"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",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1"
|
||||
@@ -17642,30 +17562,6 @@
|
||||
"@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": {
|
||||
"version": "5.1.1",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
@@ -18076,6 +17972,14 @@
|
||||
"react": "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",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "24.22.1"
|
||||
@@ -106,6 +106,7 @@
|
||||
"jsdom": "30.1.3",
|
||||
"knip": "6.43.0",
|
||||
"lint-staged": "17.6.0",
|
||||
"minimatch": "file:scripts/minimatch-compat",
|
||||
"msw": "2.15.0",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"playwright": "1.64.1",
|
||||
@@ -114,7 +115,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-pwa": "2.0.0",
|
||||
"vitest": "5.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -124,7 +125,9 @@
|
||||
},
|
||||
"@vitest/browser": "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": {
|
||||
"esbuild@0.28.2": true,
|
||||
|
||||
@@ -16,7 +16,7 @@ type EntryCardProps = {
|
||||
tone: "negative"
|
||||
| "neutral"
|
||||
| "positive";
|
||||
value: string;
|
||||
value: ReactNode;
|
||||
valueLabel: string;
|
||||
wrap?: boolean;
|
||||
};
|
||||
@@ -32,6 +32,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
|
||||
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
|
||||
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
|
||||
const valueComponent = typeof props.value === "string" ? undefined : "div";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -63,7 +64,11 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
</Stack>
|
||||
|
||||
<Stack className={classes.value} gap={2}>
|
||||
<Text className={classes[props.tone]} fw={700}>
|
||||
<Text
|
||||
className={classes[props.tone]}
|
||||
component={valueComponent}
|
||||
fw={700}
|
||||
>
|
||||
{props.value}
|
||||
</Text>
|
||||
|
||||
|
||||
+16
-1
@@ -1,15 +1,30 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
|
||||
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"],
|
||||
"timezone": "Europe/Moscow",
|
||||
"labels": ["dependencies"],
|
||||
"dependencyDashboard": true,
|
||||
"rangeStrategy": "pin",
|
||||
"prConcurrentLimit": 5,
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 4am on the first day of the month"]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"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"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,10 @@ const position: Portfolio["positions"][number]
|
||||
displayName: "Сбербанк",
|
||||
figi: "SBER",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "2",
|
||||
sector: null,
|
||||
ticker: "SBER",
|
||||
type: "STOCK",
|
||||
};
|
||||
@@ -215,6 +217,7 @@ beforeEach(() => {
|
||||
}),
|
||||
),
|
||||
incomeTotalsByCurrency: [],
|
||||
window: { from: "2026-09-01", to: "2026-10-01" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import type { ComponentType, FC } from "react";
|
||||
|
||||
import { lazy } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
@@ -6,101 +6,35 @@ import { Navigate, Route, Routes } from "react-router";
|
||||
import { LazyPage } from "../RouteFallback";
|
||||
import { Protected, Public } from "../RouteGuards";
|
||||
|
||||
const CardPage = lazy(async () => {
|
||||
const { CardPage } = await import("@/pages/card");
|
||||
// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each).
|
||||
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 { CardsPage } = await import("@/pages/cards");
|
||||
|
||||
return { default: CardsPage };
|
||||
});
|
||||
|
||||
const BondPage = lazy(async () => {
|
||||
const { BondPage } = await import("@/pages/bond");
|
||||
|
||||
return { default: BondPage };
|
||||
});
|
||||
|
||||
const BondsPage = lazy(async () => {
|
||||
const { BondsPage } = await import("@/pages/bonds");
|
||||
|
||||
return { default: BondsPage };
|
||||
});
|
||||
|
||||
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 CardPage = lazyPage(async () => import("@/pages/card"), "CardPage");
|
||||
const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage");
|
||||
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
|
||||
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 AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage");
|
||||
const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage");
|
||||
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
|
||||
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 NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage");
|
||||
const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage");
|
||||
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
|
||||
const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout");
|
||||
|
||||
const AppRoutes: FC = () => (
|
||||
<Routes>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useDocumentTitle } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCurrentRoute } from "@/app/model";
|
||||
@@ -8,12 +7,12 @@ import { useBankCard } from "@/entities/bank-card";
|
||||
import { useBond } from "@/entities/bond";
|
||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||
import { useStock } from "@/entities/stock";
|
||||
import { useAppDocumentTitle } from "@/shared/ui";
|
||||
|
||||
type DocumentTitleProps = { title: string };
|
||||
|
||||
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
|
||||
const { t } = useTranslation();
|
||||
useDocumentTitle(`${props.title} — ${t("appName")}`);
|
||||
useAppDocumentTitle(props.title);
|
||||
};
|
||||
|
||||
type AccountDocumentTitleProps = {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { useDocumentTitle } from "@mantine/hooks";
|
||||
import { Component } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { useAppDocumentTitle } from "@/shared/ui";
|
||||
|
||||
type BoundaryProps = PropsWithChildren<{
|
||||
authenticated: boolean;
|
||||
@@ -37,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
|
||||
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
||||
const { authenticated } = props;
|
||||
const { t } = useTranslation();
|
||||
useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
|
||||
useAppDocumentTitle(t("routeError.title"));
|
||||
const to = authenticated ? "/accounts" : "/login";
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,10 +7,8 @@ import { useCurrentRoute } from "@/app/model";
|
||||
|
||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
||||
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
||||
import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
||||
import { EntityBreadcrumbs } from "./EntityBreadcrumbs";
|
||||
|
||||
const AppBreadcrumbs: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,9 +24,16 @@ const AppBreadcrumbs: FC = () => {
|
||||
stock,
|
||||
} = useCurrentRoute();
|
||||
|
||||
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
||||
if (stock)
|
||||
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
||||
if (bond) {
|
||||
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />;
|
||||
}
|
||||
|
||||
if (stock) {
|
||||
return (
|
||||
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
|
||||
);
|
||||
}
|
||||
|
||||
if (account && (section || !account.params.section))
|
||||
return (
|
||||
<Stack gap={0} mb="md">
|
||||
@@ -43,8 +48,13 @@ const AppBreadcrumbs: FC = () => {
|
||||
/>
|
||||
</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 (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,
|
||||
isApiErrorCode,
|
||||
isGetUnder,
|
||||
} from "@/shared/api";
|
||||
|
||||
import type {
|
||||
@@ -26,21 +27,14 @@ async function mutationData<T>(
|
||||
return data;
|
||||
}
|
||||
|
||||
const bankCardGetPredicate = isGetUnder(["/api/v1/bank-cards"]);
|
||||
|
||||
function useBankMutationOptions() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
const [method, path] = queryKey;
|
||||
|
||||
return (
|
||||
method === "get"
|
||||
&& typeof path === "string"
|
||||
&& (path === "/api/v1/bank-cards"
|
||||
|| path.startsWith("/api/v1/bank-cards/"))
|
||||
);
|
||||
},
|
||||
predicate: ({ queryKey }) => bankCardGetPredicate(queryKey),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,5 +5,7 @@ export {
|
||||
type OperationCategory,
|
||||
useBrokerageOperations,
|
||||
} from "./api/useBrokerageOperations";
|
||||
export { brokerageOperationGroupKey } from "./model/display";
|
||||
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
|
||||
export { LedgerEntryRow } from "./ui/LedgerEntryRow";
|
||||
export { OperationGroup } from "./ui/OperationGroup";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { operationLabelKey } from "./display";
|
||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||
|
||||
import { brokerageOperationGroupKey, operationLabelKey } from "./display";
|
||||
|
||||
it.each([
|
||||
["OPERATION_TYPE_BUY", "buy"],
|
||||
["OPERATION_TYPE_COUPON", "other"],
|
||||
["OPERATION_TYPE_BUY_CARD", "buy"],
|
||||
["OPERATION_TYPE_BUY_MARGIN", "buy"],
|
||||
["OPERATION_TYPE_SELL", "sell"],
|
||||
@@ -21,3 +24,71 @@ it.each([
|
||||
])("preserves the wire operation label for %s", (type, label) => {
|
||||
expect(operationLabelKey(type)).toBe(`ledger.operationType.${label}`);
|
||||
});
|
||||
|
||||
describe("brokerageOperationGroupKey", () => {
|
||||
const base: BrokerageOperation = {
|
||||
account: { id: "account-1", name: "Брокерский" },
|
||||
accruedInterest: null,
|
||||
category: "income",
|
||||
commission: null,
|
||||
description: "Купон",
|
||||
figi: "FIGI",
|
||||
id: "operation",
|
||||
instrumentType: "bond",
|
||||
instrumentUid: "UID",
|
||||
logoName: null,
|
||||
occurredAt: "2026-08-14T10:00:00Z",
|
||||
payment: null,
|
||||
price: null,
|
||||
quantity: null,
|
||||
ticker: "BOND",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
};
|
||||
|
||||
it("uses screen day, preferred identity and exact type, not account", () => {
|
||||
const first = brokerageOperationGroupKey(base);
|
||||
|
||||
const same = brokerageOperationGroupKey({
|
||||
...base,
|
||||
account: { id: "account-2", name: "ИИС" },
|
||||
figi: "OTHER-FIGI",
|
||||
occurredAt: "2026-08-14T11:00:00Z",
|
||||
});
|
||||
|
||||
expect(same).toBe(first);
|
||||
expect(brokerageOperationGroupKey({
|
||||
...base,
|
||||
type: "OPERATION_TYPE_DIVIDEND",
|
||||
})).not.toBe(first);
|
||||
});
|
||||
|
||||
it("keeps buy and sell of one instrument in different groups", () => {
|
||||
expect(brokerageOperationGroupKey({ ...base, type: "OPERATION_TYPE_BUY" }))
|
||||
.not.toBe(brokerageOperationGroupKey({
|
||||
...base,
|
||||
type: "OPERATION_TYPE_SELL",
|
||||
}));
|
||||
});
|
||||
|
||||
it("separates local midnight and joins one local day across UTC dates", () => {
|
||||
const before = new Date(2026, 7, 14, 23, 59).toISOString();
|
||||
const after = new Date(2026, 7, 15, 0, 1).toISOString();
|
||||
const morning = new Date(2026, 7, 14, 1).toISOString();
|
||||
const evening = new Date(2026, 7, 14, 23).toISOString();
|
||||
|
||||
expect(brokerageOperationGroupKey({ ...base, occurredAt: before }))
|
||||
.not.toBe(brokerageOperationGroupKey({ ...base, occurredAt: after }));
|
||||
expect(brokerageOperationGroupKey({ ...base, occurredAt: morning }))
|
||||
.toBe(brokerageOperationGroupKey({ ...base, occurredAt: evening }));
|
||||
});
|
||||
|
||||
it("falls back to FIGI and leaves missing identity ungrouped", () => {
|
||||
expect(brokerageOperationGroupKey({ ...base, instrumentUid: null }))
|
||||
.toBe(brokerageOperationGroupKey({ ...base, instrumentUid: "" }));
|
||||
expect(brokerageOperationGroupKey({
|
||||
...base,
|
||||
figi: null,
|
||||
instrumentUid: null,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { formatMoney, formatSnapshotDay } from "@/shared/lib";
|
||||
|
||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||
|
||||
@@ -27,6 +27,19 @@ type PaymentPresentation = {
|
||||
| "positive";
|
||||
};
|
||||
|
||||
function brokerageOperationGroupKey(
|
||||
operation: BrokerageOperation,
|
||||
): null | string {
|
||||
const identity = operation.instrumentUid || operation.figi;
|
||||
if (!identity) return null;
|
||||
|
||||
return JSON.stringify([
|
||||
formatSnapshotDay(operation.occurredAt),
|
||||
identity,
|
||||
operation.type,
|
||||
]);
|
||||
}
|
||||
|
||||
function entryLabelKey(operation: BrokerageOperation): string {
|
||||
if (operation.category === "income")
|
||||
return isOperationType(operation.type, OperationType.Coupon)
|
||||
@@ -115,4 +128,11 @@ function paymentPresentation(
|
||||
};
|
||||
}
|
||||
|
||||
export { isIncomePayment, operationLabelKey, paymentPresentation };
|
||||
export {
|
||||
brokerageOperationGroupKey,
|
||||
entryLabelKey,
|
||||
isIncomePayment,
|
||||
isZeroDecimal,
|
||||
operationLabelKey,
|
||||
paymentPresentation,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||
|
||||
import { OperationGroup } from "./OperationGroup";
|
||||
|
||||
function operation(
|
||||
id: string,
|
||||
account: { id: string; name: string },
|
||||
value: null | string,
|
||||
currency = "RUB",
|
||||
): BrokerageOperation {
|
||||
return {
|
||||
account,
|
||||
accruedInterest: null,
|
||||
category: "income",
|
||||
commission: null,
|
||||
description: "Купон",
|
||||
figi: "FIGI",
|
||||
id,
|
||||
instrumentType: "bond",
|
||||
instrumentUid: "UID",
|
||||
logoName: null,
|
||||
occurredAt: "2026-08-14T10:00:00Z",
|
||||
payment: value === null ? null : { currency, value },
|
||||
price: null,
|
||||
quantity: null,
|
||||
ticker: "BOND",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
};
|
||||
}
|
||||
|
||||
function renderGroup(
|
||||
operations: BrokerageOperation[],
|
||||
isTotalVisible = true,
|
||||
) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<ul>
|
||||
<OperationGroup
|
||||
entries={operations.map((operation, index) => ({
|
||||
key: `${operation.id}-${index}`,
|
||||
operation,
|
||||
}))}
|
||||
totalVisible={isTotalVisible}
|
||||
/>
|
||||
</ul>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OperationGroup", () => {
|
||||
it("shows the header, exact total and separate card rows per account", () => {
|
||||
renderGroup([
|
||||
operation("1", { id: "broker", name: "Брокерский" }, "0.1"),
|
||||
operation("2", { id: "iis", name: "ИИС" }, "0.2"),
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
|
||||
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
|
||||
|
||||
const accounts = within(screen.getByRole("list", {
|
||||
name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
|
||||
}));
|
||||
|
||||
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
|
||||
.toHaveTextContent("Купон");
|
||||
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
|
||||
.toHaveTextContent("Купон");
|
||||
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
|
||||
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces the hidden total as unavailable", () => {
|
||||
renderGroup([
|
||||
operation("1", { id: "broker", name: "Брокерский" }, "100"),
|
||||
operation("2", { id: "iis", name: "ИИС" }, null),
|
||||
]);
|
||||
|
||||
expect(screen.getByRole("list", {
|
||||
name: /Купон · BOND, Исполнено .*Итог недоступен/,
|
||||
})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["0", "0.000"])(
|
||||
"hides the whole total for a zero source amount (%s)",
|
||||
(value) => {
|
||||
renderGroup([
|
||||
operation("1", { id: "broker", name: "Брокерский" }, value),
|
||||
operation("2", { id: "iis", name: "ИИС" }, "100"),
|
||||
]);
|
||||
|
||||
expect(screen.getAllByText("+100 ₽")).toHaveLength(1);
|
||||
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("hides the total for incomplete data but keeps compensated zero", () => {
|
||||
const { unmount } = renderGroup([
|
||||
operation("1", { id: "broker", name: "Брокерский" }, "100"),
|
||||
operation("2", { id: "iis", name: "ИИС" }, "100"),
|
||||
], false);
|
||||
|
||||
expect(screen.queryByText("+200 ₽")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderGroup([
|
||||
operation("1", { id: "broker", name: "Брокерский" }, "100"),
|
||||
operation("2", { id: "iis", name: "ИИС" }, "-100"),
|
||||
]);
|
||||
expect(screen.getByText("0 ₽")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders repeated ids inside one group as separate rows", () => {
|
||||
renderGroup([
|
||||
operation("same", { id: "broker", name: "Брокерский" }, "10"),
|
||||
operation("same", { id: "iis", name: "ИИС" }, "20"),
|
||||
]);
|
||||
|
||||
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
|
||||
const accounts = within(screen.getByRole("list", { name: /Купон/ }));
|
||||
expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
|
||||
expect(accounts.getAllByText("ИИС")).toHaveLength(1);
|
||||
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { EntryCard } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
formatSnapshotDay,
|
||||
instrumentLogoUrl,
|
||||
sumMoney,
|
||||
totalText,
|
||||
} from "@/shared/lib";
|
||||
import { AccountCardRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
|
||||
|
||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||
|
||||
import {
|
||||
entryLabelKey,
|
||||
isZeroDecimal,
|
||||
operationLabelKey,
|
||||
paymentPresentation,
|
||||
} from "../model/display";
|
||||
|
||||
type OperationGroupProps = {
|
||||
entries: { key: string; operation: BrokerageOperation }[];
|
||||
totalVisible: boolean;
|
||||
};
|
||||
|
||||
const OperationGroup: FC<OperationGroupProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const operations = props.entries.map(entry => entry.operation);
|
||||
const first = operations.at(0);
|
||||
|
||||
// groupItems only emits groups of at least two entries.
|
||||
if (!first) return null;
|
||||
|
||||
const typeLabel = t(first.category === "income"
|
||||
? entryLabelKey(first)
|
||||
: operationLabelKey(first.type));
|
||||
|
||||
const instrument
|
||||
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
|
||||
|
||||
const amounts = operations.flatMap(operation =>
|
||||
operation.payment ? [operation.payment] : [],
|
||||
);
|
||||
|
||||
const isAmountsKnown = amounts.length === operations.length
|
||||
&& amounts.every(amount => !isZeroDecimal(amount.value));
|
||||
|
||||
const totals = isAmountsKnown && props.totalVisible
|
||||
? sumMoney(amounts)
|
||||
: null;
|
||||
|
||||
const date = formatSnapshotDay(first.occurredAt);
|
||||
const title = `${typeLabel} · ${instrument}`;
|
||||
const totalsText = totalText(totals, true);
|
||||
|
||||
const accessibleName = [
|
||||
title,
|
||||
t("payout.executedAt", { date }),
|
||||
totalsText || t("entryGroup.totalUnavailable"),
|
||||
].join(", ");
|
||||
|
||||
return (
|
||||
<EntryGroup
|
||||
header={(
|
||||
<EntryCard
|
||||
imageSrc={instrumentLogoUrl(first.logoName)}
|
||||
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
|
||||
metadata={t("payout.executedAt", { date })}
|
||||
title={title}
|
||||
tone="neutral"
|
||||
value={<EntryGroupTotal isSigned totals={totals} />}
|
||||
valueLabel={typeLabel}
|
||||
wrap
|
||||
/>
|
||||
)}
|
||||
label={accessibleName}
|
||||
>
|
||||
{props.entries.map(({ key, operation }) => {
|
||||
const payment = paymentPresentation(operation);
|
||||
|
||||
return (
|
||||
<AccountCardRow
|
||||
accountName={operation.account?.name ?? "—"}
|
||||
key={key}
|
||||
value={payment.amount}
|
||||
valueLabel={t(payment.label)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</EntryGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { OperationGroup };
|
||||
@@ -2,8 +2,9 @@ export {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "./api/useBrokerageEvents";
|
||||
export { brokerageEventKey } from "./model/event-key";
|
||||
export { brokerageEventGroupKey, brokerageEventKey } from "./model/event-key";
|
||||
export { BrokerageEventCard } from "./ui/BrokerageEventCard";
|
||||
export { BrokerageEventGroup } from "./ui/BrokerageEventGroup";
|
||||
export {
|
||||
BrokerageEventsPreview,
|
||||
type BrokerageEventsPreviewState,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||
|
||||
import { brokerageEventGroupKey } from "./event-key";
|
||||
|
||||
const base: BrokerageEvent = {
|
||||
account: { id: "account-1", name: "Брокерский" },
|
||||
date: "2026-08-20",
|
||||
dateKind: "event",
|
||||
displayName: "ОФЗ 26238",
|
||||
figi: "FIGI",
|
||||
instrumentUid: "UID",
|
||||
isin: "RU000A0JX0J2",
|
||||
logoName: null,
|
||||
projectedAmount: { currency: "RUB", value: "100" },
|
||||
ticker: "OFZ",
|
||||
type: "coupon",
|
||||
};
|
||||
|
||||
describe("brokerageEventGroupKey", () => {
|
||||
it("uses date, preferred identity, exact type and dateKind", () => {
|
||||
const key = brokerageEventGroupKey(base);
|
||||
|
||||
expect(brokerageEventGroupKey({
|
||||
...base,
|
||||
account: { id: "account-2", name: "ИИС" },
|
||||
figi: "OTHER-FIGI",
|
||||
})).toBe(key);
|
||||
expect(brokerageEventGroupKey({ ...base, date: "2026-08-21" }))
|
||||
.not.toBe(key);
|
||||
expect(brokerageEventGroupKey({ ...base, dateKind: "registry" }))
|
||||
.not.toBe(key);
|
||||
expect(brokerageEventGroupKey({ ...base, type: "dividend" }))
|
||||
.not.toBe(key);
|
||||
});
|
||||
|
||||
it("falls back through FIGI and ISIN and rejects missing identity", () => {
|
||||
expect(brokerageEventGroupKey({ ...base, instrumentUid: null }))
|
||||
.toBe(brokerageEventGroupKey({ ...base, instrumentUid: "" }));
|
||||
expect(brokerageEventGroupKey({
|
||||
...base,
|
||||
figi: null,
|
||||
instrumentUid: null,
|
||||
})).toBe(brokerageEventGroupKey({
|
||||
...base,
|
||||
figi: "",
|
||||
instrumentUid: null,
|
||||
}));
|
||||
expect(brokerageEventGroupKey({
|
||||
...base,
|
||||
figi: null,
|
||||
instrumentUid: null,
|
||||
isin: null,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||
|
||||
function brokerageEventGroupKey(event: BrokerageEvent): null | string {
|
||||
const identity = event.instrumentUid || event.figi || event.isin;
|
||||
|
||||
return identity
|
||||
? JSON.stringify([event.date, identity, event.type, event.dateKind])
|
||||
: null;
|
||||
}
|
||||
|
||||
function brokerageEventKey(event: BrokerageEvent) {
|
||||
return JSON.stringify([
|
||||
event.account?.id, event.type, event.date, event.instrumentUid,
|
||||
@@ -7,4 +15,4 @@ function brokerageEventKey(event: BrokerageEvent) {
|
||||
]);
|
||||
}
|
||||
|
||||
export { brokerageEventKey };
|
||||
export { brokerageEventGroupKey, brokerageEventKey };
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||
|
||||
import { BrokerageEventGroup } from "./BrokerageEventGroup";
|
||||
|
||||
function event(
|
||||
account: { id: string; name: string },
|
||||
value: null | string,
|
||||
currency = "RUB",
|
||||
): BrokerageEvent {
|
||||
return {
|
||||
account,
|
||||
date: "2026-08-20",
|
||||
dateKind: "registry",
|
||||
displayName: "ОФЗ 26238",
|
||||
figi: "FIGI",
|
||||
instrumentUid: "UID",
|
||||
isin: "RU000A0JX0J2",
|
||||
logoName: null,
|
||||
projectedAmount: value === null ? null : { currency, value },
|
||||
ticker: "OFZ",
|
||||
type: "coupon",
|
||||
};
|
||||
}
|
||||
|
||||
function renderGroup(
|
||||
events: [BrokerageEvent, BrokerageEvent],
|
||||
isTotalVisible = true,
|
||||
) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<ul>
|
||||
<BrokerageEventGroup
|
||||
events={events}
|
||||
totalVisible={isTotalVisible}
|
||||
/>
|
||||
</ul>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("BrokerageEventGroup", () => {
|
||||
it("shows an unsigned forecast total and every account forecast", () => {
|
||||
renderGroup([
|
||||
event({ id: "broker", name: "Брокерский" }, "100.5"),
|
||||
event({ id: "iis", name: "ИИС" }, "49.5"),
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument();
|
||||
expect(screen.getByText("150 ₽")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Прогноз").length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const accounts = within(screen.getByRole("list", {
|
||||
name: /Купон · ОФЗ 26238, 20\.08\.2026 · Дата реестра, 150 ₽/,
|
||||
}));
|
||||
|
||||
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
|
||||
expect(accounts.getByText("ИИС")).toBeInTheDocument();
|
||||
expect(accounts.getByText("100,5 ₽")).toBeInTheDocument();
|
||||
expect(accounts.getByText("49,5 ₽")).toBeInTheDocument();
|
||||
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces an unknown total as unavailable", () => {
|
||||
renderGroup([
|
||||
event({ id: "broker", name: "Брокерский" }, null),
|
||||
event({ id: "iis", name: "ИИС" }, "49.5"),
|
||||
]);
|
||||
|
||||
expect(screen.getByRole("list", {
|
||||
name: /Купон · ОФЗ 26238.*Итог недоступен/,
|
||||
})).toBeInTheDocument();
|
||||
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the total for incomplete data", () => {
|
||||
renderGroup([
|
||||
event({ id: "broker", name: "Брокерский" }, "100.5"),
|
||||
event({ id: "iis", name: "ИИС" }, "49.5"),
|
||||
], false);
|
||||
expect(screen.queryByText("150 ₽")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows zero source forecasts to produce a visible zero total", () => {
|
||||
renderGroup([
|
||||
event({ id: "broker", name: "Брокерский" }, "0"),
|
||||
event({ id: "iis", name: "ИИС" }, "0.00"),
|
||||
]);
|
||||
|
||||
expect(screen.getAllByText("0 ₽")).toHaveLength(2);
|
||||
expect(screen.getByText("0,00 ₽")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows separate forecast totals per currency without conversion", () => {
|
||||
renderGroup([
|
||||
event({ id: "broker", name: "Брокерский" }, "10", "USD"),
|
||||
event({ id: "iis", name: "ИИС" }, "200"),
|
||||
]);
|
||||
|
||||
expect(screen.getByRole("list", { name: /10 USD.*200 ₽/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an offer group without a total", () => {
|
||||
renderGroup([
|
||||
{ ...event({ id: "broker", name: "Брокерский" }, null), type: "offer" },
|
||||
{ ...event({ id: "iis", name: "ИИС" }, null), type: "offer" },
|
||||
]);
|
||||
|
||||
const accounts = within(screen.getByRole("list", {
|
||||
name: /Итог недоступен/,
|
||||
}));
|
||||
|
||||
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { EntryCard } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
formatDate,
|
||||
formatMoney,
|
||||
instrumentLogoUrl,
|
||||
sumMoney,
|
||||
totalText,
|
||||
} from "@/shared/lib";
|
||||
import { AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
|
||||
|
||||
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||
|
||||
import { brokerageEventKey } from "../model/event-key";
|
||||
|
||||
type BrokerageEventGroupProps = {
|
||||
events: [BrokerageEvent, BrokerageEvent, ...BrokerageEvent[]];
|
||||
totalVisible: boolean;
|
||||
};
|
||||
|
||||
const BrokerageEventGroup: FC<BrokerageEventGroupProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [first] = props.events;
|
||||
const typeLabel = t(`events.type.${first.type}`);
|
||||
|
||||
const amounts = props.events.flatMap(event =>
|
||||
event.projectedAmount ? [event.projectedAmount] : [],
|
||||
);
|
||||
|
||||
const totals = props.totalVisible && amounts.length === props.events.length
|
||||
? sumMoney(amounts)
|
||||
: null;
|
||||
|
||||
const date = formatDate(first.date);
|
||||
|
||||
const metadata = [
|
||||
date,
|
||||
first.dateKind === "registry" ? t("events.dateKind.registry") : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
const title = `${typeLabel} · ${first.displayName}`;
|
||||
const totalsText = totalText(totals, false);
|
||||
|
||||
const accessibleName = [
|
||||
title,
|
||||
metadata,
|
||||
totalsText || t("entryGroup.totalUnavailable"),
|
||||
].join(", ");
|
||||
|
||||
return (
|
||||
<EntryGroup
|
||||
header={(
|
||||
<EntryCard
|
||||
imageSrc={instrumentLogoUrl(first.logoName)}
|
||||
initial={(first.ticker.trim()[0] ?? "?").toUpperCase()}
|
||||
metadata={metadata}
|
||||
title={title}
|
||||
tone="neutral"
|
||||
value={<EntryGroupTotal totals={totals} />}
|
||||
valueLabel={t("payout.forecast")}
|
||||
wrap
|
||||
/>
|
||||
)}
|
||||
label={accessibleName}
|
||||
>
|
||||
{props.events.map((event) => {
|
||||
const value = event.projectedAmount
|
||||
? formatMoney(event.projectedAmount)
|
||||
: t("events.noAmount");
|
||||
|
||||
return (
|
||||
<AccountRow
|
||||
accountName={event.account?.name ?? "—"}
|
||||
key={brokerageEventKey(event)}
|
||||
value={value}
|
||||
valueLabel={t("payout.forecast")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</EntryGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { BrokerageEventGroup };
|
||||
@@ -5,12 +5,16 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { groupItems } from "@/shared/lib";
|
||||
|
||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||
|
||||
import { brokerageEventKey } from "../../model/event-key";
|
||||
import { brokerageEventGroupKey, brokerageEventKey } from "../../model/event-key";
|
||||
import { BrokerageEventCard } from "../BrokerageEventCard";
|
||||
import { BrokerageEventGroup } from "../BrokerageEventGroup";
|
||||
|
||||
type BrokerageEventsPreviewProps = {
|
||||
allAccounts?: boolean;
|
||||
events: BrokerageEventsPreviewState;
|
||||
moreLabel?: string;
|
||||
};
|
||||
@@ -20,6 +24,7 @@ type BrokerageEventsPreviewState
|
||||
items: BrokerageEvent[];
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
totalVisible?: boolean;
|
||||
}
|
||||
| { state: "empty"
|
||||
| "error"
|
||||
@@ -43,13 +48,32 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
|
||||
{events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
|
||||
|
||||
{(events.state === "loaded" || events.state === "partial")
|
||||
&& events.items.map(event => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={brokerageEventKey(event)}
|
||||
/>
|
||||
))}
|
||||
{(events.state === "loaded" || events.state === "partial") && (
|
||||
<Stack
|
||||
component="ul"
|
||||
gap="sm"
|
||||
m={0}
|
||||
p={0}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
{groupItems(
|
||||
events.items,
|
||||
props.allAccounts ? brokerageEventGroupKey : () => null,
|
||||
).map(entry => entry.type === "group"
|
||||
? (
|
||||
<BrokerageEventGroup
|
||||
events={entry.items}
|
||||
key={entry.key}
|
||||
totalVisible={events.totalVisible === true}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<li key={brokerageEventKey(entry.item)}>
|
||||
<BrokerageEventCard event={entry.item} />
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Button component={Link} to={events.to} variant="subtle">
|
||||
{props.moreLabel ?? t("events.open")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resource } from "@/test/resource";
|
||||
|
||||
@@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => {
|
||||
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 = {
|
||||
error: boolean;
|
||||
hasData: boolean;
|
||||
loading: boolean;
|
||||
partial?: boolean;
|
||||
readRetry?: ReturnType<typeof readRetry>;
|
||||
};
|
||||
|
||||
function hasSectionData(data: unknown) {
|
||||
@@ -13,8 +14,11 @@ function hasSectionData(data: unknown) {
|
||||
|
||||
function section(query: {
|
||||
data: unknown;
|
||||
error?: unknown;
|
||||
isError: boolean;
|
||||
isFetching?: boolean;
|
||||
meta?: InvestmentResourceMeta;
|
||||
refetch?: () => Promise<unknown>;
|
||||
}): SectionState {
|
||||
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
|
||||
const hasData = hasSectionData(query.data);
|
||||
@@ -24,6 +28,17 @@ function section(query: {
|
||||
hasData,
|
||||
loading: !hasData && !isError,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ReadRetry } from "@/shared/ui";
|
||||
|
||||
import type { SectionState } from "../model/section";
|
||||
|
||||
import classes from "./InstrumentDetails.module.css";
|
||||
@@ -81,6 +83,8 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!!props.state.readRetry && <ReadRetry {...props.state.readRetry} />}
|
||||
|
||||
{sectionContent}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -2,19 +2,18 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
apiQueryClient,
|
||||
isGetUnder,
|
||||
type paths,
|
||||
} from "@/shared/api";
|
||||
|
||||
type TbankConnectionStatus
|
||||
= paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
function isInvestmentPath(path: string) {
|
||||
return [
|
||||
"/api/v1/brokerage-accounts",
|
||||
"/api/v1/stocks",
|
||||
"/api/v1/bonds",
|
||||
].some(root => path === root || path.startsWith(`${root}/`));
|
||||
}
|
||||
const investmentGetPredicate = isGetUnder([
|
||||
"/api/v1/brokerage-accounts",
|
||||
"/api/v1/stocks",
|
||||
"/api/v1/bonds",
|
||||
]);
|
||||
|
||||
const tbankConnectionQueryOptions = () =>
|
||||
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
|
||||
@@ -27,15 +26,7 @@ function commitTbankConnectionBoundary(
|
||||
) {
|
||||
queryClient.setQueryData(tbankConnectionQueryKey, status);
|
||||
void queryClient.resetQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
const [method, path] = queryKey;
|
||||
|
||||
return (
|
||||
method === "get"
|
||||
&& typeof path === "string"
|
||||
&& isInvestmentPath(path)
|
||||
);
|
||||
},
|
||||
predicate: ({ queryKey }) => investmentGetPredicate(queryKey),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@ import {
|
||||
TbankConnectionError,
|
||||
TbankConnectionLoading,
|
||||
} from "./TbankConnectionSection.view";
|
||||
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
|
||||
import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm";
|
||||
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
|
||||
|
||||
const TbankConnectionSection: FC = () => {
|
||||
const viewModel = useTbankConnectionViewModel();
|
||||
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
||||
const vm = useTbankConnectionSectionVm();
|
||||
if (vm.state === "loading") return <TbankConnectionLoading />;
|
||||
|
||||
return viewModel.state === "error"
|
||||
? <TbankConnectionError retry={viewModel.retry} />
|
||||
: <TbankConnectionSectionLoaded {...viewModel} />;
|
||||
return vm.state === "error"
|
||||
? <TbankConnectionError retry={vm.retry} />
|
||||
: <TbankConnectionSectionLoaded {...vm} />;
|
||||
};
|
||||
|
||||
export { TbankConnectionSection };
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api";
|
||||
|
||||
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
|
||||
|
||||
function useTbankConnectionViewModel() {
|
||||
function useTbankConnectionSectionVm() {
|
||||
const status = useAuthenticatedQuery({
|
||||
...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 { AccountEventsPage } from "./AccountEventsPage";
|
||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
||||
import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
|
||||
|
||||
const Navigation: FC = () => {
|
||||
const location = useLocation();
|
||||
@@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => {
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
||||
const { result } = renderHook(() => useAccountEventsPageVm(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
|
||||
@@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAccountEventsPageViewModel(),
|
||||
() => useAccountEventsPageVm(),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -176,7 +176,11 @@ describe("Upcoming event date filters", () => {
|
||||
({ request }) => {
|
||||
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",
|
||||
({ request }) =>
|
||||
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 }),
|
||||
),
|
||||
);
|
||||
@@ -256,6 +264,7 @@ describe("Upcoming event date filters", () => {
|
||||
calculatedAt: "2099-01-01T00:00:00Z",
|
||||
events: [],
|
||||
incomeTotalsByCurrency: [],
|
||||
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"]);
|
||||
const from = await screen.findByLabelText("Период");
|
||||
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(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
|
||||
|
||||
@@ -361,7 +371,7 @@ it("retains the previous complete forecast when its source refresh fails", async
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
||||
const { result } = renderHook(() => useAccountEventsPageVm(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<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",
|
||||
},
|
||||
],
|
||||
incomeTotalsByCurrency: [{ 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",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events: [],
|
||||
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
@@ -136,6 +137,7 @@ describe("AccountEventsPage", () => {
|
||||
type: "offer",
|
||||
},
|
||||
],
|
||||
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
@@ -169,15 +171,18 @@ describe("AccountEventsPage", () => {
|
||||
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();
|
||||
expect(
|
||||
screen.getByText("За выбранный период событий не найдено"),
|
||||
).toBeInTheDocument();
|
||||
query.mockReturnValue({
|
||||
data: undefined,
|
||||
error: { code: "TBANK_UNAVAILABLE", status: 503 },
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as never);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
@@ -187,7 +192,7 @@ describe("AccountEventsPage", () => {
|
||||
</MantineProvider>,
|
||||
);
|
||||
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", () => {
|
||||
@@ -237,4 +242,80 @@ describe("AccountEventsPage", () => {
|
||||
screen.queryByText("За выбранный период событий не найдено"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups same-day instrument events only in all-account mode", () => {
|
||||
const events = [
|
||||
{
|
||||
account: { id: "broker", name: "Брокерский" },
|
||||
date: "2026-08-20",
|
||||
dateKind: "event",
|
||||
displayName: "ОФЗ 26238",
|
||||
figi: "FIGI",
|
||||
instrumentUid: "UID",
|
||||
isin: "RU000A0JX0J2",
|
||||
logoName: null,
|
||||
projectedAmount: { currency: "RUB", value: "200" },
|
||||
ticker: "OFZ",
|
||||
type: "coupon",
|
||||
},
|
||||
{
|
||||
account: { id: "iis", name: "ИИС" },
|
||||
date: "2026-08-20",
|
||||
dateKind: "event",
|
||||
displayName: "ОФЗ 26238",
|
||||
figi: "FIGI",
|
||||
instrumentUid: "UID",
|
||||
isin: "RU000A0JX0J2",
|
||||
logoName: null,
|
||||
projectedAmount: { currency: "RUB", value: "100" },
|
||||
ticker: "OFZ",
|
||||
type: "coupon",
|
||||
},
|
||||
] as const;
|
||||
|
||||
query.mockReturnValue({
|
||||
data: {
|
||||
accountId: "all",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events: [...events],
|
||||
window: { from: "2026-08-14", to: "2026-09-13" },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
meta: {
|
||||
availability: "available",
|
||||
completeness: "complete",
|
||||
lastAttempt: {
|
||||
finishedAt: null,
|
||||
problems: [],
|
||||
startedAt: null,
|
||||
state: "none",
|
||||
},
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/all/events"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
element={<AccountEventsPage />}
|
||||
path="/accounts/:accountId/events"
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument();
|
||||
expect(screen.getByText("300 ₽")).toBeInTheDocument();
|
||||
|
||||
const accounts = within(screen.getByRole("list", {
|
||||
name: /Купон · ОФЗ 26238, 20\.08\.2026, 300 ₽/,
|
||||
}));
|
||||
|
||||
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
|
||||
expect(accounts.getByText("ИИС")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
||||
import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
|
||||
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
||||
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||
|
||||
const AccountEventsPage: FC = () => {
|
||||
const viewModel = useAccountEventsPageViewModel();
|
||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
||||
const vm = useAccountEventsPageVm();
|
||||
if (vm.state === "loading") return <AccountEventsPageLoading />;
|
||||
|
||||
return viewModel.state === "error"
|
||||
? <AccountEventsPageError {...viewModel} />
|
||||
: <AccountEventsPageLoaded {...viewModel} />;
|
||||
return vm.state === "error"
|
||||
? <AccountEventsPageError {...vm} />
|
||||
: <AccountEventsPageLoaded {...vm} />;
|
||||
};
|
||||
|
||||
export { AccountEventsPage };
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
|
||||
import {
|
||||
defaultPeriod,
|
||||
isValidPeriod,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
|
||||
type AccountEventsPageViewModel
|
||||
= | {
|
||||
allAccounts: boolean;
|
||||
calculatedAt: string;
|
||||
canApply: boolean;
|
||||
draftPeriod: [null | string, null | string];
|
||||
events: BrokerageEvent[];
|
||||
@@ -27,13 +28,20 @@ type AccountEventsPageViewModel
|
||||
onPeriodPopoverOpen: () => void;
|
||||
onReset: () => void;
|
||||
partial?: boolean;
|
||||
readRetry: ReturnType<typeof readRetry>;
|
||||
source?: InvestmentResourceMeta;
|
||||
state: "loaded";
|
||||
totalVisible: boolean;
|
||||
window: { from: string; to: string };
|
||||
}
|
||||
| {
|
||||
onReset: () => void;
|
||||
readRetry: ReturnType<typeof readRetry>;
|
||||
state: "error";
|
||||
}
|
||||
| { onReset: () => void; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
function useAccountEventsPageVm(): AccountEventsPageViewModel {
|
||||
const { accountId = "" } = useParams();
|
||||
|
||||
const {
|
||||
@@ -81,6 +89,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
isFetching: events.isFetching,
|
||||
});
|
||||
|
||||
const retry = readRetry([events]);
|
||||
|
||||
if (availability === "loading" && events.isLoading)
|
||||
return { state: "loading" };
|
||||
|
||||
@@ -92,14 +102,17 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
|
||||
return {
|
||||
onReset,
|
||||
readRetry: retry,
|
||||
state: "error",
|
||||
};
|
||||
if (!events.data)
|
||||
return availability === "unavailable"
|
||||
? { onReset, state: "error" }
|
||||
? { onReset, readRetry: retry, state: "error" }
|
||||
: { state: "loading" };
|
||||
|
||||
return {
|
||||
allAccounts: accountId === "all",
|
||||
calculatedAt: events.data.calculatedAt,
|
||||
canApply:
|
||||
(draftFrom !== appliedFrom || draftTo !== appliedTo)
|
||||
&& isValidPeriod(draftFrom, draftTo),
|
||||
@@ -119,9 +132,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
|
||||
onReset,
|
||||
partial: events.meta?.completeness === "incomplete",
|
||||
readRetry: retry,
|
||||
source: events.meta,
|
||||
state: "loaded",
|
||||
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 { 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 { t } = useTranslation();
|
||||
@@ -13,6 +18,13 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||
<StateView
|
||||
action={(
|
||||
<Group>
|
||||
{props.readRetry.exhausted && (
|
||||
<ReadRetryButton
|
||||
onRetry={props.readRetry.onRetry}
|
||||
retrying={props.readRetry.retrying}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={props.onReset} variant="default">
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
|
||||
+39
-8
@@ -13,9 +13,12 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
BrokerageEventCard,
|
||||
BrokerageEventGroup,
|
||||
brokerageEventGroupKey,
|
||||
brokerageEventKey,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib";
|
||||
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
|
||||
@@ -30,14 +33,32 @@ const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
|
||||
props,
|
||||
) => {
|
||||
if (props.events.length > 0) {
|
||||
const entries = groupItems(
|
||||
props.events,
|
||||
props.allAccounts ? brokerageEventGroupKey : () => null,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{props.events.map(event => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={brokerageEventKey(event)}
|
||||
/>
|
||||
))}
|
||||
<Stack
|
||||
component="ul"
|
||||
gap="sm"
|
||||
m={0}
|
||||
p={0}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
{entries.map(entry => entry.type === "group"
|
||||
? (
|
||||
<BrokerageEventGroup
|
||||
events={entry.items}
|
||||
key={entry.key}
|
||||
totalVisible={props.totalVisible}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<li key={brokerageEventKey(entry.item)}>
|
||||
<BrokerageEventCard event={entry.item} />
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -62,10 +83,20 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
stale={props.source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
<ReadRetry {...props.readRetry} />
|
||||
|
||||
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||
|
||||
<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">
|
||||
<DatePickerInput
|
||||
allowSingleDateInRange
|
||||
|
||||
@@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
@@ -33,10 +34,14 @@ import { server } from "@/test/server";
|
||||
import { toIncomeDisplayModel } from "../../model/display";
|
||||
import { AccountIncomePage } from "./AccountIncomePage";
|
||||
import {
|
||||
useAccountIncomePageViewModel,
|
||||
useAccountIncomePageVm,
|
||||
} from "./AccountIncomePage.vm";
|
||||
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
|
||||
= {
|
||||
accountId: "account-1",
|
||||
@@ -100,7 +105,10 @@ function money(currency: string, value: string) {
|
||||
return { currency, value };
|
||||
}
|
||||
|
||||
function renderPage(search = "from=2026-08-01&to=2026-08-27") {
|
||||
function renderPage(
|
||||
search = "from=2026-08-01&to=2026-08-27",
|
||||
accountId = "account-1",
|
||||
) {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
@@ -113,7 +121,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
|
||||
<DatesProvider settings={{ locale: "ru" }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter
|
||||
initialEntries={[`/accounts/account-1/income?${search}`]}
|
||||
initialEntries={[`/accounts/${accountId}/income?${search}`]}
|
||||
>
|
||||
<LocationProbe />
|
||||
|
||||
@@ -163,7 +171,7 @@ describe("AccountIncomePage", () => {
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
|
||||
const { result } = renderHook(() => useAccountIncomePageVm(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
|
||||
@@ -400,21 +408,124 @@ describe("AccountIncomePage", () => {
|
||||
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a quiet realized error", async () => {
|
||||
it.each([
|
||||
["full", analytics],
|
||||
["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }],
|
||||
])("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",
|
||||
() => new HttpResponse(null, { status: 503 }),
|
||||
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("Не удалось рассчитать пассивный доход", undefined, {
|
||||
timeout: 4000,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
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(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
() => {
|
||||
requests += 1;
|
||||
|
||||
return isFailing
|
||||
? new HttpResponse(null, { status: 503 })
|
||||
: HttpResponse.json(resource(analytics));
|
||||
},
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Не удалось рассчитать пассивный доход",
|
||||
undefined,
|
||||
{ timeout: RETRIES_EXHAUSTED_MS },
|
||||
),
|
||||
).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 = () => {
|
||||
@@ -680,4 +791,110 @@ describe("income period and snapshot boundary", () => {
|
||||
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups all-account payouts by instrument with an exact total", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
||||
() => HttpResponse.json(resource({
|
||||
...analytics,
|
||||
accountId: "all",
|
||||
entries: [
|
||||
{
|
||||
...entry(),
|
||||
account: { id: "broker", name: "Брокерский" },
|
||||
payment: money("RUB", "10"),
|
||||
},
|
||||
{
|
||||
...entry(),
|
||||
account: { id: "iis", name: "ИИС" },
|
||||
id: "coupon-2",
|
||||
payment: money("RUB", "20"),
|
||||
},
|
||||
],
|
||||
})),
|
||||
),
|
||||
);
|
||||
renderPage(undefined, "all");
|
||||
|
||||
expect(await screen.findByText("Купон · BOND")).toBeInTheDocument();
|
||||
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
|
||||
|
||||
const accounts = within(screen.getByRole("list", {
|
||||
name: /Купон · BOND, Исполнено/,
|
||||
}));
|
||||
|
||||
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
|
||||
expect(accounts.getByText("ИИС")).toBeInTheDocument();
|
||||
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps repeated payout ids inside one group as separate cards", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
||||
() => HttpResponse.json(resource({
|
||||
...analytics,
|
||||
accountId: "all",
|
||||
entries: [
|
||||
{
|
||||
...entry(),
|
||||
account: { id: "broker", name: "Брокерский" },
|
||||
payment: money("RUB", "10"),
|
||||
},
|
||||
{
|
||||
...entry(),
|
||||
account: { id: "iis", name: "ИИС" },
|
||||
payment: money("RUB", "20"),
|
||||
},
|
||||
],
|
||||
})),
|
||||
),
|
||||
);
|
||||
renderPage(undefined, "all");
|
||||
|
||||
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
|
||||
|
||||
const accounts = within(screen.getByRole("list", {
|
||||
name: /Купон · BOND/,
|
||||
}));
|
||||
|
||||
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows the group total after completeness changes to complete", async () => {
|
||||
let completeness: "complete" | "incomplete" = "incomplete";
|
||||
|
||||
const entries = [
|
||||
{
|
||||
...entry(),
|
||||
account: { id: "broker", name: "Брокерский" },
|
||||
payment: money("RUB", "10"),
|
||||
},
|
||||
{
|
||||
...entry(),
|
||||
account: { id: "iis", name: "ИИС" },
|
||||
id: "coupon-2",
|
||||
payment: money("RUB", "20"),
|
||||
},
|
||||
];
|
||||
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
||||
() => HttpResponse.json(
|
||||
resource({ ...analytics, accountId: "all", entries }, { completeness }),
|
||||
),
|
||||
),
|
||||
);
|
||||
const view = renderPage(undefined, "all");
|
||||
|
||||
await screen.findByText("Купон · BOND");
|
||||
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
|
||||
view.unmount();
|
||||
|
||||
completeness = "complete";
|
||||
renderPage(undefined, "all");
|
||||
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
|
||||
import { useAccountIncomePageVm } from "./AccountIncomePage.vm";
|
||||
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
|
||||
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
||||
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
||||
|
||||
const AccountIncomePage: FC = () => {
|
||||
const viewModel = useAccountIncomePageViewModel();
|
||||
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
|
||||
const vm = useAccountIncomePageVm();
|
||||
if (vm.state === "loading") return <AccountIncomePageLoading />;
|
||||
|
||||
return viewModel.state === "error"
|
||||
? <AccountIncomePageError {...viewModel} />
|
||||
: <AccountIncomePageLoaded {...viewModel} />;
|
||||
return vm.state === "error"
|
||||
? <AccountIncomePageError {...vm} />
|
||||
: <AccountIncomePageLoaded {...vm} />;
|
||||
};
|
||||
|
||||
export { AccountIncomePage };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from "react-router";
|
||||
|
||||
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
|
||||
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
|
||||
|
||||
import {
|
||||
@@ -30,15 +31,19 @@ type AccountIncomePageViewModel
|
||||
onReset: () => void;
|
||||
onSubmit: (values: IncomeForm) => void;
|
||||
partial?: boolean;
|
||||
readRetry: ReturnType<typeof readRetry>;
|
||||
source?: InvestmentResourceMeta;
|
||||
state: "loaded";
|
||||
totalVisible: boolean;
|
||||
}
|
||||
| {
|
||||
period: IncomePeriod;
|
||||
readRetry: ReturnType<typeof readRetry>;
|
||||
state: "error";
|
||||
}
|
||||
| { state: "loading" };
|
||||
|
||||
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
|
||||
const { accountId = "" } = useParams();
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
|
||||
@@ -83,12 +88,14 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
isFetching: analytics.isFetching,
|
||||
});
|
||||
|
||||
const retry = readRetry([analytics]);
|
||||
|
||||
if (availability === "loading" && analytics.isLoading)
|
||||
return { state: "loading" };
|
||||
|
||||
if (!analytics.data)
|
||||
return availability === "unavailable"
|
||||
? { period, state: "error" }
|
||||
? { period, readRetry: retry, state: "error" }
|
||||
: { state: "loading" };
|
||||
|
||||
const display = toIncomeDisplayModel(analytics.data);
|
||||
@@ -115,7 +122,10 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
onReset,
|
||||
onSubmit,
|
||||
partial: analytics.meta?.completeness === "incomplete",
|
||||
readRetry: retry,
|
||||
source: analytics.meta,
|
||||
state: "loaded",
|
||||
totalVisible: analytics.meta?.completeness === "complete",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -136,5 +146,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean {
|
||||
|
||||
export {
|
||||
type AccountIncomePageViewModel,
|
||||
useAccountIncomePageViewModel,
|
||||
useAccountIncomePageVm,
|
||||
};
|
||||
|
||||
+13
-1
@@ -3,15 +3,27 @@ import type { FC } from "react";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ReadRetryButton } from "@/shared/ui";
|
||||
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
|
||||
|
||||
const AccountIncomePageError: FC<ErrorProps> = () => {
|
||||
const AccountIncomePageError: FC<ErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const retry = props.readRetry.exhausted
|
||||
? (
|
||||
<ReadRetryButton
|
||||
onRetry={props.readRetry.onRetry}
|
||||
retrying={props.readRetry.retrying}
|
||||
/>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={retry}
|
||||
error
|
||||
text={t("income.errorText")}
|
||||
title={t("income.errorTitle")}
|
||||
|
||||
+15
-1
@@ -9,6 +9,8 @@ import { StateView } from "@wealthledger/ui";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
@@ -49,11 +51,23 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
/>
|
||||
</FormProvider>
|
||||
|
||||
<SourceSyncStatus
|
||||
lastAttempt={props.source?.lastAttempt}
|
||||
lastSuccessfulAt={props.source?.lastSuccessfulAt}
|
||||
stale={props.source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
<ReadRetry {...props.readRetry} />
|
||||
|
||||
<AccountIncomePageLoadedSummary {...props} />
|
||||
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
|
||||
<IncomePayments entries={display.entries} />
|
||||
<IncomePayments
|
||||
allAccounts={props.analytics.accountId === "all"}
|
||||
entries={display.entries}
|
||||
totalVisible={props.totalVisible}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,13 +3,22 @@ import type { FC } from "react";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import {
|
||||
brokerageOperationGroupKey,
|
||||
LedgerEntryRow,
|
||||
OperationGroup,
|
||||
} from "@/entities/broker-operation";
|
||||
import { groupItems } from "@/shared/lib";
|
||||
|
||||
import type { IncomeDisplayModel } from "../../../model/display";
|
||||
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
|
||||
type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] };
|
||||
type IncomePaymentsProps = {
|
||||
allAccounts?: boolean;
|
||||
entries: IncomeDisplayModel["entries"];
|
||||
totalVisible?: boolean;
|
||||
};
|
||||
|
||||
const IncomePayments: FC<IncomePaymentsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,11 +28,24 @@ const IncomePayments: FC<IncomePaymentsProps> = (props) => {
|
||||
<Title order={2}>{t("income.payments")}</Title>
|
||||
|
||||
<Stack className={classes.entries} component="ul" gap="sm">
|
||||
{props.entries.map(({ key, operation }) => (
|
||||
<li key={key}>
|
||||
<LedgerEntryRow operation={operation} wrap />
|
||||
</li>
|
||||
))}
|
||||
{groupItems(
|
||||
props.entries,
|
||||
entry => props.allAccounts
|
||||
? brokerageOperationGroupKey(entry.operation)
|
||||
: null,
|
||||
).map(entry => entry.type === "group"
|
||||
? (
|
||||
<OperationGroup
|
||||
entries={entry.items}
|
||||
key={entry.key}
|
||||
totalVisible={props.totalVisible === true}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<li key={entry.item.key}>
|
||||
<LedgerEntryRow operation={entry.item.operation} wrap />
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
+230
-10
@@ -2,7 +2,7 @@ import type { FC } from "react";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
MemoryRouter,
|
||||
@@ -44,6 +44,25 @@ const operation: BrokerageOperation
|
||||
type: "OPERATION_TYPE_BUY",
|
||||
};
|
||||
|
||||
function coupon(
|
||||
id: string,
|
||||
accountId: string,
|
||||
accountName: string,
|
||||
value: string,
|
||||
): BrokerageOperation {
|
||||
return {
|
||||
...operation,
|
||||
account: { id: accountId, name: accountName },
|
||||
category: "income",
|
||||
description: "Купон",
|
||||
id,
|
||||
instrumentUid: "UID",
|
||||
payment: { currency: "RUB", value },
|
||||
ticker: "BOND",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
};
|
||||
}
|
||||
|
||||
const Navigation: FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -71,12 +90,16 @@ function configureTestAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage(search = "") {
|
||||
function renderPage(
|
||||
search = "",
|
||||
accountId = "account-1",
|
||||
queryClient = createTestQueryClient(),
|
||||
) {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter
|
||||
initialEntries={[`/accounts/account-1/operations${search}`]}
|
||||
initialEntries={[`/accounts/${accountId}/operations${search}`]}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -90,6 +113,18 @@ function renderPage(search = "") {
|
||||
);
|
||||
}
|
||||
|
||||
// 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", () => {
|
||||
it("marks cashless operations without rendering operation details", () => {
|
||||
render(
|
||||
@@ -217,6 +252,27 @@ describe("LedgerEntryRow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const historyStateResource = (historyState: "loading" | "partial") =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{
|
||||
hasNext: false,
|
||||
items: [operation],
|
||||
nextCursor: null,
|
||||
},
|
||||
{
|
||||
coverage: {
|
||||
asOf: null,
|
||||
completeRanges: [],
|
||||
historyBoundary: "unknown",
|
||||
historyState,
|
||||
missingRanges: [],
|
||||
requested: [],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
describe("AccountOperationsPage", () => {
|
||||
it.each(["account-1", "all"])(
|
||||
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
|
||||
@@ -478,21 +534,115 @@ describe("AccountOperationsPage", () => {
|
||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders an error state", async () => {
|
||||
it("does not show the history loading alert when coverage is partial", async () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
() => historyStateResource("partial"),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
await screen.findByText("Покупка акций");
|
||||
expect(screen.queryByText("История догружается")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the history loading alert while historyState is loading", async () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
() => historyStateResource("loading"),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Не удалось загрузить журнал", undefined, {
|
||||
timeout: 4000,
|
||||
}),
|
||||
await screen.findByText("История догружается"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it(
|
||||
"renders an error state with retry that recovers in place",
|
||||
{ timeout: TEST_MS },
|
||||
async () => {
|
||||
configureTestAuth();
|
||||
let isFailing = true;
|
||||
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));
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Не удалось загрузить журнал", undefined, {
|
||||
timeout: RETRIES_EXHAUSTED_MS,
|
||||
}),
|
||||
).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 () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
@@ -602,3 +752,73 @@ it("recovers an invalid cursor at the first page without clearing applied filter
|
||||
for (const key of ["from", "to", "categories"])
|
||||
expect(searches[2].get(key)).toBe(searches[0].get(key));
|
||||
});
|
||||
|
||||
describe("all-account operation groups", () => {
|
||||
it("groups only the current page and rebuilds the group at its boundary", async () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
({ request }) => {
|
||||
const cursor = new URL(request.url).searchParams.get("cursor");
|
||||
|
||||
const items = cursor
|
||||
? [
|
||||
coupon("X2", "iis", "ИИС", "150"),
|
||||
coupon("X3", "broker", "Брокерский", "50"),
|
||||
operation,
|
||||
]
|
||||
: [coupon("X1", "broker", "Брокерский", "100")];
|
||||
|
||||
return HttpResponse.json(resource({
|
||||
accountId: "all",
|
||||
hasNext: !cursor,
|
||||
items,
|
||||
nextCursor: cursor ? null : "page-2",
|
||||
}));
|
||||
},
|
||||
),
|
||||
);
|
||||
renderPage("", "all");
|
||||
|
||||
await screen.findAllByText("Купон");
|
||||
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
|
||||
.not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||
|
||||
const accounts = await screen.findByRole("list", {
|
||||
name: /Купон · BOND, Исполнено/,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
|
||||
expect(screen.getByText("+200 ₽")).toBeInTheDocument();
|
||||
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
|
||||
await screen.findAllByText("Купон");
|
||||
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves repeated operations ungrouped for one account", async () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(resource({
|
||||
accountId: "account-1",
|
||||
hasNext: false,
|
||||
items: [
|
||||
coupon("X1", "account-1", "Брокерский", "100"),
|
||||
coupon("X2", "account-1", "Брокерский", "150"),
|
||||
],
|
||||
nextCursor: null,
|
||||
})),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
await screen.findAllByText("Купон");
|
||||
expect(screen.queryByRole("list", { name: /Разбивка|Исполнено/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL
|
||||
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
|
||||
|
||||
const AccountOperationsPage: FC = () => {
|
||||
const viewModel = useAccountOperationsPageVm();
|
||||
const vm = useAccountOperationsPageVm();
|
||||
|
||||
if (viewModel.state === "loading") {
|
||||
if (vm.state === "loading") {
|
||||
return <AccountOperationsPageLoading />;
|
||||
}
|
||||
|
||||
return viewModel.state === "error"
|
||||
? <AccountOperationsPageError {...viewModel} />
|
||||
: <AccountOperationsPageLoaded {...viewModel} />;
|
||||
return vm.state === "error"
|
||||
? <AccountOperationsPageError {...vm} />
|
||||
: <AccountOperationsPageLoaded {...vm} />;
|
||||
};
|
||||
|
||||
export { AccountOperationsPage };
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
useBrokerageOperationHistory,
|
||||
} from "@/entities/broker-operation";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import {
|
||||
ApiErrorCode,
|
||||
apiErrorCode,
|
||||
isApiErrorCode,
|
||||
readRetry,
|
||||
} from "@/shared/api";
|
||||
import {
|
||||
defaultPeriod,
|
||||
isValidPeriod,
|
||||
@@ -25,6 +30,7 @@ const operationCategorySet = new Set<string>(operationCategories);
|
||||
|
||||
type AccountOperationsPageViewModel
|
||||
= | {
|
||||
allAccounts: boolean;
|
||||
canApply: boolean;
|
||||
draftCategories: OperationCategory[];
|
||||
draftPeriod: [null | string, null | string];
|
||||
@@ -41,10 +47,16 @@ type AccountOperationsPageViewModel
|
||||
onPrevious: () => void;
|
||||
onReset: () => void;
|
||||
partial?: boolean;
|
||||
readRetry: ReturnType<typeof readRetry>;
|
||||
source?: InvestmentResourceMeta;
|
||||
state: "loaded";
|
||||
totalVisible: boolean;
|
||||
}
|
||||
| {
|
||||
onReset: () => void;
|
||||
readRetry: ReturnType<typeof readRetry>;
|
||||
state: "error";
|
||||
}
|
||||
| { onReset: () => void; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
@@ -105,6 +117,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
isFetching: operations.isFetching,
|
||||
});
|
||||
|
||||
const retry = readRetry([operations]);
|
||||
|
||||
if (availability === "loading" && operations.isLoading)
|
||||
return { state: "loading" };
|
||||
|
||||
@@ -132,11 +146,11 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
(isInvalidCursor || !operations.data)
|
||||
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
|
||||
)
|
||||
return { onReset, state: "error" };
|
||||
return { onReset, readRetry: retry, state: "error" };
|
||||
|
||||
if (!operations.data)
|
||||
return availability === "unavailable"
|
||||
? { onReset, state: "error" }
|
||||
? { onReset, readRetry: retry, state: "error" }
|
||||
: { state: "loading" };
|
||||
|
||||
const nextCursor = operations.data.nextCursor;
|
||||
@@ -147,14 +161,13 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
|| !isSameCategories(draftCategories, appliedCategories);
|
||||
|
||||
return {
|
||||
allAccounts: accountId === "all",
|
||||
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
|
||||
draftCategories,
|
||||
draftPeriod,
|
||||
hasNext: Boolean(nextCursor && operations.data.hasNext),
|
||||
hasPrevious: history.hasPrevious,
|
||||
historyLoading:
|
||||
operations.meta?.coverage?.historyState === "loading"
|
||||
|| operations.meta?.coverage?.historyState === "partial",
|
||||
historyLoading: operations.meta?.coverage?.historyState === "loading",
|
||||
items: operations.data.items,
|
||||
maxDate,
|
||||
onApply: () => {
|
||||
@@ -181,8 +194,10 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
onPrevious: history.onPrevious,
|
||||
onReset,
|
||||
partial: operations.meta?.completeness === "incomplete",
|
||||
readRetry: retry,
|
||||
source: operations.meta,
|
||||
state: "loaded",
|
||||
totalVisible: operations.meta?.completeness === "complete",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+15
-4
@@ -1,9 +1,11 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ReadRetryButton } from "@/shared/ui";
|
||||
|
||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||
|
||||
type AccountOperationsPageErrorProps = Extract<
|
||||
@@ -19,9 +21,18 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
|
||||
return (
|
||||
<StateView
|
||||
action={(
|
||||
<Button onClick={props.onReset} variant="default">
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
<Group gap="sm" justify="center">
|
||||
{props.readRetry.exhausted && (
|
||||
<ReadRetryButton
|
||||
onRetry={props.readRetry.onRetry}
|
||||
retrying={props.readRetry.retrying}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={props.onReset} variant="default">
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
text={t("ledger.errorText")}
|
||||
title={t("ledger.errorTitle")}
|
||||
|
||||
+45
-10
@@ -12,8 +12,14 @@ import { DatePickerInput } from "@mantine/dates";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
import {
|
||||
brokerageOperationGroupKey,
|
||||
LedgerEntryRow,
|
||||
operationCategories,
|
||||
OperationGroup,
|
||||
} from "@/entities/broker-operation";
|
||||
import { groupItems } from "@/shared/lib";
|
||||
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||
|
||||
@@ -24,22 +30,49 @@ type AccountOperationsPageLoadedProps = Extract<
|
||||
|
||||
const AccountOperationsPageLoadedOperations: FC<
|
||||
AccountOperationsPageLoadedProps
|
||||
> = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists.
|
||||
> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { items } = props;
|
||||
|
||||
if (items.length > 0) {
|
||||
return items.map(operation => (
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
const entries = groupItems(
|
||||
items.map(operation => ({
|
||||
key: JSON.stringify([
|
||||
operation.account?.id,
|
||||
operation.id,
|
||||
operation.logoName,
|
||||
])}
|
||||
operation={operation}
|
||||
/>
|
||||
));
|
||||
]),
|
||||
operation,
|
||||
})),
|
||||
entry => props.allAccounts
|
||||
? brokerageOperationGroupKey(entry.operation)
|
||||
: null,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
component="ul"
|
||||
gap="lg"
|
||||
m={0}
|
||||
p={0}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
{entries.map(entry => entry.type === "group"
|
||||
? (
|
||||
<OperationGroup
|
||||
entries={entry.items}
|
||||
key={entry.key}
|
||||
totalVisible={props.totalVisible}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<li key={entry.item.key}>
|
||||
<LedgerEntryRow operation={entry.item.operation} />
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.partial) {
|
||||
@@ -69,6 +102,8 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||
stale={props.source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
<ReadRetry {...props.readRetry} />
|
||||
|
||||
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||
|
||||
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { toConcentration } from "./concentration";
|
||||
import { portfolio, position } from "./positions.fixtures";
|
||||
|
||||
function snapshot(positions: Portfolio["positions"]): Portfolio {
|
||||
return { ...portfolio, positions };
|
||||
}
|
||||
|
||||
function stock(
|
||||
name: string,
|
||||
value: null | string,
|
||||
currency = "RUB",
|
||||
overrides: Partial<Portfolio["positions"][number]> = {},
|
||||
): Portfolio["positions"][number] {
|
||||
return {
|
||||
...position,
|
||||
currentValue: value === null ? null : { currency, value },
|
||||
displayName: name,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const labels = {
|
||||
missingIssuer: "Эмитент не указан",
|
||||
missingSector: "Сектор не указан",
|
||||
other: "Прочее",
|
||||
sector: (code: string) => code,
|
||||
};
|
||||
|
||||
describe("account concentration", () => {
|
||||
it("uses exact positive values and excludes cash, negatives and nulls", () => {
|
||||
const model = toConcentration(snapshot([
|
||||
stock("A", "1"), stock("B", "15"),
|
||||
stock("C", "0"), stock("D", "-50"), stock("E", null),
|
||||
stock("F", null, "USD"), stock("G", "-3", "USD"),
|
||||
]), "stocks", "RUB", labels);
|
||||
|
||||
expect(model.currencies).toEqual(["RUB", "USD"]);
|
||||
expect(model.total).toBe("16");
|
||||
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
|
||||
expect(model.unaccounted).toBe(3);
|
||||
const negative = snapshot([stock("A", "-3", "USD")]);
|
||||
expect(toConcentration(negative, "stocks", "USD", labels))
|
||||
.toMatchObject({ empty: true, unaccounted: 1 });
|
||||
});
|
||||
|
||||
it("ranks seven positions with exact other and top-five totals", () => {
|
||||
const model = toConcentration(snapshot([
|
||||
stock("A", "400"), stock("B", "250"), stock("C", "150"),
|
||||
stock("D", "100"), stock("E", "50"), stock("F", "30"),
|
||||
stock("G", "20"),
|
||||
]), "stocks", "RUB", labels);
|
||||
|
||||
expect(model.items.map(item => item.amount)).toEqual([
|
||||
"400", "250", "150", "100", "50",
|
||||
]);
|
||||
expect(model.other).toMatchObject({
|
||||
amount: "50", count: 2, share: "5 %",
|
||||
});
|
||||
expect(model.top5Share).toBe("95 %");
|
||||
expect(model.other?.entries.map(item => item.share)).toEqual([
|
||||
"3 %", "2 %",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps sixth row, tiny share and stable alphabetical ties", () => {
|
||||
const model = toConcentration(snapshot([
|
||||
stock("Сбер", "100"), stock("X5", "100"),
|
||||
stock("VK", "100"), stock("D", "100"),
|
||||
stock("E", "100"), stock("F", "100"),
|
||||
]), "stocks", "RUB", labels);
|
||||
|
||||
expect(model.items).toHaveLength(6);
|
||||
expect(model.other).toBeNull();
|
||||
expect(model.items.slice(0, 2).map(item => item.name)).toEqual([
|
||||
"D", "E",
|
||||
]);
|
||||
|
||||
const tiny = snapshot([
|
||||
stock("Large", "100000000"), stock("Small", "1"),
|
||||
]);
|
||||
|
||||
expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share)
|
||||
.toBe("< 0,1 %");
|
||||
});
|
||||
|
||||
it("groups sectors and issuers by codes, not labels", () => {
|
||||
const positions = [
|
||||
stock("A", "300", "RUB", {
|
||||
issuer: { id: "one", name: "Сбербанк" }, sector: "energy",
|
||||
}),
|
||||
stock("B", "200", "RUB", {
|
||||
issuer: { id: "one", name: "Сбер" }, sector: "energy",
|
||||
type: "BOND",
|
||||
}),
|
||||
stock("C", "100", "RUB", {
|
||||
issuer: { id: "two", name: "Сбер" }, sector: "financial",
|
||||
type: "FUND",
|
||||
}),
|
||||
stock("D", "50", "RUB", { issuer: null, sector: null }),
|
||||
];
|
||||
|
||||
expect(toConcentration(snapshot(positions), "sectors", "RUB", labels)
|
||||
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
|
||||
expect(toConcentration(snapshot(positions), "issuers", "RUB", labels)
|
||||
.items.map(item => [item.name, item.amount])).toEqual([
|
||||
["Сбербанк", "500"], ["", "50"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders equal sums by displayed name", () => {
|
||||
const issuers = snapshot([
|
||||
stock("A", "300", "RUB", {
|
||||
issuer: { id: "g", name: "Газпром" }, sector: "energy",
|
||||
}),
|
||||
stock("B", "300", "RUB", {
|
||||
issuer: { id: "s", name: "Сбер" }, sector: "energy",
|
||||
}),
|
||||
stock("C", "200", "RUB", { issuer: null, sector: "energy" }),
|
||||
stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
|
||||
]);
|
||||
|
||||
expect(toConcentration(issuers, "issuers", "RUB", labels)
|
||||
.items.map(item => [item.label, item.amount])).toEqual([
|
||||
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
|
||||
]);
|
||||
|
||||
const sectors = snapshot([
|
||||
stock("A", "100", "RUB", { sector: "it" }),
|
||||
stock("B", "100", "RUB", { sector: "energy" }),
|
||||
]);
|
||||
|
||||
const sectorLabels = {
|
||||
...labels,
|
||||
sector: (code: string) => code === "it"
|
||||
? "Информационные технологии"
|
||||
: "Энергетика",
|
||||
};
|
||||
|
||||
expect(toConcentration(sectors, "sectors", "RUB", sectorLabels)
|
||||
.items.map(item => item.label))
|
||||
.toEqual(["Информационные технологии", "Энергетика"]);
|
||||
});
|
||||
|
||||
it("chooses issuer name from the largest position, then alphabetically", () => {
|
||||
const data = snapshot([
|
||||
stock("A", "100", "RUB", {
|
||||
issuer: { id: "brand", name: "Ястреб" },
|
||||
}),
|
||||
stock("B", "300", "RUB", {
|
||||
issuer: { id: "brand", name: "Альфа" },
|
||||
}),
|
||||
stock("C", "400", "RUB", {
|
||||
issuer: { id: "other", name: "Бета" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(toConcentration(data, "issuers", "RUB", labels).items
|
||||
.map(item => [item.name, item.label, item.amount])).toEqual([
|
||||
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses ISIN before ticker for bonds", () => {
|
||||
const data = snapshot([stock("Облигация", "100", "RUB", {
|
||||
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
|
||||
})]);
|
||||
|
||||
expect(toConcentration(data, "bonds", "RUB", labels)
|
||||
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import {
|
||||
type Decimal,
|
||||
decimalFromString,
|
||||
decimalToString,
|
||||
instrumentLogoUrl,
|
||||
sumDecimals,
|
||||
} from "@/shared/lib";
|
||||
|
||||
type Concentration = {
|
||||
currencies: string[];
|
||||
empty: boolean;
|
||||
items: ConcentrationItem[];
|
||||
other: ConcentrationOther | null;
|
||||
top5Share: null | string;
|
||||
total: null | string;
|
||||
unaccounted: number;
|
||||
};
|
||||
|
||||
type ConcentrationItem = {
|
||||
amount: string;
|
||||
chartValue: number;
|
||||
key: string;
|
||||
label: string;
|
||||
logo: ConcentrationLogo | null;
|
||||
name: string;
|
||||
share: string;
|
||||
unknown: boolean;
|
||||
};
|
||||
|
||||
type ConcentrationLabels = {
|
||||
missingIssuer: string;
|
||||
missingSector: string;
|
||||
other: string;
|
||||
sector: (code: string) => string;
|
||||
};
|
||||
|
||||
type ConcentrationLogo = {
|
||||
src: null | string;
|
||||
subtitle: null | string;
|
||||
};
|
||||
|
||||
type ConcentrationOther = ConcentrationItem & {
|
||||
count: number;
|
||||
entries: ConcentrationItem[];
|
||||
};
|
||||
|
||||
type Group = {
|
||||
best?: { name: string; value: Decimal };
|
||||
first: number;
|
||||
key: string;
|
||||
label: string;
|
||||
logo: ConcentrationLogo | null;
|
||||
name: string;
|
||||
unknown: boolean;
|
||||
values: Decimal[];
|
||||
};
|
||||
|
||||
type Position = Portfolio["positions"][number];
|
||||
|
||||
type Slice = "bonds"
|
||||
| "issuers"
|
||||
| "sectors"
|
||||
| "stocks";
|
||||
|
||||
const eligibleTypes = new Set(["BOND", "FUND", "STOCK"]);
|
||||
const RADIX = 10n;
|
||||
const CHART_PRECISION = 1_000_000n;
|
||||
const SHARE_PRECISION = 1000n;
|
||||
const HALF = 2n;
|
||||
const TOP = 5;
|
||||
const FIRST_OTHER = 7;
|
||||
|
||||
const compareNames = (a: string, b: string) =>
|
||||
a.toLowerCase().localeCompare(b.toLowerCase(), "en", {
|
||||
numeric: false,
|
||||
});
|
||||
|
||||
function addPosition(
|
||||
groups: Map<string, Group>,
|
||||
position: Position,
|
||||
index: number,
|
||||
slice: Slice,
|
||||
labels: ConcentrationLabels,
|
||||
selected: null | string,
|
||||
): number {
|
||||
if (!isIncluded(slice, position.type)) return 0;
|
||||
if (!position.currentValue) return 1;
|
||||
if (position.currentValue.currency !== selected) return 0;
|
||||
const value = decimalFromString(position.currentValue.value);
|
||||
if (value.value < 0n) return 1;
|
||||
if (value.value === 0n) return 0;
|
||||
const identity = groupIdentity(position, slice, index, labels);
|
||||
|
||||
const group = groups.get(identity.key) ?? {
|
||||
...identity, first: index, values: [],
|
||||
};
|
||||
|
||||
group.values.push(value);
|
||||
|
||||
if (slice === "issuers" && !identity.unknown) {
|
||||
const best = group.best;
|
||||
|
||||
if (!best || compareDecimal(value, best.value) > 0
|
||||
|| (compareDecimal(value, best.value) === 0
|
||||
&& compareNames(identity.name, best.name) < 0)) {
|
||||
group.best = { name: identity.name, value };
|
||||
group.name = identity.name;
|
||||
group.label = identity.label;
|
||||
}
|
||||
}
|
||||
|
||||
groups.set(identity.key, group);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function chartValue(part: Decimal, total: Decimal): number {
|
||||
const scale = Math.max(part.scale, total.scale);
|
||||
const numerator = part.value * RADIX ** BigInt(scale - part.scale);
|
||||
const denominator = total.value * RADIX ** BigInt(scale - total.scale);
|
||||
|
||||
return Number(numerator * CHART_PRECISION / denominator)
|
||||
/ Number(CHART_PRECISION);
|
||||
}
|
||||
|
||||
function compareDecimal(a: Decimal, b: Decimal): number {
|
||||
const scale = Math.max(a.scale, b.scale);
|
||||
const left = a.value * RADIX ** BigInt(scale - a.scale);
|
||||
const right = b.value * RADIX ** BigInt(scale - b.scale);
|
||||
|
||||
if (left > right) return 1;
|
||||
|
||||
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(
|
||||
position: Position,
|
||||
slice: Slice,
|
||||
index: number,
|
||||
labels: ConcentrationLabels,
|
||||
) {
|
||||
if (slice === "sectors") {
|
||||
const sector = position.sector;
|
||||
const isUnknown = !sector;
|
||||
|
||||
return {
|
||||
key: sector ?? "",
|
||||
label: isUnknown
|
||||
? labels.missingSector
|
||||
: labels.sector(sector),
|
||||
logo: null,
|
||||
name: sector ?? "",
|
||||
unknown: isUnknown,
|
||||
};
|
||||
}
|
||||
|
||||
if (slice === "issuers") {
|
||||
const isUnknown = !position.issuer;
|
||||
|
||||
return {
|
||||
key: position.issuer?.id ?? "",
|
||||
label: position.issuer ? position.issuer.name : labels.missingIssuer,
|
||||
logo: null,
|
||||
name: position.issuer?.name ?? "",
|
||||
unknown: isUnknown,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: String(index),
|
||||
label: position.displayName,
|
||||
logo: {
|
||||
src: instrumentLogoUrl(position.logoName),
|
||||
subtitle: slice === "bonds"
|
||||
? position.isin ?? position.ticker ?? position.figi
|
||||
: position.ticker ?? position.isin ?? position.figi,
|
||||
},
|
||||
name: position.displayName,
|
||||
unknown: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isIncluded(slice: Slice, type: string): boolean {
|
||||
if (slice === "stocks") return type === "STOCK";
|
||||
if (slice === "bonds") return type === "BOND";
|
||||
|
||||
return slice === "issuers" ? type === "STOCK" || type === "BOND" : eligibleTypes.has(type);
|
||||
}
|
||||
|
||||
function share(part: Decimal, total: Decimal): string {
|
||||
const scale = Math.max(part.scale, total.scale);
|
||||
const numerator = part.value * RADIX ** BigInt(scale - part.scale);
|
||||
const denominator = total.value * RADIX ** BigInt(scale - total.scale);
|
||||
if (numerator * SHARE_PRECISION < denominator) return "< 0,1 %";
|
||||
|
||||
const tenths = (numerator * SHARE_PRECISION * HALF + denominator)
|
||||
/ (HALF * denominator);
|
||||
|
||||
return tenths % RADIX === 0n ? `${tenths / RADIX} %` : `${tenths / RADIX},${tenths % RADIX} %`;
|
||||
}
|
||||
|
||||
function toConcentration(
|
||||
portfolio: Portfolio,
|
||||
slice: Slice,
|
||||
currency: null | string,
|
||||
labels: ConcentrationLabels,
|
||||
): Concentration {
|
||||
const currencies = concentrationCurrencies(portfolio);
|
||||
|
||||
const selected = currency && currencies.includes(currency)
|
||||
? currency
|
||||
: currencies[0] ?? null;
|
||||
|
||||
const groups = new Map<string, Group>();
|
||||
let unaccounted = 0;
|
||||
|
||||
for (const [index, position] of portfolio.positions.entries()) {
|
||||
unaccounted += addPosition(
|
||||
groups, position, index, slice, labels, selected,
|
||||
);
|
||||
}
|
||||
|
||||
const ranked = Array.from(groups.values(), group => ({
|
||||
...group, sum: sumDecimals(group.values),
|
||||
})).toSorted((a, b) => compareDecimal(b.sum, a.sum)
|
||||
|| compareNames(a.label, b.label) || a.first - b.first);
|
||||
|
||||
const total = ranked.length > 0
|
||||
? sumDecimals(ranked.map(item => item.sum))
|
||||
: null;
|
||||
|
||||
const entries = total
|
||||
? ranked.map(group => ({
|
||||
amount: decimalToString(group.sum),
|
||||
chartValue: chartValue(group.sum, total),
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
logo: group.logo,
|
||||
name: group.name,
|
||||
share: share(group.sum, total),
|
||||
unknown: group.unknown,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const rest = entries.length >= FIRST_OTHER ? entries.slice(TOP) : [];
|
||||
|
||||
const otherSum = rest.length > 0
|
||||
? sumDecimals(ranked.slice(TOP).map(item => item.sum))
|
||||
: null;
|
||||
|
||||
const other = otherSum && total
|
||||
? {
|
||||
amount: decimalToString(otherSum),
|
||||
chartValue: chartValue(otherSum, total),
|
||||
count: rest.length,
|
||||
entries: rest,
|
||||
key: "__other",
|
||||
label: labels.other,
|
||||
logo: null,
|
||||
name: "", share: share(otherSum, total), unknown: false,
|
||||
}
|
||||
: null;
|
||||
|
||||
const top5 = ranked.length > TOP
|
||||
? sumDecimals(ranked.slice(0, TOP).map(item => item.sum))
|
||||
: null;
|
||||
|
||||
return {
|
||||
currencies,
|
||||
empty: !total,
|
||||
items: other ? entries.slice(0, TOP) : entries,
|
||||
other,
|
||||
top5Share: top5 && total ? share(top5, total) : null,
|
||||
total: total && decimalToString(total),
|
||||
unaccounted,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
concentrationCurrencies,
|
||||
toConcentration,
|
||||
};
|
||||
export type {
|
||||
Concentration,
|
||||
ConcentrationItem,
|
||||
ConcentrationLabels,
|
||||
Slice,
|
||||
};
|
||||
@@ -13,8 +13,10 @@ const portfolio
|
||||
displayName: "Акция",
|
||||
figi: "FIGI-STOCK",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: "akciya.png",
|
||||
quantity: "2",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
@@ -25,8 +27,10 @@ const portfolio
|
||||
displayName: "Облигация",
|
||||
figi: "FIGI-BOND",
|
||||
isin: "RU000A0JX0J2",
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
@@ -37,8 +41,10 @@ const portfolio
|
||||
displayName: "Фонд",
|
||||
figi: "FIGI-FUND",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: "fond.png",
|
||||
quantity: "3",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "FUND",
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ function sections(positions: Portfolio["positions"]) {
|
||||
}
|
||||
|
||||
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 = [
|
||||
"OPTION",
|
||||
"OTHER",
|
||||
@@ -73,12 +73,30 @@ describe("account position sections", () => {
|
||||
"FIGI-FUND",
|
||||
...otherTypes.map(type => `FIGI-${type}-NEXT`),
|
||||
]);
|
||||
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
|
||||
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
|
||||
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
|
||||
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("50 %");
|
||||
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("25 %");
|
||||
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("25 %");
|
||||
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", () => {
|
||||
expect(
|
||||
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { formatShare, type Portfolio } from "@/entities/portfolio";
|
||||
import { formatDecimal, formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||
import {
|
||||
type Decimal,
|
||||
decimalFromString,
|
||||
decimalToString,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
instrumentLogoUrl,
|
||||
sumDecimals,
|
||||
} from "@/shared/lib";
|
||||
|
||||
type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
instrumentTo?: string;
|
||||
@@ -23,8 +31,6 @@ type AccountPagePositionSection = {
|
||||
|
||||
type AccountPageReturnTone = "negative" | "positive";
|
||||
|
||||
type Decimal = { scale: number; value: bigint };
|
||||
|
||||
type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>;
|
||||
|
||||
type PositionSectionKind = "bonds"
|
||||
@@ -32,38 +38,11 @@ type PositionSectionKind = "bonds"
|
||||
| "stocks";
|
||||
|
||||
const RADIX = 10n;
|
||||
const DECIMAL_PARTS = 2;
|
||||
const PERCENT_SCALE = 6;
|
||||
const DISPLAY_PERCENT_SCALE = 2;
|
||||
const PERCENT_FACTOR = 100n;
|
||||
const ROUNDING_FACTOR = 2n;
|
||||
|
||||
function decimalFromString(value: string): Decimal {
|
||||
const [whole = "0", fraction = ""] = value.split(".", DECIMAL_PARTS);
|
||||
|
||||
return { scale: fraction.length, value: BigInt(`${whole}${fraction}`) };
|
||||
}
|
||||
|
||||
function decimalToString(decimal: Decimal): string {
|
||||
const sign = decimal.value < 0n ? "-" : "";
|
||||
|
||||
const digits = (decimal.value < 0n ? -decimal.value : decimal.value)
|
||||
.toString()
|
||||
.padStart(decimal.scale + 1, "0");
|
||||
|
||||
const whole = decimal.scale
|
||||
? digits.slice(0, -decimal.scale)
|
||||
: digits;
|
||||
|
||||
let fraction = decimal.scale ? digits.slice(-decimal.scale) : "";
|
||||
|
||||
while (fraction.endsWith("0")) fraction = fraction.slice(0, -1);
|
||||
|
||||
const suffix = fraction ? `.${fraction}` : "";
|
||||
|
||||
return `${sign}${whole}${suffix}`;
|
||||
}
|
||||
|
||||
function formatPositionReturnPercent(value: string): string {
|
||||
const decimal = decimalFromString(value);
|
||||
let rounded = decimal.value;
|
||||
@@ -167,25 +146,13 @@ function returnPercent(
|
||||
return decimalToString({ scale: PERCENT_SCALE, value: rounded });
|
||||
}
|
||||
|
||||
function sumDecimals(values: Decimal[]): Decimal {
|
||||
const scale = Math.max(...values.map(value => value.scale));
|
||||
|
||||
const sum = values.reduce(
|
||||
(total, value) => total
|
||||
+ value.value * RADIX ** BigInt(scale - value.scale),
|
||||
0n,
|
||||
);
|
||||
|
||||
return { scale, value: sum };
|
||||
}
|
||||
|
||||
function toAccountPagePositionSections(
|
||||
portfolio: Portfolio,
|
||||
): AccountPagePositionSection[] {
|
||||
const denominators = new Map<string, number>();
|
||||
|
||||
for (const position of portfolio.positions) {
|
||||
if (!position.currentValue) continue;
|
||||
if (position.type === "CURRENCY" || !position.currentValue) continue;
|
||||
const currency = position.currentValue.currency;
|
||||
denominators.set(
|
||||
currency,
|
||||
|
||||
@@ -33,6 +33,7 @@ type AccountPageRecentOperations
|
||||
stale?: boolean;
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
totalVisible?: boolean;
|
||||
}
|
||||
| {
|
||||
state: "empty"
|
||||
@@ -84,6 +85,7 @@ function toRecentEvents(
|
||||
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
|
||||
state: "partial",
|
||||
to,
|
||||
totalVisible: false,
|
||||
};
|
||||
if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
|
||||
return { state: "error", to };
|
||||
@@ -94,6 +96,7 @@ function toRecentEvents(
|
||||
items,
|
||||
state: "loaded",
|
||||
to,
|
||||
totalVisible: events.meta?.completeness === "complete",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +112,12 @@ function toRecentOperations(
|
||||
)
|
||||
return { state: "loading", to };
|
||||
if (operations.meta?.completeness === "incomplete")
|
||||
return { items: operations.data?.items ?? [], state: "partial", to };
|
||||
return {
|
||||
items: operations.data?.items ?? [],
|
||||
state: "partial",
|
||||
to,
|
||||
totalVisible: false,
|
||||
};
|
||||
if (
|
||||
!operations.data
|
||||
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
|
||||
@@ -125,6 +133,7 @@ function toRecentOperations(
|
||||
stale: operations.meta?.freshness === "stale",
|
||||
state: "loaded",
|
||||
to,
|
||||
totalVisible: operations.meta?.completeness === "complete",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
|
||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
@@ -24,6 +28,7 @@ import type {
|
||||
AccountPageRecentOperations,
|
||||
} from "../../model/previews";
|
||||
|
||||
import { toConcentration } from "../../model/concentration";
|
||||
import {
|
||||
toAccountPagePositionSections,
|
||||
toUnrealizedReturns,
|
||||
@@ -34,6 +39,19 @@ import { AccountPageError } from "./components/AccountPageError.view";
|
||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
||||
|
||||
function AccountSwitcher() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate("/accounts/account-2")}
|
||||
type="button"
|
||||
>
|
||||
Другой счёт
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function configureTestAuth() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test-token",
|
||||
@@ -42,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") {
|
||||
configureTestAuth();
|
||||
|
||||
@@ -49,6 +71,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
|
||||
<AccountSwitcher />
|
||||
|
||||
<Routes>
|
||||
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
||||
</Routes>
|
||||
@@ -83,8 +107,10 @@ const portfolio
|
||||
displayName: "Вторая",
|
||||
figi: "FIGI-2",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: "vtoraya.png",
|
||||
quantity: "2",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
@@ -95,8 +121,10 @@ const portfolio
|
||||
displayName: "Первая",
|
||||
figi: "FIGI-1",
|
||||
isin: "RU000A0JX0J2",
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
@@ -115,8 +143,10 @@ function position(
|
||||
displayName: "Инструмент",
|
||||
figi: null,
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
...overrides,
|
||||
@@ -133,6 +163,19 @@ const emptyRecentEvents: AccountPageRecentEvents = {
|
||||
to: "/accounts/account-1/events",
|
||||
};
|
||||
|
||||
function concentrationLabels() {
|
||||
const i18n = getI18n();
|
||||
|
||||
return {
|
||||
missingIssuer: i18n.t("portfolio.concentration.missingIssuer"),
|
||||
missingSector: i18n.t("portfolio.concentration.missingSector"),
|
||||
other: i18n.t("portfolio.concentration.other"),
|
||||
sector: (code: string) => i18n.t(`stock.sectors.${code}`, {
|
||||
defaultValue: code,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyProps() {
|
||||
return {
|
||||
recentEvents: emptyRecentEvents,
|
||||
@@ -140,17 +183,388 @@ function emptyProps() {
|
||||
};
|
||||
}
|
||||
|
||||
const LoadedHarness = (
|
||||
props: Omit<Partial<Parameters<typeof AccountPageLoaded>[0]>, "onTabChange">,
|
||||
) => {
|
||||
const [tab, setTab] = useState(props.tab ?? "positions");
|
||||
|
||||
return (
|
||||
<AccountPageLoaded
|
||||
{...loadedProps()}
|
||||
{...props}
|
||||
onTabChange={value => setTab(value ?? "positions")}
|
||||
tab={tab}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||||
return {
|
||||
concentration: null,
|
||||
currency: null,
|
||||
onCurrencyChange: vi.fn(),
|
||||
onSliceChange: vi.fn(),
|
||||
portfolio: nextPortfolio,
|
||||
positionSections: toAccountPagePositionSections(nextPortfolio),
|
||||
recentEvents: emptyRecentEvents,
|
||||
recentOperations: emptyRecentOperations,
|
||||
slice: "stocks" as const,
|
||||
unrealizedReturns: toUnrealizedReturns(nextPortfolio.positions),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AccountPage states", () => {
|
||||
it("shows concentration only for a loaded individual account", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource({
|
||||
...portfolio,
|
||||
positions: [position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Акция",
|
||||
})],
|
||||
})),
|
||||
),
|
||||
);
|
||||
const client = createTestQueryClient();
|
||||
renderAccountRoute(client);
|
||||
const tab = await screen.findByRole("tab", { name: "Концентрация" });
|
||||
expect(tab).toHaveAttribute("aria-selected", "false");
|
||||
fireEvent.click(tab);
|
||||
expect(screen.getAllByText("100 ₽")).toHaveLength(2);
|
||||
expect(screen.getByRole("img", { name: "Распределение позиций" }))
|
||||
.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,
|
||||
cashBalances: [],
|
||||
isStale: false,
|
||||
positions: [],
|
||||
totalsByCurrency: [],
|
||||
},
|
||||
{
|
||||
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", () => {
|
||||
const positions = [400, 250, 150, 100, 50, 30, 20].map(
|
||||
(value, index) => position({
|
||||
currentValue: { currency: "RUB", value: String(value) },
|
||||
displayName: `Акция ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
|
||||
const data = { ...portfolio, positions };
|
||||
|
||||
const model = toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
);
|
||||
|
||||
const props = loadedProps(data);
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...props}
|
||||
accountId="a"
|
||||
concentration={model}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
const other = screen.getByRole("button", { name: /Прочее/ });
|
||||
expect(other).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(other);
|
||||
expect(other).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("Акция 7")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||
expect(props.onSliceChange).toHaveBeenCalledWith("sectors");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, "2 элемента"],
|
||||
[5, "5 элементов"],
|
||||
[21, "21 элемент"],
|
||||
])("pluralizes the other count %d as %s", (count, expected) => {
|
||||
const values = [400, 250, 150, 100, 50];
|
||||
|
||||
const rest = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => 20 + index,
|
||||
);
|
||||
|
||||
const positions = [...values, ...rest].map(
|
||||
(value, index) => position({
|
||||
currentValue: { currency: "RUB", value: String(value) },
|
||||
displayName: `Акция ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps({ ...portfolio, positions })}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
{ ...portfolio, positions },
|
||||
"stocks",
|
||||
"RUB",
|
||||
concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
const other = screen.getByRole("button", { name: /Прочее/ });
|
||||
expect(other).toHaveTextContent(expected);
|
||||
});
|
||||
|
||||
it("shows ticker subtitles and stocks rows with logos, issuers without", () => {
|
||||
const positions = [
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "300" },
|
||||
displayName: "Сбербанк",
|
||||
ticker: "SBER",
|
||||
}),
|
||||
];
|
||||
|
||||
const data = { ...portfolio, positions };
|
||||
|
||||
const view = renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("SBER")).toBeInTheDocument();
|
||||
|
||||
view.unmount();
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "issuers", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
slice="issuers"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("SBER")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the currency select when only one currency is available", () => {
|
||||
const data = { ...portfolio, positions: [position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Акция",
|
||||
})] };
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("combobox", { name: "Валюта концентрации" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows sector labels including government and unknown codes", () => {
|
||||
const positions = [
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "300" },
|
||||
displayName: "А",
|
||||
sector: "government",
|
||||
}),
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Б",
|
||||
sector: "unknown_code",
|
||||
}),
|
||||
];
|
||||
|
||||
const data = { ...portfolio, positions };
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "sectors", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
slice="sectors"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Государственный сектор")).toBeInTheDocument();
|
||||
expect(screen.getByText("unknown_code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("describes ranked segments with amounts and shares", () => {
|
||||
const data = { ...portfolio, positions: [
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "300" },
|
||||
displayName: "Первая акция",
|
||||
}),
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Вторая акция",
|
||||
}),
|
||||
...[50, 40, 30, 20, 10].map((value, index) => position({
|
||||
currentValue: { currency: "RUB", value: String(value) },
|
||||
displayName: `Остаток ${index + 1}`,
|
||||
})),
|
||||
] };
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chart = screen.getByRole("img", { name: "Распределение позиций" });
|
||||
|
||||
expect(chart).toHaveAccessibleDescription(
|
||||
/Первая акция: 300 ₽, 54,5 %; Вторая акция: 100 ₽, 18,2 %; Остаток 1: 50 ₽, 9,1 %; Остаток 2: 40 ₽, 7,3 %; Остаток 3: 30 ₽, 5,5 %; Прочее: 30 ₽, 5,5 %/,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not duplicate the partial warning on the concentration tab", () => {
|
||||
const data = { ...portfolio, positions: [position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Акция",
|
||||
})] };
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
partial
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole("status"))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps slice and currency while switching page tabs and resets on account change", async () => {
|
||||
const multiCurrency = { ...portfolio, positions:
|
||||
["RUB", "USD"].flatMap(currency =>
|
||||
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
|
||||
position({
|
||||
currentValue: { currency, value: String(value) },
|
||||
displayName: `${currency} акция ${index + 1}`,
|
||||
sector: "energy", type: "STOCK",
|
||||
}),
|
||||
),
|
||||
) };
|
||||
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource(multiCurrency)),
|
||||
),
|
||||
);
|
||||
const client = createTestQueryClient();
|
||||
renderAccountRoute(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||
.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("radio", { name: "Секторы" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Акции" }));
|
||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||
.toHaveAttribute("aria-expanded", "false");
|
||||
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: "Концентрация" }));
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Секторы" }))
|
||||
.toBeChecked();
|
||||
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
||||
.toHaveValue("USD");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
||||
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", () => {
|
||||
const data = { ...portfolio, positions: [
|
||||
position({
|
||||
currentValue: { currency: "USD", value: "200" },
|
||||
}),
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
}),
|
||||
] };
|
||||
|
||||
const model = toConcentration(
|
||||
data, "stocks", "EUR", concentrationLabels(),
|
||||
);
|
||||
|
||||
expect(model.currencies).toEqual(["RUB", "USD"]);
|
||||
expect(model.total).toBe("100");
|
||||
expect(model.items.map(item => item.amount)).toEqual(["100"]);
|
||||
});
|
||||
it("shows the loaded portfolio source sync status", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
@@ -305,7 +719,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
cashBalances: [
|
||||
@@ -489,14 +903,13 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/T-Bank временно недоступен/))
|
||||
.not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: "Облигации" }),
|
||||
@@ -527,7 +940,7 @@ describe("AccountPage states", () => {
|
||||
"renders %s logos, fallbacks and signed returns including zero",
|
||||
(type) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
@@ -570,7 +983,7 @@ describe("AccountPage states", () => {
|
||||
"groups %s supporting metrics into quiet single lines",
|
||||
(type) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [position({
|
||||
@@ -592,7 +1005,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
@@ -617,7 +1030,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [position({
|
||||
@@ -635,7 +1048,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("uses the cached account title when available", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
|
||||
account={{
|
||||
@@ -654,7 +1067,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("keeps the portfolio visible when recent operations fail", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
recentOperations={{
|
||||
state: "error",
|
||||
@@ -673,7 +1086,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("uses the ledger row for a recent outgoing trade operation", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
|
||||
recentOperations={{
|
||||
@@ -723,7 +1136,7 @@ describe("AccountPage states", () => {
|
||||
};
|
||||
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
|
||||
recentEvents={{
|
||||
@@ -792,7 +1205,7 @@ describe("AccountPage states", () => {
|
||||
"keeps the upcoming %s state and calendar action",
|
||||
(state) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
||||
/>,
|
||||
@@ -829,7 +1242,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("keeps the portfolio visible when upcoming events fail", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
recentEvents={{
|
||||
state: "error",
|
||||
@@ -846,6 +1259,110 @@ describe("AccountPage states", () => {
|
||||
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, totalsByCurrency: [{ 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,5 ₽")).toBeInTheDocument();
|
||||
// One cached read plus the three automatic attempts.
|
||||
expect(requests).toBe(4);
|
||||
|
||||
isFailing = false;
|
||||
fireEvent.click(retry);
|
||||
|
||||
expect(await screen.findByText("999 ₽")).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])(
|
||||
"preserves totals quietly through refresh error %i and success",
|
||||
async (status) => {
|
||||
@@ -898,7 +1415,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
expect(structure).toHaveTextContent("10 ₽");
|
||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||
});
|
||||
@@ -942,7 +1459,7 @@ describe("AccountPage states", () => {
|
||||
screen.getAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(["-0,5 $"]),
|
||||
);
|
||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на");
|
||||
expect(structure).not.toHaveTextContent("10 ₽");
|
||||
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
||||
expect(
|
||||
@@ -983,7 +1500,7 @@ describe("AccountPage states", () => {
|
||||
expect(
|
||||
screen.getAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(["1 234,5 ₽"]);
|
||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Загрузка" }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -994,7 +1511,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("opens positions by default and switches between local tabs", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Позиции" }))
|
||||
.toHaveAttribute("aria-selected", "true");
|
||||
@@ -1011,7 +1528,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("links operation, event and income previews to their detail pages", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
incomeSummary={{
|
||||
state: "empty",
|
||||
@@ -1032,7 +1549,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("filters positions by the kinds present in the portfolio", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||
|
||||
expect(
|
||||
screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value),
|
||||
@@ -1046,7 +1563,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("hides the position filter when only one kind exists", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [portfolio.positions[0]],
|
||||
@@ -1059,7 +1576,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("hides unrealized returns for a partial portfolio", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} partial />);
|
||||
renderPage(<LoadedHarness {...loadedProps()} partial />);
|
||||
|
||||
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
|
||||
.not.toBeInTheDocument();
|
||||
@@ -1067,7 +1584,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("omits an unrealized currency total when any return is incomplete", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
@@ -1084,4 +1601,72 @@ describe("AccountPage states", () => {
|
||||
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups overview items only after the existing five-item limits", async () => {
|
||||
const operations = Array.from({ length: 6 }, (_, index) => ({
|
||||
account: { id: `account-${index}`, name: `Счёт ${index + 1}` },
|
||||
accruedInterest: null,
|
||||
category: "income",
|
||||
commission: null,
|
||||
description: "Купон",
|
||||
figi: "FIGI",
|
||||
id: `operation-${index}`,
|
||||
instrumentType: "bond",
|
||||
instrumentUid: "UID",
|
||||
logoName: null,
|
||||
occurredAt: "2026-08-14T10:00:00Z",
|
||||
payment: { currency: "RUB", value: "100" },
|
||||
price: null,
|
||||
quantity: null,
|
||||
ticker: "BOND",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
})) satisfies BrokerageOperation[];
|
||||
|
||||
const events = Array.from({ length: 6 }, (_, index) => ({
|
||||
account: { id: `account-${index}`, name: `Счёт ${index + 1}` },
|
||||
date: "2026-08-20",
|
||||
dateKind: "event",
|
||||
displayName: "ОФЗ 26238",
|
||||
figi: "FIGI",
|
||||
instrumentUid: "UID",
|
||||
isin: "RU000A0JX0J2",
|
||||
logoName: null,
|
||||
projectedAmount: { currency: "RUB", value: "100" },
|
||||
ticker: "OFZ",
|
||||
type: "coupon",
|
||||
})) satisfies BrokerageEvent[];
|
||||
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(resource({
|
||||
accountId: "all",
|
||||
hasNext: true,
|
||||
items: operations.slice(0, 5),
|
||||
nextCursor: "next",
|
||||
}))),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json(resource({
|
||||
accountId: "all",
|
||||
calculatedAt: "2026-08-14T00:00:00Z",
|
||||
events,
|
||||
}))),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient(), "all");
|
||||
await screen.findByRole("heading", { level: 1, name: "Все счета" });
|
||||
|
||||
const operationGroup = screen.getByRole("list", {
|
||||
name: /Купон · BOND, Исполнено/,
|
||||
});
|
||||
|
||||
expect(screen.getByText("+500 ₽")).toBeInTheDocument();
|
||||
expect(within(operationGroup).getAllByRole("listitem")).toHaveLength(5);
|
||||
expect(within(operationGroup).queryByRole("link")).not.toBeInTheDocument();
|
||||
|
||||
const eventGroup = screen.getByRole("list", {
|
||||
name: /Купон · ОФЗ 26238, 20\.08\.2026, 500 ₽/,
|
||||
});
|
||||
|
||||
expect(screen.getAllByText("500 ₽").length).toBeGreaterThan(0);
|
||||
expect(within(eventGroup).getAllByRole("listitem")).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,48 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||
import { useAccountPageVm } from "./AccountPage.vm";
|
||||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||||
import { AccountPageError } from "./components/AccountPageError.view";
|
||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
||||
|
||||
const AccountPage: FC = () => {
|
||||
const viewModel = useAccountPageViewModel();
|
||||
if (viewModel.state === "loading") return <AccountPageLoading />;
|
||||
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
||||
if (viewModel.state === "empty")
|
||||
const vm = useAccountPageVm();
|
||||
if (vm.state === "loading") return <AccountPageLoading />;
|
||||
if (vm.state === "error") return <AccountPageError {...vm} />;
|
||||
if (vm.state === "empty")
|
||||
return (
|
||||
<AccountPageEmpty
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
incomeSummary={viewModel.incomeSummary}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
account={vm.account}
|
||||
accountId={vm.accountId}
|
||||
incomeSummary={vm.incomeSummary}
|
||||
readRetry={vm.readRetry}
|
||||
recentEvents={vm.recentEvents}
|
||||
recentOperations={vm.recentOperations}
|
||||
source={vm.source}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountPageLoaded
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
incomeSummary={viewModel.incomeSummary}
|
||||
partial={viewModel.partial}
|
||||
portfolio={viewModel.portfolio}
|
||||
positionSections={viewModel.positionSections}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
source={viewModel.source}
|
||||
unrealizedReturns={viewModel.unrealizedReturns}
|
||||
account={vm.account}
|
||||
accountId={vm.accountId}
|
||||
concentration={vm.concentration}
|
||||
currency={vm.currency}
|
||||
incomeSummary={vm.incomeSummary}
|
||||
onCurrencyChange={vm.onCurrencyChange}
|
||||
onSliceChange={vm.onSliceChange}
|
||||
onTabChange={vm.onTabChange}
|
||||
partial={vm.partial}
|
||||
portfolio={vm.portfolio}
|
||||
positionSections={vm.positionSections}
|
||||
readRetry={vm.readRetry}
|
||||
recentEvents={vm.recentEvents}
|
||||
recentOperations={vm.recentOperations}
|
||||
slice={vm.slice}
|
||||
source={vm.source}
|
||||
tab={vm.tab}
|
||||
unrealizedReturns={vm.unrealizedReturns}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import { portfolio } from "../../model/positions.fixtures";
|
||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||
import { useAccountPageVm } from "./AccountPage.vm";
|
||||
|
||||
function configureTestAuth() {
|
||||
configureAuthClient({
|
||||
@@ -102,12 +102,12 @@ function mockLoadedAccountPage() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("useAccountPageViewModel", () => {
|
||||
describe("useAccountPageVm", () => {
|
||||
it("prepares recent operations and events state for presentation views", async () => {
|
||||
configureTestAuth();
|
||||
mockLoadedAccountPage();
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
const { result } = renderHook(() => useAccountPageVm(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("useAccountPageViewModel", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
const { result } = renderHook(() => useAccountPageVm(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
@@ -194,7 +194,7 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
const { result } = renderHook(() => useAccountPageVm(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
@@ -232,7 +232,7 @@ it("keeps overview sections loaded without exposing technical retry", async () =
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
const { result } = renderHook(() => useAccountPageVm(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
@@ -14,9 +16,16 @@ import {
|
||||
import { useIncomeSummary } from "@/entities/income";
|
||||
import { usePortfolio } from "@/entities/portfolio";
|
||||
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 {
|
||||
type Concentration,
|
||||
concentrationCurrencies,
|
||||
type ConcentrationLabels,
|
||||
type Slice,
|
||||
toConcentration,
|
||||
} from "../../model/concentration";
|
||||
import {
|
||||
type AccountPagePositionSection,
|
||||
toAccountPagePositionSections,
|
||||
@@ -36,8 +45,10 @@ type AccountPageContent = {
|
||||
accountId?: string;
|
||||
incomeSummary: AccountPageIncomeSummary;
|
||||
partial?: boolean;
|
||||
readRetry: AccountPageReadRetry;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
source?: InvestmentResourceMeta;
|
||||
};
|
||||
|
||||
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
@@ -45,28 +56,47 @@ type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
};
|
||||
|
||||
type AccountPageLoadedViewModel = AccountPageContent & {
|
||||
concentration: Concentration | null;
|
||||
currency: null | string;
|
||||
onCurrencyChange: (currency: null | string) => void;
|
||||
onSliceChange: (slice: Slice) => void;
|
||||
onTabChange: (tab: null | string) => void;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
source?: InvestmentResourceMeta;
|
||||
slice: Slice;
|
||||
state: "loaded";
|
||||
tab: string;
|
||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
||||
};
|
||||
|
||||
type AccountPageReadRetry = ReturnType<typeof readRetry>;
|
||||
|
||||
type AccountPageViewModel
|
||||
= | AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel
|
||||
| { code?: string; state: "error" }
|
||||
| { code?: string; readRetry: AccountPageReadRetry; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
function isPortfolioAccessFailure(code?: string) {
|
||||
return isApiErrorCode(code, ApiErrorCode.Forbidden)
|
||||
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|
||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
||||
}
|
||||
|
||||
function useAccountPageViewModel(): AccountPageViewModel {
|
||||
function useAccountPageVm(): AccountPageViewModel {
|
||||
const { accountId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selection, setSelection] = useState<{
|
||||
accountId?: string;
|
||||
currency: null | string;
|
||||
slice: Slice;
|
||||
tab: string;
|
||||
}>({ accountId, currency: null, slice: "stocks", tab: "positions" });
|
||||
|
||||
const current = selection.accountId === accountId
|
||||
? selection
|
||||
: {
|
||||
accountId, currency: null, slice: "stocks" as const, tab: "positions",
|
||||
};
|
||||
|
||||
const updateSelection = (change: Partial<typeof selection>) =>
|
||||
setSelection({ ...current, ...change });
|
||||
|
||||
const portfolio = usePortfolio(accountId);
|
||||
|
||||
const operations = useBrokerageOperations(accountId, {
|
||||
@@ -86,7 +116,9 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
]);
|
||||
|
||||
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({
|
||||
hasContent: !!portfolio.data,
|
||||
@@ -95,12 +127,13 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
isFetching: portfolio.isFetching,
|
||||
});
|
||||
|
||||
if (isAccessFailure) return { code, state: "error" as const };
|
||||
if (isAccessFailure)
|
||||
return { code, readRetry: retry, state: "error" as const };
|
||||
|
||||
if (!portfolio.data)
|
||||
return {
|
||||
state: availability === "unavailable" ? "error" : "loading",
|
||||
} as const;
|
||||
return availability === "unavailable"
|
||||
? { code, readRetry: retry, state: "error" as const }
|
||||
: { state: "loading" as const };
|
||||
|
||||
const account = accounts.data?.find(item => item.id === accountId);
|
||||
const recentOperations = toRecentOperations(accountId, operations);
|
||||
@@ -117,26 +150,53 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
account,
|
||||
accountId,
|
||||
incomeSummary,
|
||||
readRetry: retry,
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
source: portfolio.meta,
|
||||
state: "empty" as const,
|
||||
};
|
||||
|
||||
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 = {
|
||||
missingIssuer: t("portfolio.concentration.missingIssuer"),
|
||||
missingSector: t("portfolio.concentration.missingSector"),
|
||||
other: t("portfolio.concentration.other"),
|
||||
sector: code => t(`stock.sectors.${code}`, { defaultValue: code }),
|
||||
};
|
||||
|
||||
const concentration = accountId !== "all"
|
||||
&& current.tab === "concentration"
|
||||
? toConcentration(portfolio.data, current.slice, currency, labels)
|
||||
: null;
|
||||
|
||||
return {
|
||||
account,
|
||||
accountId,
|
||||
concentration,
|
||||
currency,
|
||||
incomeSummary,
|
||||
onCurrencyChange: value => updateSelection({ currency: value }),
|
||||
onSliceChange: slice => updateSelection({ slice }),
|
||||
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
|
||||
partial: isPartial,
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||
readRetry: retry,
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
slice: current.slice,
|
||||
source: portfolio.meta,
|
||||
state: "loaded" as const,
|
||||
tab: current.tab,
|
||||
unrealizedReturns: toUnrealizedReturns(portfolio.data.positions),
|
||||
};
|
||||
}
|
||||
|
||||
export { useAccountPageViewModel };
|
||||
export { useAccountPageVm };
|
||||
|
||||
@@ -5,9 +5,10 @@ import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
||||
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
@@ -17,14 +18,17 @@ import type {
|
||||
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageEmptyProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
readRetry?: ReadRetryProps;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
source?: InvestmentResourceMeta;
|
||||
};
|
||||
|
||||
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
@@ -40,15 +44,29 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
<Stack>
|
||||
<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")} />
|
||||
|
||||
<PortfolioStructure
|
||||
structure={{ currencies: [], unvaluedCategories: [] }}
|
||||
/>
|
||||
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
<RecentOperations
|
||||
allAccounts={props.accountId === "all"}
|
||||
operations={props.recentOperations}
|
||||
/>
|
||||
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
<BrokerageEventsPreview
|
||||
allAccounts={props.accountId === "all"}
|
||||
events={props.recentEvents}
|
||||
/>
|
||||
|
||||
{!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />}
|
||||
</Stack>
|
||||
|
||||
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
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 { 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 (
|
||||
<StateView
|
||||
action={retry}
|
||||
text={t("states.unavailableText")}
|
||||
title={t("states.unavailableTitle")}
|
||||
/>
|
||||
|
||||
@@ -15,9 +15,9 @@ import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
||||
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
|
||||
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
@@ -27,27 +27,40 @@ import type {
|
||||
|
||||
import classes from "../AccountPage.module.css";
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { Concentration } from "./Concentration/Concentration.view";
|
||||
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
|
||||
import { PositionSection } from "./PositionSection.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageLoadedProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
concentration: ConcentrationModel | null;
|
||||
currency: null | string;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
onCurrencyChange: (currency: null | string) => void;
|
||||
onSliceChange: (slice: Slice) => void;
|
||||
onTabChange: (tab: null | string) => void;
|
||||
partial?: boolean;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
readRetry?: ReadRetryProps;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
slice: Slice;
|
||||
source?: InvestmentResourceMeta;
|
||||
tab: string;
|
||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
||||
};
|
||||
|
||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<string>();
|
||||
const tab = props.tab;
|
||||
|
||||
const onTabChange = props.onTabChange;
|
||||
|
||||
const activeSection = props.positionSections.find(
|
||||
section => section.kind === filter,
|
||||
@@ -74,8 +87,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
stale={props.source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
|
||||
|
||||
{props.partial
|
||||
? <Alert role="status">{t("investment.partial")}</Alert>
|
||||
? tab !== "concentration" && (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
)
|
||||
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
|
||||
|
||||
{!props.partial && props.unrealizedReturns.length > 0 && (
|
||||
@@ -107,14 +124,21 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
|
||||
<Tabs
|
||||
classNames={{ list: classes.tabsList }}
|
||||
defaultValue="positions"
|
||||
keepMounted={false}
|
||||
onChange={onTabChange}
|
||||
value={tab}
|
||||
>
|
||||
<Tabs.List aria-label={t("portfolio.sections")} mb="md">
|
||||
<Tabs.Tab value="positions">
|
||||
{t("portfolio.tabs.positions")}
|
||||
</Tabs.Tab>
|
||||
|
||||
{props.accountId !== "all" && (
|
||||
<Tabs.Tab value="concentration">
|
||||
{t("portfolio.concentration.title")}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
|
||||
<Tabs.Tab value="cash">{t("portfolio.tabs.cash")}</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab value="operations">
|
||||
@@ -143,12 +167,29 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{props.accountId !== "all" && (
|
||||
<Tabs.Panel value="concentration">
|
||||
{props.concentration && (
|
||||
<Concentration
|
||||
currency={props.currency}
|
||||
key={`${props.slice}-${props.currency}`}
|
||||
model={props.concentration}
|
||||
onCurrencyChange={props.onCurrencyChange}
|
||||
onSliceChange={props.onSliceChange}
|
||||
partial={!!props.partial}
|
||||
slice={props.slice}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
|
||||
<Tabs.Panel value="cash">
|
||||
<CurrencyCards amounts={props.portfolio.cashBalances} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="operations">
|
||||
<RecentOperations
|
||||
allAccounts={props.accountId === "all"}
|
||||
moreLabel={t("portfolio.more")}
|
||||
operations={props.recentOperations}
|
||||
/>
|
||||
@@ -156,6 +197,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
|
||||
<Tabs.Panel value="events">
|
||||
<BrokerageEventsPreview
|
||||
allAccounts={props.accountId === "all"}
|
||||
events={props.recentEvents}
|
||||
moreLabel={t("portfolio.more")}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr);
|
||||
gap: var(--mantine-spacing-xl);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart {
|
||||
position: relative;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.pattern {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
inset: 32%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: clamp(12px, 1.3vw, 17px);
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
min-height: 58px;
|
||||
border-bottom: 1px solid var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
.rank {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.identity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bar,
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
background: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.other {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: var(--mantine-color-gray-0);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.other:focus-visible {
|
||||
outline: 2px solid var(--mantine-color-blue-6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.slices { max-width: 100%; overflow-x: auto; }
|
||||
.other { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { DonutChart } from "@mantine/charts";
|
||||
import {
|
||||
Alert, Box, Group, Paper, SegmentedControl, Select, Stack,
|
||||
Text, Title,
|
||||
} from "@mantine/core";
|
||||
import { type FC, useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type {
|
||||
ConcentrationItem,
|
||||
Concentration as ConcentrationModel,
|
||||
Slice,
|
||||
} from "../../../../model/concentration";
|
||||
|
||||
import { PositionLogo } from "../PositionLogo.view";
|
||||
import classes from "./Concentration.module.css";
|
||||
|
||||
type Props = {
|
||||
currency: null | string;
|
||||
model: ConcentrationModel;
|
||||
onCurrencyChange: (currency: null | string) => void;
|
||||
onSliceChange: (slice: Slice) => void;
|
||||
partial: boolean;
|
||||
slice: Slice;
|
||||
};
|
||||
|
||||
const sliceLabels = {
|
||||
bonds: "portfolio.bonds",
|
||||
issuers: "portfolio.concentration.issuers",
|
||||
sectors: "portfolio.concentration.sectors",
|
||||
stocks: "portfolio.stocks",
|
||||
} as const;
|
||||
|
||||
type RowProps = {
|
||||
color: string;
|
||||
currency: string;
|
||||
index: number;
|
||||
item: ConcentrationItem;
|
||||
logo?: ConcentrationItem["logo"];
|
||||
name: string;
|
||||
};
|
||||
|
||||
const colors = [
|
||||
"#2563eb", "#18a999", "#f59e0b", "#8b5cf6", "#ef6c5b",
|
||||
"#64748b",
|
||||
];
|
||||
|
||||
const PERCENT = 100;
|
||||
const OTHER_START = 6;
|
||||
|
||||
function amountFor(item: ConcentrationItem, currency: string): string {
|
||||
return formatMoney({ currency, value: item.amount });
|
||||
}
|
||||
|
||||
const ConcentrationRow: FC<RowProps> = props => (
|
||||
<li className={classes.row}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classes.rank}
|
||||
style={{ backgroundColor: props.color }}
|
||||
>
|
||||
{props.index}
|
||||
</span>
|
||||
|
||||
{props.logo && <PositionLogo label={props.name} src={props.logo.src} />}
|
||||
|
||||
<span className={classes.identity}>
|
||||
<span>{props.name}</span>
|
||||
|
||||
{props.logo?.subtitle && (
|
||||
<Text c="dimmed" size="xs">{props.logo.subtitle}</Text>
|
||||
)}
|
||||
|
||||
<span aria-hidden="true" className={classes.bar}>
|
||||
<span style={{
|
||||
backgroundColor: props.color,
|
||||
width: `${props.item.chartValue * PERCENT}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className={classes.value}>
|
||||
<strong>{amountFor(props.item, props.currency)}</strong>
|
||||
|
||||
<Text c="dimmed" size="sm">{props.item.share}</Text>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
type TooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: { name?: number | string }[];
|
||||
rows: { amount: string; label: string; share: string }[];
|
||||
};
|
||||
|
||||
function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) {
|
||||
const index = Number(series.name);
|
||||
const item = rows.at(index);
|
||||
|
||||
return {
|
||||
stroke: "white",
|
||||
strokeWidth: 2,
|
||||
...((item?.unknown || (index === rows.length - 1
|
||||
&& item?.key === "__other"))
|
||||
&& { strokeDasharray: "3 2" }),
|
||||
};
|
||||
}
|
||||
|
||||
function rankLabel({ index }: { index: number; percent?: number }) {
|
||||
return String(index + 1);
|
||||
}
|
||||
|
||||
const ChartTooltip: FC<TooltipProps> = (props) => {
|
||||
const row = props.rows[Number(props.payload?.[0]?.name)];
|
||||
if (!row || !props.active) return;
|
||||
|
||||
return (
|
||||
<Paper p="sm" radius="md" shadow="sm">
|
||||
<Text fw={600} size="sm">{row.label}</Text>
|
||||
|
||||
<Text size="sm">{row.amount} · {row.share}</Text>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const Concentration: FC<Props> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const otherId = useId();
|
||||
const chartId = useId();
|
||||
const currency = props.currency ?? "";
|
||||
|
||||
const rows = props.model.other
|
||||
? [...props.model.items, props.model.other]
|
||||
: props.model.items;
|
||||
|
||||
const chartData = rows.map((item, index) => ({
|
||||
color: colors[index % colors.length] ?? colors[0] ?? "blue",
|
||||
name: String(index),
|
||||
value: item.chartValue,
|
||||
}));
|
||||
|
||||
const chartText = [
|
||||
props.model.top5Share && t("portfolio.concentration.top5", {
|
||||
share: props.model.top5Share,
|
||||
}),
|
||||
props.model.total && formatMoney({
|
||||
currency, value: props.model.total,
|
||||
}),
|
||||
...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`),
|
||||
].filter(Boolean).join("; ");
|
||||
|
||||
const slices = [
|
||||
{ label: t("portfolio.stocks"), value: "stocks" },
|
||||
{ label: t("portfolio.bonds"), value: "bonds" },
|
||||
{ label: t("portfolio.concentration.sectors"), value: "sectors" },
|
||||
{ label: t("portfolio.concentration.issuers"), value: "issuers" },
|
||||
];
|
||||
|
||||
// eslint-disable-next-line frontend/readability -- Mantine callback closes over page action.
|
||||
const selectSlice = (value: string) => props.onSliceChange(value as Slice);
|
||||
// eslint-disable-next-line frontend/readability -- Disclosure state belongs to this view.
|
||||
const toggleOther = () => setExpanded(value => !value);
|
||||
|
||||
// eslint-disable-next-line frontend/readability -- Chart callback closes over visible rows.
|
||||
const cellProps = (series: { name: string }) => chartCellProps(series, rows);
|
||||
const pieProps = { isAnimationActive: false, label: rankLabel };
|
||||
|
||||
const tooltipRows = rows.map(item => ({
|
||||
amount: amountFor(item, currency),
|
||||
label: item.label,
|
||||
share: item.share,
|
||||
}));
|
||||
|
||||
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
|
||||
|
||||
const totalText = props.model.total
|
||||
? formatMoney({ currency, value: props.model.total })
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group align="end" justify="space-between">
|
||||
<SegmentedControl
|
||||
aria-label={t("portfolio.concentration.slice")}
|
||||
className={classes.slices}
|
||||
data={slices}
|
||||
onChange={selectSlice}
|
||||
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>
|
||||
|
||||
{props.partial && (
|
||||
<Alert role="status">{t("portfolio.concentration.incomplete")}</Alert>
|
||||
)}
|
||||
|
||||
{props.model.empty
|
||||
? (
|
||||
<Stack gap="xs">
|
||||
<Text fw={600}>{t(sliceLabels[props.slice])}
|
||||
{props.currency && ` · ${props.currency}`}
|
||||
</Text>
|
||||
|
||||
<Text>{t("portfolio.concentration.empty")}</Text>
|
||||
</Stack>
|
||||
)
|
||||
: (
|
||||
<Paper className={classes.layout} p="lg" radius="md" withBorder>
|
||||
<Box className={classes.chart}>
|
||||
<DonutChart
|
||||
aria-describedby={chartId}
|
||||
aria-label={t("portfolio.concentration.chart")}
|
||||
cellProps={cellProps}
|
||||
data={chartData}
|
||||
pieProps={pieProps}
|
||||
role="img"
|
||||
size={260}
|
||||
strokeWidth={24}
|
||||
tooltipDataSource="segment"
|
||||
tooltipProps={tooltipProps}
|
||||
/>
|
||||
|
||||
<div aria-hidden="true" className={classes.center}>
|
||||
{props.model.top5Share && (
|
||||
<Text fw={700} size="sm">{t("portfolio.concentration.top5", {
|
||||
share: props.model.top5Share,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text fw={700}>{totalText}</Text>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
className={classes.srOnly}
|
||||
id={chartId}
|
||||
>
|
||||
{chartText}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={3} size="md">{t("portfolio.concentration.title")}</Title>
|
||||
|
||||
<ol className={classes.list}>
|
||||
{props.model.items.map((item, index) => (
|
||||
<ConcentrationRow
|
||||
color={colors[index % colors.length] ?? "blue"}
|
||||
currency={currency}
|
||||
index={index + 1}
|
||||
item={item}
|
||||
key={item.key}
|
||||
logo={item.logo}
|
||||
name={item.label}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{props.model.other && (
|
||||
<>
|
||||
<button
|
||||
aria-controls={otherId}
|
||||
aria-expanded={expanded}
|
||||
className={classes.other}
|
||||
onClick={toggleOther}
|
||||
type="button"
|
||||
>
|
||||
<span>{t("portfolio.concentration.other")} · {t(
|
||||
"portfolio.concentration.otherCount",
|
||||
{ count: props.model.other.count },
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span>{amountFor(props.model.other, currency)} · {
|
||||
props.model.other.share
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ol
|
||||
className={classes.list}
|
||||
hidden={!expanded}
|
||||
id={otherId}
|
||||
start={OTHER_START}
|
||||
>
|
||||
{props.model.other.entries.map((item, index) => (
|
||||
<ConcentrationRow
|
||||
color={colors[(index + OTHER_START) % colors.length]
|
||||
?? "blue"}
|
||||
currency={currency}
|
||||
index={index + OTHER_START}
|
||||
item={item}
|
||||
key={item.key}
|
||||
logo={item.logo}
|
||||
name={item.label}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{props.model.unaccounted > 0 && (
|
||||
<Text c="dimmed" size="sm">{t(
|
||||
"portfolio.concentration.unaccounted",
|
||||
{ count: props.model.unaccounted },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { Concentration };
|
||||
+1
-1
@@ -28,7 +28,7 @@ const meta = {
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
title: "Widgets/PortfolioStructure",
|
||||
title: "Product/PortfolioStructure",
|
||||
} satisfies Meta<typeof PortfolioStructure>;
|
||||
|
||||
export default meta;
|
||||
@@ -12,6 +12,7 @@ const syntheticPosition
|
||||
displayName: "Учебные технологии",
|
||||
figi: null,
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
logoSrc: null,
|
||||
quantity: "100",
|
||||
@@ -19,6 +20,7 @@ const syntheticPosition
|
||||
returnPercentText: "+25,00 %",
|
||||
returnText: "+5 000 ₽",
|
||||
returnTone: "positive",
|
||||
sector: null,
|
||||
shareText: "20%",
|
||||
ticker: "DEMO",
|
||||
type: "STOCK",
|
||||
|
||||
@@ -5,11 +5,17 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import {
|
||||
brokerageOperationGroupKey,
|
||||
LedgerEntryRow,
|
||||
OperationGroup,
|
||||
} from "@/entities/broker-operation";
|
||||
import { groupItems } from "@/shared/lib";
|
||||
|
||||
import type { AccountPageRecentOperations } from "../../../model/previews";
|
||||
|
||||
type RecentOperationsProps = {
|
||||
allAccounts?: boolean;
|
||||
moreLabel?: string;
|
||||
operations: AccountPageRecentOperations;
|
||||
};
|
||||
@@ -32,17 +38,41 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
|
||||
{operations.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
|
||||
|
||||
{(operations.state === "loaded" || operations.state === "partial")
|
||||
&& operations.items.map(operation => (
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
operation.account?.id,
|
||||
operation.id,
|
||||
operation.logoName,
|
||||
])}
|
||||
operation={operation}
|
||||
/>
|
||||
))}
|
||||
{(operations.state === "loaded" || operations.state === "partial") && (
|
||||
<Stack
|
||||
component="ul"
|
||||
gap="sm"
|
||||
m={0}
|
||||
p={0}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
{groupItems(
|
||||
operations.items.map(operation => ({
|
||||
key: JSON.stringify([
|
||||
operation.account?.id,
|
||||
operation.id,
|
||||
operation.logoName,
|
||||
]),
|
||||
operation,
|
||||
})),
|
||||
entry => props.allAccounts
|
||||
? brokerageOperationGroupKey(entry.operation)
|
||||
: null,
|
||||
).map(entry => entry.type === "group"
|
||||
? (
|
||||
<OperationGroup
|
||||
entries={entry.items}
|
||||
key={entry.key}
|
||||
totalVisible={operations.totalVisible === true}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<li key={entry.item.key}>
|
||||
<LedgerEntryRow operation={entry.item.operation} />
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Button component={Link} to={operations.to} variant="subtle">
|
||||
{props.moreLabel ?? t("ledger.open")}
|
||||
|
||||
@@ -37,12 +37,14 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
|
||||
displayName: `Инструмент ${index}`,
|
||||
figi: null,
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
logoSrc: null,
|
||||
quantity: "10",
|
||||
returnPercent: null,
|
||||
returnPercentText: null,
|
||||
returnText: null,
|
||||
sector: null,
|
||||
shareText: null,
|
||||
ticker: null,
|
||||
type: kind === "stocks" ? "STOCK" : otherType,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
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 { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -261,6 +261,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)(
|
||||
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
|
||||
async (state) => {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useAccountsPageViewModel } from "./AccountsPage.vm";
|
||||
import { useAccountsPageVm } from "./AccountsPage.vm";
|
||||
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
||||
import { AccountsPageError } from "./components/AccountsPageError.view";
|
||||
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
|
||||
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
|
||||
|
||||
const AccountsPage: FC = () => {
|
||||
const viewModel = useAccountsPageViewModel();
|
||||
if (viewModel.state === "loading") return <AccountsPageLoading />;
|
||||
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
|
||||
if (viewModel.state === "empty") return <AccountsPageEmpty />;
|
||||
const vm = useAccountsPageVm();
|
||||
if (vm.state === "loading") return <AccountsPageLoading />;
|
||||
if (vm.state === "error") return <AccountsPageError {...vm} />;
|
||||
if (vm.state === "empty") return <AccountsPageEmpty />;
|
||||
|
||||
return (
|
||||
<AccountsPageLoaded
|
||||
accounts={viewModel.accounts}
|
||||
meta={viewModel.meta}
|
||||
accounts={vm.accounts}
|
||||
meta={vm.meta}
|
||||
readRetry={vm.readRetry}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,25 +4,38 @@ import {
|
||||
useBrokerageAccounts,
|
||||
} from "@/entities/brokerage-account";
|
||||
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";
|
||||
|
||||
type AccountsPageReadRetry = ReturnType<typeof readRetry>;
|
||||
|
||||
type AccountsPageViewModel
|
||||
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
|
||||
| { code?: string; state: "error" }
|
||||
| { state: "empty" }
|
||||
| { state: "loading" };
|
||||
= | {
|
||||
accounts: BrokerageAccountCard[];
|
||||
meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>;
|
||||
readRetry: AccountsPageReadRetry;
|
||||
state: "loaded";
|
||||
}
|
||||
| { code?: string; readRetry: AccountsPageReadRetry; state: "error" }
|
||||
| { state: "empty" }
|
||||
| { state: "loading" };
|
||||
|
||||
function isAccountsAccessFailure(code?: string) {
|
||||
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
||||
}
|
||||
|
||||
function useAccountsPageViewModel(): AccountsPageViewModel {
|
||||
function useAccountsPageVm(): AccountsPageViewModel {
|
||||
const accounts = useBrokerageAccounts();
|
||||
useScreenSync({ screen: "accounts" }, [accounts]);
|
||||
|
||||
const code = apiErrorCode(accounts.error);
|
||||
const retry = readRetry([accounts]);
|
||||
const isAccessFailure = isAccountsAccessFailure(code);
|
||||
|
||||
const hasResult = !!accounts.data
|
||||
@@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
||||
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)
|
||||
return {
|
||||
state: availability === "unavailable" ? "error" : "loading",
|
||||
} as const;
|
||||
return availability === "unavailable"
|
||||
? { code, readRetry: retry, state: "error" as const }
|
||||
: { state: "loading" as const };
|
||||
|
||||
if (accounts.data.length === 0)
|
||||
return {
|
||||
@@ -54,8 +68,9 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
||||
return {
|
||||
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
|
||||
meta,
|
||||
readRetry: retry,
|
||||
state: "loaded" as const,
|
||||
};
|
||||
}
|
||||
|
||||
export { useAccountsPageViewModel };
|
||||
export { useAccountsPageVm };
|
||||
|
||||
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
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 { 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 (
|
||||
<StateView
|
||||
action={retry}
|
||||
text={t("states.unavailableText")}
|
||||
title={t("states.unavailableTitle")}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
BrokerageAccountList,
|
||||
} from "@/entities/brokerage-account";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
const DECIMAL_BASE = 10n;
|
||||
const DECIMAL_PART_COUNT = 2;
|
||||
@@ -20,6 +20,7 @@ const ZERO = 0n;
|
||||
type AccountsPageLoadedProps = {
|
||||
accounts: BrokerageAccountCard[];
|
||||
meta: InvestmentResourceMeta;
|
||||
readRetry?: ReadRetryProps;
|
||||
};
|
||||
|
||||
type Money = { currency: string; value: string };
|
||||
@@ -132,6 +133,8 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||
lastSuccessfulAt={props.meta.lastSuccessfulAt}
|
||||
stale={props.meta.freshness === "stale"}
|
||||
/>
|
||||
|
||||
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -347,7 +347,6 @@ describe("bond page", () => {
|
||||
it("keeps market content and sold-position history when coupons fail", async () => {
|
||||
setup({ failCoupons: true, noPosition: true });
|
||||
await screen.findByText("98,125 %");
|
||||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -355,6 +354,22 @@ describe("bond page", () => {
|
||||
screen.getByRole("heading", { name: "Моя история событий" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
it(
|
||||
"offers a local retry for the failed coupons section only",
|
||||
{ timeout: 10_000 },
|
||||
async () => {
|
||||
setup({ failCoupons: true });
|
||||
await screen.findByText("98,125 %");
|
||||
// Automatic read retries wait 1s + 2s before the failure is final.
|
||||
await screen.findByRole(
|
||||
"button",
|
||||
{ name: "Повторить" },
|
||||
{ timeout: 6000 },
|
||||
);
|
||||
expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1);
|
||||
expect(screen.getByText("98,125 %")).toBeVisible();
|
||||
},
|
||||
);
|
||||
it("preserves cached quote without manual refresh", async () => {
|
||||
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
|
||||
await screen.findByText("98,125 %");
|
||||
|
||||
@@ -120,11 +120,52 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
body: { ...body, version },
|
||||
id: props.operation.id,
|
||||
});
|
||||
else
|
||||
await operationCreation.mutateAsync({
|
||||
else {
|
||||
const base = {
|
||||
amount: body.amount,
|
||||
cardId: values.cardId,
|
||||
date: values.date,
|
||||
description: values.description,
|
||||
id: intent,
|
||||
...body,
|
||||
});
|
||||
};
|
||||
|
||||
switch (values.kind) {
|
||||
case "expense": {
|
||||
await operationCreation.mutateAsync({
|
||||
...base,
|
||||
categoryId: values.categoryId || defaultCategoryId,
|
||||
kind: values.kind,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "refund": {
|
||||
await operationCreation.mutateAsync({
|
||||
...base,
|
||||
kind: values.kind,
|
||||
originalExpenseId: values.originalExpenseId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "topup": {
|
||||
await operationCreation.mutateAsync({
|
||||
...base,
|
||||
kind: values.kind,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "transfer": {
|
||||
await operationCreation.mutateAsync({
|
||||
...base,
|
||||
kind: values.kind,
|
||||
targetCardId: values.targetCardId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
id: "bank-operation-saved-" + actionId,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useDocumentTitle } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AuthPageLayout, LoginForm } from "@/features/auth";
|
||||
import { useAppDocumentTitle } from "@/shared/ui";
|
||||
|
||||
const LoginPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
useDocumentTitle(`${t("auth.login")} — ${t("appName")}`);
|
||||
useAppDocumentTitle(t("auth.login"));
|
||||
|
||||
return (
|
||||
<AuthPageLayout title={t("auth.login")}>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Button, Stack, Title } from "@mantine/core";
|
||||
import { useDocumentTitle } from "@mantine/hooks";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { useAppDocumentTitle } from "@/shared/ui";
|
||||
|
||||
const NotFoundPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`);
|
||||
useAppDocumentTitle(t("notFound.title"));
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useDocumentTitle } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AuthPageLayout, RegisterForm } from "@/features/auth";
|
||||
import { useAppDocumentTitle } from "@/shared/ui";
|
||||
|
||||
const RegisterPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
useDocumentTitle(`${t("auth.register")} — ${t("appName")}`);
|
||||
useAppDocumentTitle(t("auth.register"));
|
||||
|
||||
return (
|
||||
<AuthPageLayout title={t("auth.register")}>
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Button, Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { TbankConnectionSection } from "@/features/tbank-connection";
|
||||
|
||||
import { useSettingsPageVm } from "./SettingsPage.vm";
|
||||
|
||||
const SettingsPage: FC = () => {
|
||||
const { onLogout, t } = useSettingsPageVm();
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useAuth();
|
||||
|
||||
// eslint-disable-next-line frontend/readability -- the page VM was removed as degenerate (frontend#143); a one-off error-swallowing logout is not a ViewModel.
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
}
|
||||
catch {
|
||||
// The session is already cleared; the route guard owns navigation.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAuth } from "@/features/auth";
|
||||
|
||||
function useSettingsPageVm() {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
}
|
||||
catch {
|
||||
// The session is already cleared; the route guard owns navigation.
|
||||
}
|
||||
};
|
||||
|
||||
return { onLogout, t };
|
||||
}
|
||||
|
||||
export { useSettingsPageVm };
|
||||
@@ -81,6 +81,7 @@ function setup(
|
||||
candles?: NonNullable<StockPageVm["candles"]>["candles"];
|
||||
cursorError?: string;
|
||||
failCompany?: boolean;
|
||||
failCompanyAfter?: { value: boolean };
|
||||
failStock?: boolean;
|
||||
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
|
||||
noPosition?: boolean;
|
||||
@@ -133,7 +134,7 @@ function setup(
|
||||
),
|
||||
);
|
||||
if (params.section === "company")
|
||||
return options.failCompany
|
||||
return (options.failCompanyAfter?.value ?? options.failCompany)
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(
|
||||
|
||||
@@ -396,7 +397,6 @@ describe("stock page", () => {
|
||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||
});
|
||||
it("keeps successful sections visible when company data is initially missing", async () => {
|
||||
const { requests } = setup({ failCompany: true });
|
||||
@@ -404,8 +404,39 @@ describe("stock page", () => {
|
||||
const previousRequests = requests.length;
|
||||
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
|
||||
expect(requests).toHaveLength(previousRequests);
|
||||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||
});
|
||||
it(
|
||||
"retries only the failed company section and recovers it in place",
|
||||
{ timeout: 10_000 },
|
||||
async () => {
|
||||
const failing = { value: true };
|
||||
const { requests } = setup({ failCompanyAfter: failing });
|
||||
await screen.findByText("Зачисление дивидендов");
|
||||
|
||||
// Automatic read retries wait 1s + 2s before the failure is final.
|
||||
const retry = await screen.findByRole(
|
||||
"button",
|
||||
{ name: "Повторить" },
|
||||
{ timeout: 6000 },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
|
||||
expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1);
|
||||
const before = requests.length;
|
||||
const companyBefore = requests.filter(url => url.pathname.endsWith("/company")).length;
|
||||
|
||||
failing.value = false;
|
||||
fireEvent.click(retry);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||
.not.toBeInTheDocument());
|
||||
expect(requests.filter(url => url.pathname.endsWith("/company")))
|
||||
.toHaveLength(companyBefore + 1);
|
||||
expect(requests.length - before).toBe(1);
|
||||
expect(screen.getByText("Описание компании")).toBeVisible();
|
||||
},
|
||||
);
|
||||
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
|
||||
const { requests } = setup();
|
||||
await screen.findByText("Зачисление дивидендов");
|
||||
|
||||
@@ -6,10 +6,7 @@ import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import {
|
||||
useAuthenticatedQueries,
|
||||
useAuthenticatedQuery,
|
||||
} from "./authenticated-query";
|
||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||
import { apiQueryClient, configureAuthClient } from "./client";
|
||||
|
||||
const useSingleQuery = () =>
|
||||
@@ -17,58 +14,49 @@ const useSingleQuery = () =>
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
);
|
||||
|
||||
const useMultipleQueries = () =>
|
||||
useAuthenticatedQueries([
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
])[0];
|
||||
|
||||
describe("useAuthenticatedQuery", () => {
|
||||
it.each(["single", "multiple"])(
|
||||
"keeps %s protected queries idle without a session and loads after session configuration",
|
||||
async (mode) => {
|
||||
const requests = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests();
|
||||
it("keeps protected queries idle without a session and loads after session configuration", async () => {
|
||||
const requests = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests();
|
||||
|
||||
return HttpResponse.json(
|
||||
resource([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
openedOn: "2024-08-13",
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return HttpResponse.json(
|
||||
resource([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
openedOn: "2024-08-13",
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
mode === "single" ? useSingleQuery : useMultipleQueries,
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
const { result } = renderHook(useSingleQuery, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(requests).not.toHaveBeenCalled();
|
||||
expect(result.current?.fetchStatus).toBe("idle");
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(requests).not.toHaveBeenCalled();
|
||||
expect(result.current?.fetchStatus).toBe("idle");
|
||||
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current?.isSuccess).toBe(true));
|
||||
expect(requests).toHaveBeenCalledOnce();
|
||||
expect(result.current?.data?.data).toEqual([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
openedOn: "2024-08-13",
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
await waitFor(() => expect(result.current?.isSuccess).toBe(true));
|
||||
expect(requests).toHaveBeenCalledOnce();
|
||||
expect(result.current?.data?.data).toEqual([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
openedOn: "2024-08-13",
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type QueryKey,
|
||||
useQueries,
|
||||
useQuery,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
@@ -12,25 +11,6 @@ import {
|
||||
subscribeAuthenticatedSession,
|
||||
} from "./client";
|
||||
|
||||
function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
|
||||
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
|
||||
) {
|
||||
const hasSession = useHasAuthenticatedSession();
|
||||
|
||||
return useQueries({
|
||||
queries: queries.map(
|
||||
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
|
||||
...options,
|
||||
enabled: query =>
|
||||
hasSession
|
||||
&& (typeof options.enabled === "function"
|
||||
? options.enabled(query)
|
||||
: (options.enabled ?? true)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useAuthenticatedQuery<
|
||||
TQueryFunctionData = unknown,
|
||||
TError = Error,
|
||||
@@ -58,4 +38,4 @@ function useHasAuthenticatedSession() {
|
||||
);
|
||||
}
|
||||
|
||||
export { useAuthenticatedQueries, useAuthenticatedQuery };
|
||||
export { useAuthenticatedQuery };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user