diff --git a/.dockerignore b/.dockerignore
index bdbb86c..653fc9b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -13,6 +13,9 @@
!public/**
!docker/
!docker/**
+!scripts/
+!scripts/minimatch-compat/
+!scripts/minimatch-compat/**
!packages/
!packages/tokens/
!packages/tokens/package.json
diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml
index bbef2a1..518324b 100644
--- a/.gitea/workflows/verify.yml
+++ b/.gitea/workflows/verify.yml
@@ -66,7 +66,7 @@ jobs:
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
- run: npm audit --include=dev --audit-level=low
+ run: npm audit --include=dev --audit-level=moderate
verify:
needs: [commits, security]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0d8863..8dd80e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
# Changelog
+## [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
diff --git a/Dockerfile b/Dockerfile
index 2795607..31c3f57 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index c136094..7ea5714 100644
--- a/README.md
+++ b/README.md
@@ -123,10 +123,12 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
-`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
-исключений frontend нет. Локально можно выполнить те же команды:
+`npm audit --include=dev --audit-level=moderate`. Advisory уровня moderate и выше
+блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии
+(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет.
+Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
-`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
+`npm audit --include=dev --audit-level=moderate`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
diff --git a/index.html b/index.html
index e33e6a4..42dc759 100644
--- a/index.html
+++ b/index.html
@@ -5,7 +5,7 @@
-
+
WealthLedger
diff --git a/knip.json b/knip.json
index 5f5b315..a31ed98 100644
--- a/knip.json
+++ b/knip.json
@@ -1,5 +1,5 @@
{
- "ignoreDependencies": ["conventional-changelog-conventionalcommits"],
+ "ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
"workspaces": {
".": {
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
diff --git a/package-lock.json b/package-lock.json
index ff08565..d9dd952 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "wealth-ledger-frontend",
- "version": "1.2.1",
+ "version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wealth-ledger-frontend",
- "version": "1.2.1",
+ "version": "1.3.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"
+ }
}
}
}
diff --git a/package.json b/package.json
index 168e6da..434f528 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "wealth-ledger-frontend",
"private": true,
- "version": "1.2.1",
+ "version": "1.3.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,
diff --git a/packages/ui/src/EntryCard/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx
index 973c8ef..76e5f66 100644
--- a/packages/ui/src/EntryCard/EntryCard.tsx
+++ b/packages/ui/src/EntryCard/EntryCard.tsx
@@ -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 = (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 (
= (props) => {
-
+
{props.value}
diff --git a/scripts/minimatch-compat/index.cjs b/scripts/minimatch-compat/index.cjs
new file mode 100644
index 0000000..f1e6908
--- /dev/null
+++ b/scripts/minimatch-compat/index.cjs
@@ -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),
+ }),
+};
diff --git a/scripts/minimatch-compat/index.js b/scripts/minimatch-compat/index.js
new file mode 100644
index 0000000..eeab204
--- /dev/null
+++ b/scripts/minimatch-compat/index.js
@@ -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),
+});
diff --git a/scripts/minimatch-compat/package.json b/scripts/minimatch-compat/package.json
new file mode 100644
index 0000000..9e0832a
--- /dev/null
+++ b/scripts/minimatch-compat/package.json
@@ -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"
+ }
+}
diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx
index a7dacf9..0363249 100644
--- a/src/app/routes/AppRoutes/AllAccounts.test.tsx
+++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx
@@ -11,7 +11,7 @@ import {
} from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, useLocation } from "react-router";
-import { beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { IncomeAnalytics } from "@/entities/income";
@@ -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",
};
@@ -145,6 +147,7 @@ function renderPage(path: string) {
return client;
}
+afterEach(() => vi.useRealTimers());
beforeEach(() => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
@@ -271,6 +274,8 @@ describe("All accounts routes", () => {
);
it("restores applied dates when switching accounts in events", async () => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-10-15T12:00:00Z"));
// The events calendar blocks past dates, so pick within selectable dates.
const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`;
renderPage(`/accounts/all/events${search}`);
@@ -303,14 +308,16 @@ describe("All accounts routes", () => {
"href",
`/accounts/${account.id}`,
);
- for (const suffix of ["operations", "income", "events"])
- expect(
- screen
- .getAllByRole("link")
- .find(
- link => link.getAttribute("href") === `/accounts/all/${suffix}`,
- ),
- ).toBeInTheDocument();
+
+ for (const [suffix, tab] of [
+ ["operations", "Операции"],
+ ["income", "Доход"],
+ ["events", "События"],
+ ]) {
+ fireEvent.click(screen.getByRole("tab", { name: tab }));
+ expect(screen.getByRole("link", { name: "Подробнее" }))
+ .toHaveAttribute("href", `/accounts/all/${suffix}`);
+ }
});
it("switches accounts within operations with applied filters and a fresh cursor", async () => {
diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx
index ca97244..7707151 100644
--- a/src/app/routes/AppRoutes/AppRoutes.test.tsx
+++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx
@@ -232,7 +232,8 @@ describe("AppRoutes", () => {
}),
).toBeInTheDocument();
expect(
- screen.getByRole("navigation", { name: "WealthLedger" }),
- ).toBeInTheDocument();
+ screen.getAllByRole("navigation", { name: "Основная навигация" }),
+ ).toHaveLength(2);
+ expect(screen.queryByRole("banner")).not.toBeInTheDocument();
});
});
diff --git a/src/app/ui/AppShell/AppShell.module.css b/src/app/ui/AppShell/AppShell.module.css
index 2baaffc..6d463bf 100644
--- a/src/app/ui/AppShell/AppShell.module.css
+++ b/src/app/ui/AppShell/AppShell.module.css
@@ -1,12 +1,22 @@
+.root {
+ --bottom-nav-height: 56px;
+}
+
.content {
max-width: 1120px;
margin-inline: auto;
}
-.header {
+.brand {
align-items: center;
+ border-bottom: 1px solid var(--mantine-color-gray-3);
display: flex;
- padding: 0 var(--mantine-spacing-md);
+ min-height: 60px;
+ padding-inline: var(--mantine-spacing-sm);
+}
+
+.navList {
+ padding: var(--mantine-spacing-sm);
}
.bottomNav {
@@ -52,29 +62,85 @@
.bottomNav {
background: var(--mantine-color-white);
border-top: 1px solid var(--mantine-color-gray-3);
- bottom: 0;
- display: flex;
- justify-content: space-around;
- left: 0;
- padding: var(--mantine-spacing-xs) 2px;
- gap: 2px;
- font-size: 11px;
- & a {
- min-width: 0;
- padding: 8px 2px;
- text-align: center;
- }
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ inset: auto 0 0;
+ min-height: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ padding-inline:
+ max(4px, env(safe-area-inset-left, 0px))
+ max(4px, env(safe-area-inset-right, 0px));
position: fixed;
- right: 0;
- z-index: 200;
+ z-index: var(--mantine-z-index-app);
+ }
+
+ .bottomNavItem {
+ align-items: center;
+ color: var(--mantine-color-gray-7);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ justify-content: center;
+ min-height: var(--bottom-nav-height);
+ min-width: 0;
+ text-decoration: none;
+
+ &:active .iconPill {
+ background-color: var(--mantine-color-gray-1);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--mantine-primary-color-filled);
+ outline-offset: -2px;
+ }
+ }
+
+ @media (hover: hover) {
+ .bottomNavItem:hover .iconPill {
+ background-color: var(--mantine-color-gray-1);
+ }
+ }
+
+ .iconPill {
+ align-items: center;
+ border-radius: 14px;
+ display: inline-flex;
+ height: 28px;
+ justify-content: center;
+ width: 48px;
+ }
+
+ .bottomNavLabel {
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 16px;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ .bottomNavItem,
+ .iconPill {
+ transition:
+ background-color 150ms ease-in-out,
+ color 150ms ease-in-out;
+ }
}
.active {
color: var(--mantine-primary-color-filled);
- font-weight: 600;
+
+ & .iconPill {
+ background-color: var(--mantine-primary-color-light);
+ }
}
.main {
- padding-bottom: 5rem;
+ padding-bottom: calc(
+ var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px) +
+ var(--mantine-spacing-md)
+ );
}
}
diff --git a/src/app/ui/AppShell/AppShell.test.tsx b/src/app/ui/AppShell/AppShell.test.tsx
new file mode 100644
index 0000000..307f2a0
--- /dev/null
+++ b/src/app/ui/AppShell/AppShell.test.tsx
@@ -0,0 +1,47 @@
+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 { AppShell } from "./AppShell";
+
+function renderShell(path: string) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("AppShell", () => {
+ it("has no header banner", () => {
+ renderShell("/cards");
+
+ expect(screen.queryByRole("banner")).not.toBeInTheDocument();
+ });
+
+ it("marks only the current bottom navigation item", () => {
+ renderShell("/cards");
+
+ const bottomNav = screen
+ .getAllByRole("navigation", { name: "Основная навигация" })
+ .at(-1);
+
+ if (!bottomNav) {
+ throw new Error("Bottom navigation is not rendered");
+ }
+
+ const links = within(bottomNav).getAllByRole("link");
+
+ expect(links).toHaveLength(5);
+ expect(within(bottomNav).getByRole("link", { name: "Карты" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(
+ within(bottomNav).getByRole("link", { name: "Счета" }),
+ ).not.toHaveAttribute("aria-current");
+ });
+});
diff --git a/src/app/ui/AppShell/AppShell.tsx b/src/app/ui/AppShell/AppShell.tsx
index b498395..aee1778 100644
--- a/src/app/ui/AppShell/AppShell.tsx
+++ b/src/app/ui/AppShell/AppShell.tsx
@@ -17,6 +17,12 @@ type AppShellProps = {
contentAboveOutlet?: ReactNode;
};
+function getBottomNavItemClass({ isActive }: { isActive: boolean }) {
+ return isActive
+ ? `${classes.bottomNavItem} ${classes.active}`
+ : classes.bottomNavItem;
+}
+
const AppShell: FC = (props) => {
const { contentAboveOutlet } = props;
const { t } = useTranslation();
@@ -24,31 +30,31 @@ const AppShell: FC = (props) => {
const items = [
{
- icon: ,
+ Icon: PortfolioIcon,
iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"),
to: "/accounts",
},
{
- icon: ,
+ Icon: CardsIcon,
iconClassName: classes.cardsIcon,
label: t("navigation.cards"),
to: "/cards",
},
{
- icon: ,
+ Icon: StocksIcon,
iconClassName: classes.stocksIcon,
label: t("navigation.stocks"),
to: "/stocks",
},
{
- icon: ,
+ Icon: BondsIcon,
iconClassName: classes.bondsIcon,
label: t("navigation.bonds"),
to: "/bonds",
},
{
- icon: ,
+ Icon: SettingsIcon,
iconClassName: classes.settingsIcon,
label: t("navigation.settings"),
to: "/settings",
@@ -57,32 +63,32 @@ const AppShell: FC = (props) => {
return (
-
- {t("appName")}
-
+
+
+ {t("appName")}
+
-
- {items.map(item => (
-
- {item.icon}
-
- )}
- to={item.to}
- />
- ))}
+
+ {items.map(item => (
+
+
+
+ )}
+ to={item.to}
+ />
+ ))}
+
@@ -93,22 +99,23 @@ const AppShell: FC = (props) => {
-
- {items.map((item) => {
- const appearanceClass = pathname.startsWith(item.to)
- ? classes.active
- : undefined;
+
+ {items.map(item => (
+
+
+
+
- return (
-
- {item.label}
-
- );
- })}
+ {item.label}
+
+ ))}
);
diff --git a/src/entities/broker-operation/index.ts b/src/entities/broker-operation/index.ts
index 9c81877..f249e34 100644
--- a/src/entities/broker-operation/index.ts
+++ b/src/entities/broker-operation/index.ts
@@ -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";
diff --git a/src/entities/broker-operation/model/display.test.ts b/src/entities/broker-operation/model/display.test.ts
index b37267f..bd53e00 100644
--- a/src/entities/broker-operation/model/display.test.ts
+++ b/src/entities/broker-operation/model/display.test.ts
@@ -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();
+ });
+});
diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts
index 2c9aa3a..53e86e1 100644
--- a/src/entities/broker-operation/model/display.ts
+++ b/src/entities/broker-operation/model/display.ts
@@ -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,
+};
diff --git a/src/entities/broker-operation/ui/OperationGroup.test.tsx b/src/entities/broker-operation/ui/OperationGroup.test.tsx
new file mode 100644
index 0000000..7c830fa
--- /dev/null
+++ b/src/entities/broker-operation/ui/OperationGroup.test.tsx
@@ -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(
+
+
+
+ ({
+ key: `${operation.id}-${index}`,
+ operation,
+ }))}
+ totalVisible={isTotalVisible}
+ />
+
+
+ ,
+ );
+}
+
+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);
+ });
+});
diff --git a/src/entities/broker-operation/ui/OperationGroup.tsx b/src/entities/broker-operation/ui/OperationGroup.tsx
new file mode 100644
index 0000000..a5d73fb
--- /dev/null
+++ b/src/entities/broker-operation/ui/OperationGroup.tsx
@@ -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 = (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 (
+ }
+ valueLabel={typeLabel}
+ wrap
+ />
+ )}
+ label={accessibleName}
+ >
+ {props.entries.map(({ key, operation }) => {
+ const payment = paymentPresentation(operation);
+
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export { OperationGroup };
diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts
index e624699..08cb2d2 100644
--- a/src/entities/brokerage-account/model/display.ts
+++ b/src/entities/brokerage-account/model/display.ts
@@ -8,11 +8,15 @@ const VISIBLE_CURRENCY_LIMIT = 2;
type BrokerageAccountCard = {
account: BrokerageAccount;
- positionCounts: null
- | { bonds: number; stocks: number };
+ positionCounts: null | PositionCounts;
source?: NonNullable[number];
summary:
- | { remainingCurrencyCount: number; state: "available"; totals: Money[] }
+ | {
+ portfolio: NonNullable;
+ remainingCurrencyCount: number;
+ state: "available";
+ totals: Money[];
+ }
| { state: "empty" }
| { state: "unavailable" };
};
@@ -21,6 +25,8 @@ type Money = NonNullable<
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
+type PositionCounts = { bonds: number; stocks: number; total: number };
+
function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const;
}
@@ -48,6 +54,7 @@ function brokerageAccountCards(
const positionCounts = {
bonds: summary.bondPositionCount,
stocks: summary.stockPositionCount,
+ total: summary.totalPositionCount,
};
if (summary.totalsByCurrency.length === 0)
@@ -58,6 +65,7 @@ function brokerageAccountCards(
positionCounts,
source,
summary: {
+ portfolio: summary,
remainingCurrencyCount: Math.max(
summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT,
0,
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css
index 80afbf0..99cc82b 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css
@@ -1,166 +1,144 @@
.list {
+ display: grid;
width: 100%;
+ margin: 0;
+ padding: 0;
+ gap: var(--mantine-spacing-sm);
+ list-style: none;
}
-.card {
- min-width: 0;
- gap: var(--mantine-spacing-xl);
- background: var(--mantine-color-white);
- border-color: var(--mantine-color-gray-3);
- box-shadow: 0 4px 16px rgb(15 23 42 / 3%);
+.row {
+ display: grid;
+ grid-template-areas:
+ "identity balance"
+ "details balance"
+ "structure structure";
+ grid-template-columns: minmax(0, 1fr) minmax(0, auto);
+ gap: var(--mantine-spacing-sm) var(--mantine-spacing-xl);
+ align-items: center;
+ padding: var(--mantine-spacing-lg);
+ border: 1px solid var(--wl-color-border);
+ border-left: 3px solid var(--wl-color-primary);
+ border-radius: var(--mantine-radius-md);
+ background: var(--wl-color-surface);
color: inherit;
text-decoration: none;
- transition:
- transform 160ms ease,
- box-shadow 160ms ease,
- border-color 160ms ease;
+ transition: background-color 160ms ease, border-color 160ms ease;
}
-.card:hover,
-.card:focus-visible {
- border-color: var(--mantine-color-blue-3);
- box-shadow: 0 12px 28px rgb(15 23 42 / 8%);
- transform: translateY(-3px);
+.row:hover,
+.row:focus-visible {
+ border-color: var(--wl-color-primary);
+ background: var(--wl-color-subtle);
}
-.card:focus-visible {
- outline: 2px solid var(--mantine-color-blue-6);
- outline-offset: 3px;
+.row:focus-visible {
+ outline: 2px solid var(--wl-color-primary);
+ outline-offset: -2px;
}
.identity {
+ grid-area: identity;
min-width: 0;
}
-.kicker {
- padding: 5px 10px;
- border-radius: var(--mantine-radius-sm);
- background: var(--mantine-color-blue-0);
- color: var(--mantine-color-blue-8);
- letter-spacing: 0.02em;
-}
-
-.accountIcon {
- width: 28px;
- height: 28px;
- flex-shrink: 0;
- color: var(--mantine-color-blue-5);
-}
-
.name {
margin: 0;
- font-size: var(--mantine-h3-font-size);
- line-height: 1.3;
+ color: var(--wl-color-text);
+ font-size: var(--mantine-font-size-md);
+ line-height: var(--mantine-line-height-md);
overflow-wrap: anywhere;
}
+.accountType {
+ flex-shrink: 0;
+ align-self: flex-start;
+ padding: 2px var(--mantine-spacing-xs);
+ border-radius: var(--mantine-radius-sm);
+}
+
.balance {
- flex: 1;
+ grid-area: balance;
+ min-width: 0;
+ text-align: right;
}
.summary {
- font-variant-numeric: tabular-nums;
+ font-variant-numeric: tabular-nums lining-nums;
+ white-space: nowrap;
}
.total {
- font-size: clamp(1.5rem, 2.8vw, 2.25rem);
- letter-spacing: -0.035em;
- line-height: 1.2;
- overflow-wrap: anywhere;
-}
-
-.total + .total {
+ color: var(--wl-color-text);
font-size: var(--mantine-font-size-lg);
- font-weight: 500;
- letter-spacing: 0;
- color: var(--mantine-color-gray-7);
+ line-height: var(--mantine-line-height-lg);
}
-.positions {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: var(--mantine-spacing-sm);
- margin: 0;
+.return,
+.cashValue {
+ font-variant-numeric: tabular-nums lining-nums;
+ white-space: nowrap;
}
-.positions > div {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
- gap: 4px var(--mantine-spacing-sm);
- padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
- border-radius: var(--mantine-radius-md);
+.positive {
+ color: var(--wl-color-positive);
}
-.stocks {
- background: var(--mantine-color-blue-0);
- color: var(--mantine-color-blue-8);
+.negative {
+ color: var(--wl-color-negative);
}
-.bonds {
- background: var(--mantine-color-grape-0);
- color: var(--mantine-color-grape-8);
+.neutral {
+ color: var(--wl-color-muted);
}
-.positions dt {
- font-size: var(--mantine-font-size-sm);
- font-weight: 500;
+.cash {
+ font-variant-numeric: tabular-nums lining-nums;
}
-.positions dd {
- margin: 0;
- font-size: 1.75rem;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
- line-height: 1.25;
- overflow-wrap: anywhere;
+.details {
+ grid-area: details;
+ min-width: 0;
+ font-variant-numeric: tabular-nums lining-nums;
}
-.footer {
- border-top: 1px solid var(--mantine-color-gray-2);
- padding-top: var(--mantine-spacing-md);
-}
-
-.open {
- display: inline-flex;
- align-items: center;
+.structure {
+ grid-area: structure;
gap: var(--mantine-spacing-xs);
- margin-left: auto;
- color: var(--mantine-color-blue-7);
}
-.arrow {
- font-size: var(--mantine-font-size-lg);
- transition: transform 160ms ease;
+.structureBar {
+ display: flex;
+ width: 100%;
+ height: 6px;
+ overflow: hidden;
+ border-radius: var(--mantine-radius-xs);
+ background: var(--wl-color-subtle);
}
-.card:hover .arrow {
- transform: translateX(3px);
+.structureSection {
+ flex: none;
+ height: 100%;
+ background: var(--category-color);
}
-@media (max-width: 36em) {
- .card {
+@media (max-width: 47.9375em) {
+ .row {
+ grid-template-areas:
+ "identity"
+ "balance"
+ "structure"
+ "details";
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-md);
- gap: var(--mantine-spacing-lg);
}
- .positions > div {
- flex-direction: column;
- align-items: flex-start;
- padding: var(--mantine-spacing-sm);
+ .balance {
+ text-align: left;
}
-}
-@media (prefers-reduced-motion: reduce) {
- .card,
- .arrow {
- transition: none;
- }
-
- .card:hover,
- .card:focus-visible,
- .card:hover .arrow {
- transform: none;
+ .balance > div {
+ justify-content: flex-start;
}
}
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx
index 255e46c..a7fc647 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx
@@ -2,32 +2,86 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router";
+import { resource } from "@/test/resource";
+
import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
-const syntheticAccount = {
- id: "storybook-account",
- name: "Учебный брокерский счёт",
- openedOn: "2025-03-15",
- portfolioSummary: {
- bondPositionCount: 12,
- stockPositionCount: 5,
- totalsByCurrency: [
- { currency: "RUB", value: "125000.50" },
- { currency: "USD", value: "320" },
- { currency: "EUR", value: "210" },
- ],
+type PortfolioSummary = NonNullable;
+
+const rubReturn = {
+ absolute: "136900",
+ costBasis: "1103600.50",
+ currency: "RUB",
+ partial: false,
+ percent: "12.40",
+};
+
+const portfolioSummary = {
+ bondPositionCount: 12,
+ cashBalances: [
+ { currency: "RUB", value: "82500.50" },
+ { currency: "USD", value: "1500" },
+ ],
+ fundPositionCount: 2,
+ stockPositionCount: 5,
+ structure: {
+ currencies: [{
+ categories: [
+ { sharePercent: "55.000000", type: "STOCK", value: "682500" },
+ { sharePercent: "25.000000", type: "BOND", value: "310500" },
+ { sharePercent: "10.000000", type: "OTHER", value: "124200" },
+ { sharePercent: "10.000000", type: "CASH", value: "124200" },
+ ],
+ currency: "RUB",
+ hasNegativeValues: false,
+ totalValue: "1241400",
+ }],
+ unvaluedCategories: [],
},
- type: "BROKERAGE",
-} satisfies BrokerageAccount;
+ totalPositionCount: 19,
+ totalsByCurrency: [{ currency: "RUB", value: "1240500.50" }],
+ unrealizedReturnByCurrency: [rubReturn],
+} satisfies PortfolioSummary;
+
+function account(
+ id: string,
+ name: string,
+ summary: null | PortfolioSummary = portfolioSummary,
+ type: BrokerageAccount["type"] = "BROKERAGE",
+): BrokerageAccount {
+ return {
+ id,
+ name,
+ openedOn: "2025-03-15",
+ portfolioSummary: summary,
+ type,
+ };
+}
+
+const negativeSummary: PortfolioSummary = {
+ ...portfolioSummary,
+ unrealizedReturnByCurrency: [{
+ ...rubReturn,
+ absolute: "-4200",
+ percent: "-3.05",
+ }],
+};
+
+const emptySummary: PortfolioSummary = {
+ ...portfolioSummary,
+ cashBalances: [],
+ structure: { currencies: [], unvaluedCategories: [] },
+ totalPositionCount: 0,
+ totalsByCurrency: [],
+ unrealizedReturnByCurrency: [],
+};
const meta = {
- args: { accounts: brokerageAccountCards([syntheticAccount]) },
- argTypes: {
- accounts: { control: false },
- },
+ args: { accounts: brokerageAccountCards([account("normal", "Основной ИИС")]) },
+ argTypes: { accounts: { control: false } },
component: BrokerageAccountList,
decorators: [
Story => (
@@ -40,7 +94,7 @@ const meta = {
docs: {
description: {
component:
- "Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
+ "Карточки счетов: стоимость и доходность по валютам, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.",
},
},
},
@@ -52,188 +106,112 @@ export default meta;
type Story = StoryObj;
-export const Available: Story = {
- name: "Сводка по нескольким валютам",
- parameters: {
- docs: {
- description: {
- story:
- "Показываются первые две валюты и количество остальных. Суммы разных валют не складываются в общий итог.",
- },
- },
+export const Normal: Story = {};
+
+export const MultiCurrency: Story = {
+ args: {
+ accounts: brokerageAccountCards([account("multi", "Мультивалютный", {
+ ...portfolioSummary,
+ cashBalances: [
+ { currency: "RUB", value: "82500.50" },
+ { currency: "USD", value: "1500" },
+ ],
+ totalsByCurrency: [
+ { currency: "RUB", value: "1240500.50" },
+ { currency: "USD", value: "3200" },
+ { currency: "EUR", value: "900" },
+ ],
+ unrealizedReturnByCurrency: [
+ ...portfolioSummary.unrealizedReturnByCurrency,
+ {
+ absolute: "20",
+ costBasis: "3180",
+ currency: "USD",
+ partial: false,
+ percent: "2.10",
+ },
+ {
+ absolute: "0",
+ costBasis: "900",
+ currency: "EUR",
+ partial: false,
+ percent: null,
+ },
+ ],
+ })]),
},
};
-export const Unavailable: Story
- = {
- args: {
- accounts: brokerageAccountCards([
- { ...syntheticAccount, openedOn: null, portfolioSummary: null },
- ]),
- },
- name: "Сводка недоступна",
- parameters: {
- docs: {
- description: {
- story:
- "Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.",
- },
- },
- },
- };
+export const Negative: Story = {
+ args: {
+ accounts: brokerageAccountCards([
+ account("negative", "Просевший портфель", negativeSummary),
+ ]),
+ },
+};
+
+export const Partial: Story = {
+ args: {
+ accounts: brokerageAccountCards([account("partial", "Частичные данные", {
+ ...portfolioSummary,
+ unrealizedReturnByCurrency: [{
+ ...rubReturn,
+ partial: true,
+ }],
+ })]),
+ },
+};
export const Empty: Story = {
args: {
accounts: brokerageAccountCards([
- {
- ...syntheticAccount,
- portfolioSummary: {
- bondPositionCount: 0,
- stockPositionCount: 0,
- totalsByCurrency: [],
- },
- },
+ account("empty", "Новый пустой счёт", emptySummary),
]),
},
- name: "Нет текущих сумм",
- parameters: {
- docs: {
- description: {
- story:
- "Успешно полученная пустая сводка отличается от ошибки получения данных. Карточка сохраняет название, тип счёта и переход.",
- },
- },
+};
+
+export const Unavailable: Story = {
+ args: {
+ accounts: brokerageAccountCards([
+ account("unavailable", "Счёт без сводки", null),
+ ]),
+ },
+};
+
+export const Stale: Story = {
+ args: {
+ accounts: brokerageAccountCards([
+ account("stale", "Устаревший портфель"),
+ ], resource([], {
+ dependencies: [{
+ availability: "available",
+ completeness: "complete",
+ dataRevision: "stale-revision",
+ freshness: "stale",
+ lastAttempt: {
+ finishedAt: null,
+ problems: [],
+ startedAt: null,
+ state: "none",
+ },
+ lastSuccessfulAt: "2026-08-13T00:00:00Z",
+ resourceKey: "portfolio/stale",
+ }],
+ freshness: "stale",
+ lastSuccessfulAt: "2026-08-14T00:00:00Z",
+ }).meta.dependencies),
},
};
export const LongName: Story = {
args: {
accounts: brokerageAccountCards([
- {
- ...syntheticAccount,
- name: "Учебный индивидуальный инвестиционный счёт для долгосрочных накоплений",
- type: "INDIVIDUAL_INVESTMENT",
- },
- ]),
- },
- name: "Длинное название счёта",
- parameters: {
- docs: {
- description: {
- story:
- "Проверьте перенос длинного названия на узком экране и доступность всей карточки с клавиатуры.",
- },
- },
- },
-};
-
-export const AccountGrid: Story = {
- args: {
- accounts: brokerageAccountCards([
- {
- ...syntheticAccount,
- name: "Основной",
- portfolioSummary: {
- bondPositionCount: 14,
- stockPositionCount: 8,
- totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }],
- },
- },
- {
- ...syntheticAccount,
- id: "storybook-iis",
- name: "Долгосрочные инвестиции",
- portfolioSummary: {
- bondPositionCount: 6,
- stockPositionCount: 0,
- totalsByCurrency: [{ currency: "RUB", value: "480250" }],
- },
- type: "INDIVIDUAL_INVESTMENT",
- },
- ]),
- },
- name: "Несколько счетов",
-};
-
-export const UnvaluedPositions: Story = {
- args: {
- accounts: brokerageAccountCards([
- {
- ...syntheticAccount,
- portfolioSummary: {
- bondPositionCount: 1,
- stockPositionCount: 2,
- totalsByCurrency: [],
- },
- },
- ]),
- },
- name: "Бумаги без текущей оценки",
-};
-
-export const LargeValues: Story = {
- args: {
- accounts: brokerageAccountCards([
- {
- ...syntheticAccount,
- portfolioSummary: {
- bondPositionCount: 105,
- stockPositionCount: 21,
- totalsByCurrency: [
- { currency: "RUB", value: "1234567890123456789.50" },
- { currency: "USD", value: "-9876543210987654321.50" },
- ],
- },
- },
- ]),
- },
- name: "Длинные суммы",
-};
-
-export const IndependentFreshness: Story
- = {
- args: {
- accounts: brokerageAccountCards(
- [
- syntheticAccount,
- {
- ...syntheticAccount,
- id: "waiting",
- name: "Новый счёт",
- portfolioSummary: null,
- },
- ],
- [
- {
- availability: "available",
- completeness: "complete",
- dataRevision: "1",
- freshness: "stale",
- lastAttempt: {
- finishedAt: "2026-09-24T10:31:00Z",
- problems: [],
- startedAt: "2026-09-24T10:30:00Z",
- state: "failed",
- },
- lastSuccessfulAt: "2026-09-24T10:30:00Z",
- resourceKey: "portfolio/storybook-account",
- },
- {
- availability: "initial-loading",
- completeness: "incomplete",
- dataRevision: "0",
- freshness: "unknown",
- lastAttempt: {
- finishedAt: null,
- problems: [],
- startedAt: "2026-09-24T10:30:00Z",
- state: "running",
- },
- lastSuccessfulAt: null,
- resourceKey: "portfolio/waiting",
- },
- ],
+ account(
+ "long",
+ "Очень длинное название индивидуального инвестиционного счёта, которое должно корректно переноситься на узком экране",
+ portfolioSummary,
+ "INDIVIDUAL_INVESTMENT",
),
- },
- name: "Независимая свежесть и ошибка сводок",
- };
+ ]),
+ },
+};
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx
index 8464fb7..494f200 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx
@@ -5,122 +5,202 @@ import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource";
+import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
+
import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
-describe("BrokerageAccountList", () => {
- it("renders account details and an account link", () => {
- render(
-
-
- ;
- },
- {
- id: "account-2",
- name: "Недоступный",
- openedOn: null,
- portfolioSummary: null,
- type: "BROKERAGE",
- },
- {
- id: "account-3",
- name: "Пустой",
- openedOn: null,
- portfolioSummary: {
- bondPositionCount: 0,
- stockPositionCount: 2,
- totalsByCurrency: [],
- },
- type: "BROKERAGE",
- },
- ])}
- />
-
- ,
- );
+const portfolioSummary = {
+ bondPositionCount: 12,
+ cashBalances: [
+ { currency: "RUB", value: "82500.5" },
+ { currency: "USD", value: "20" },
+ ],
+ fundPositionCount: 0,
+ stockPositionCount: 5,
+ structure: {
+ currencies: [{
+ categories: [
+ { sharePercent: "50.000000", type: "STOCK", value: "100" },
+ { sharePercent: "30.000000", type: "BOND", value: "60" },
+ { sharePercent: "0.000000", type: "OTHER", value: "0" },
+ { sharePercent: "20.000000", type: "CASH", value: "40" },
+ ],
+ currency: "RUB",
+ hasNegativeValues: false,
+ totalValue: "200",
+ }],
+ unvaluedCategories: [],
+ },
+ totalPositionCount: 17,
+ totalsByCurrency: [
+ { currency: "RUB", value: "123456789012345678901.995" },
+ { currency: "USD", value: "20" },
+ { currency: "EUR", value: "5" },
+ ],
+ unrealizedReturnByCurrency: [
+ {
+ absolute: "136900",
+ costBasis: "1000000",
+ currency: "RUB",
+ partial: true,
+ percent: "12.40",
+ },
+ {
+ absolute: "-4.2",
+ costBasis: "24.2",
+ currency: "USD",
+ partial: false,
+ percent: "-3.05",
+ },
+ {
+ absolute: "0",
+ costBasis: "5",
+ currency: "EUR",
+ partial: false,
+ percent: null,
+ },
+ ],
+} satisfies PortfolioSummary;
- expect(screen.getByRole("link", { name: /Основной/ })).toHaveAttribute(
- "href",
- "/accounts/account-1",
- );
- expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
- expect(screen.getByText(/^Открыт /)).toBeInTheDocument();
- expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
- expect(screen.getByText("20 USD")).toBeInTheDocument();
- expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument();
- expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
- expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
- expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
- const available = within(screen.getByRole("link", { name: /Основной/ }));
- expect(available.getAllByRole("definition")[0]).toHaveTextContent("5");
- expect(available.getAllByRole("definition")[1]).toHaveTextContent("12");
- const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
- expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2");
- expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0");
- expect(
- within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
- "Акции",
- ),
- ).not.toBeInTheDocument();
- });
-});
+type BrokerageAccountCardInput = Parameters[0][
+ "accounts"
+];
-it("shows portfolio summary freshness independently for each account", () => {
- const accounts = brokerageAccountCards(
- ["fresh", "stale"].map(
+function account(
+ id: string,
+ name: string,
+ summary: null | PortfolioSummary,
+): BrokerageAccount {
+ return {
+ id,
+ name,
+ openedOn: null,
+ portfolioSummary: summary,
+ type: "BROKERAGE",
+ };
+}
- id =>
- ({
- id,
- name: id,
- openedOn: null,
- portfolioSummary: {
- bondPositionCount: 0,
- stockPositionCount: 1,
- totalsByCurrency: [{ currency: "RUB", value: "10" }],
- },
- type: "BROKERAGE" as const,
- }),
- ),
- [
- resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
- .meta,
- resource(null, { freshness: "stale", resourceKey: "portfolio/stale" })
- .meta,
- ],
- );
-
- render(
+function renderList(accounts: BrokerageAccountCardInput) {
+ return render(
,
);
- expect(
- within(screen.getByRole("link", { name: /stale/ })).getByText(
- "Данные требуют обновления",
- ),
- ).toBeVisible();
- expect(
- within(screen.getByRole("link", { name: /fresh/ })).queryByText(
- "Данные требуют обновления",
- ),
- ).not.toBeInTheDocument();
+}
+
+describe("BrokerageAccountList", () => {
+ it("renders returns, cash and structure with precise totals", () => {
+ const nullReturnSummary: PortfolioSummary = {
+ ...portfolioSummary,
+ cashBalances: [],
+ structure: { currencies: [], unvaluedCategories: [] },
+ totalsByCurrency: [{ currency: "EUR", value: "5" }],
+ unrealizedReturnByCurrency: [{
+ absolute: "0",
+ costBasis: "5",
+ currency: "EUR",
+ partial: false,
+ percent: null,
+ }],
+ };
+
+ renderList(brokerageAccountCards([
+ account("account-1", "Основной", portfolioSummary),
+ account("account-2", "Без доходности", nullReturnSummary),
+ ]));
+
+ const link = screen.getByRole("link", { name: /Основной/ });
+ expect(link).toHaveAttribute("href", "/accounts/account-1");
+ expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
+ .toBeInTheDocument();
+ expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument();
+ expect(within(link).getByText("+12,40 %")).toBeInTheDocument();
+ expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
+ expect(within(link).getByText("−3,05 %")).toBeInTheDocument();
+ expect(within(link).getByText("−4,2 USD")).toBeInTheDocument();
+ const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
+ expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
+ expect(within(link).getByText("частично")).toBeInTheDocument();
+ expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
+ expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0);
+ expect(within(link).getByText("17 позиций")).toBeInTheDocument();
+ expect(screen.getByRole("img", {
+ name: "Структура в RUB: Акции 50 %; Облигации 30 %; "
+ + "Другие активы 0 %; Свободные деньги 20 %",
+ })).toBeInTheDocument();
+ const stockLegend = within(link).getByText(/Акции 50 %/);
+ expect(stockLegend).toHaveTextContent("● Акции 50 %");
+ // eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
+ expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
+ expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument();
+ expect(link).toHaveTextContent("По данным брокера");
+ expect(link).toHaveTextContent("Структура RUB: Акции 100");
+ });
+
+ it("preserves empty, unavailable and stale states", () => {
+ const emptySummary: PortfolioSummary = {
+ ...portfolioSummary,
+ cashBalances: [],
+ structure: { currencies: [], unvaluedCategories: [] },
+ totalPositionCount: 0,
+ totalsByCurrency: [],
+ unrealizedReturnByCurrency: [],
+ };
+
+ const cards = brokerageAccountCards([
+ account("empty", "Пустой", emptySummary),
+ account("unavailable", "Недоступный", null),
+ account("stale", "Устаревший", portfolioSummary),
+ account("failed", "Ошибка обновления", null),
+ ], resource([], {
+ dependencies: [{
+ availability: "available",
+ completeness: "complete",
+ dataRevision: "stale-revision",
+ freshness: "stale",
+ lastAttempt: {
+ finishedAt: null,
+ problems: [],
+ startedAt: null,
+ state: "none",
+ },
+ lastSuccessfulAt: "2026-08-13T00:00:00Z",
+ resourceKey: "portfolio/stale",
+ }, {
+ availability: "available",
+ completeness: "complete",
+ dataRevision: "failed-revision",
+ freshness: "stale",
+ lastAttempt: {
+ finishedAt: "2026-08-14T00:01:00Z",
+ problems: [],
+ startedAt: "2026-08-14T00:00:00Z",
+ state: "failed",
+ },
+ lastSuccessfulAt: "2026-08-13T00:00:00Z",
+ resourceKey: "portfolio/failed",
+ }],
+ freshness: "stale",
+ lastSuccessfulAt: "2026-08-14T00:00:00Z",
+ }).meta.dependencies);
+
+ renderList(cards);
+
+ expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
+ expect(screen.getAllByText("Сводка временно недоступна"))
+ .toHaveLength(2);
+ const staleLink = screen.getByRole("link", { name: /Устаревший/ });
+ expect(within(staleLink).getByText("Данные требуют обновления"))
+ .toBeInTheDocument();
+ expect(within(staleLink).getByText(/Данные на/)).toBeInTheDocument();
+ const failedLink = screen.getByRole("link", { name: /Ошибка обновления/ });
+ expect(within(failedLink).getByText(
+ "Не удалось обновить данные. Показаны последние полученные.",
+ )).toBeInTheDocument();
+ });
});
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx
index dd8dd3b..462334f 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx
@@ -1,11 +1,10 @@
-import type { FC } from "react";
+import type { CSSProperties, FC } from "react";
-import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
-import { WalletIcon } from "@wealthledger/icons";
+import { Badge, Group, Stack, Text, VisuallyHidden } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { formatDate, formatMoney } from "@/shared/lib";
+import { formatMoney, portfolioCategoryPresentation } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display";
@@ -13,12 +12,176 @@ import type { BrokerageAccountCard } from "../../model/display";
import { accountTypeKey } from "../../model/display";
import classes from "./BrokerageAccountList.module.css";
+const DECIMAL_PART_COUNT = 2;
+const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
+
+type AvailableSummary = Extract<
+ BrokerageAccountCard["summary"],
+ { state: "available" }
+>;
+
type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] };
type BrokerageAccountSummaryProps = {
summary: BrokerageAccountCard["summary"];
};
+type PortfolioSummary = AvailableSummary["portfolio"];
+
+type ReturnValueProps = {
+ item: PortfolioSummary["unrealizedReturnByCurrency"][number];
+};
+
+type StructureCurrency = PortfolioSummary["structure"]["currencies"][number];
+
+function formatPercent(value: null | string) {
+ if (value === null) return "—";
+
+ const formatted = `${value.replace(".", ",")}\u{A0}%`;
+ if (isZero(value))
+ return value.startsWith("-") ? formatted.slice(1) : formatted;
+
+ return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
+}
+
+function formatStructurePercent(value: string) {
+ let trimmed = value;
+ while (trimmed.includes(".") && trimmed.endsWith("0"))
+ trimmed = trimmed.slice(0, -1);
+ if (trimmed.endsWith(".")) trimmed = trimmed.slice(0, -1);
+
+ return trimmed.replace(".", ",");
+}
+
+function isZero(value: string) {
+ const unsigned = value.startsWith("-") ? value.slice(1) : value;
+ const [integer = "", fraction = ""] = unsigned.split(".", DECIMAL_PART_COUNT);
+
+ return integer.replaceAll("0", "") === ""
+ && fraction.replaceAll("0", "") === "";
+}
+
+const ReturnValue: FC = (props) => {
+ const { t } = useTranslation();
+ const { item } = props;
+
+ const isNegative = item.percent !== null
+ && item.percent.startsWith("-")
+ && !isZero(item.percent);
+
+ const isPositive = item.percent !== null
+ && !item.percent.startsWith("-")
+ && !isZero(item.percent);
+
+ let toneClass = classes.neutral;
+ if (isNegative) toneClass = classes.negative;
+ else if (isPositive) toneClass = classes.positive;
+
+ return (
+
+
+ {formatPercent(item.percent)}
+
+
+
+ {formatMoney(
+ { currency: item.currency, value: item.absolute },
+ { withSign: true },
+ )}
+
+
+ {item.partial && (
+
+ {t("accounts.partial")}
+
+ )}
+
+ );
+};
+
+type StructureBarProps = { item: StructureCurrency };
+
+const StructureBar: FC = (props) => {
+ const { t } = useTranslation();
+
+ const categories = STRUCTURE_CATEGORY_ORDER.flatMap((type) => {
+ const category = props.item.categories.find(item => item.type === type);
+
+ return category ? [category] : [];
+ });
+
+ const description = categories.map((category) => {
+ const label = t(portfolioCategoryPresentation[category.type].label);
+
+ const percent = category.sharePercent === null
+ ? null
+ : formatStructurePercent(category.sharePercent);
+
+ return percent === null
+ ? t("accounts.structure.incomplete", { category: label })
+ : t("accounts.structure.category", { category: label, percent });
+ }).join("; ");
+
+ const label = t("accounts.structure.label", {
+ currency: props.item.currency,
+ value: description,
+ });
+
+ const legendCategories = categories.filter(category =>
+ category.sharePercent === null || !isZero(category.sharePercent));
+
+ return (
+
+
+ {categories.map((category) => {
+ const presentation = portfolioCategoryPresentation[category.type];
+
+ const style = {
+ "--category-color": `var(--mantine-color-${presentation.color.replace(".", "-")})`,
+ "width": `${category.sharePercent ?? 0}%`,
+ } as CSSProperties;
+
+ return (
+
+ );
+ })}
+
+
+
+ {legendCategories.map((category) => {
+ const presentation = portfolioCategoryPresentation[category.type];
+ const categoryLabel = t(presentation.label);
+
+ const text = category.sharePercent === null
+ ? categoryLabel
+ : t("accounts.structure.category", {
+ category: categoryLabel,
+ percent: formatStructurePercent(category.sharePercent),
+ });
+
+ return (
+
+ ●
+ {" "}
+ {text}
+
+ );
+ })}
+
+
+ );
+};
+
const BrokerageAccountSummary: FC = (props) => {
const { summary } = props;
const { t } = useTranslation();
@@ -36,19 +199,51 @@ const BrokerageAccountSummary: FC = (props) => {
);
return (
-
- {summary.totals.map(total => (
-
- {formatMoney(total)}
-
- ))}
+
+
+
+ {summary.totals.map(total => (
+
+ {formatMoney(total, { withRoundedCents: true })}
+
+ ))}
- {!!summary.remainingCurrencyCount && (
-
- {t("accounts.remainingCurrencies", {
- count: summary.remainingCurrencyCount,
+ {!!summary.remainingCurrencyCount && (
+
+ {t("accounts.remainingCurrencies", {
+ count: summary.remainingCurrencyCount,
+ })}
+
+ )}
+
+
+
+ {summary.totals.map((total) => {
+ const item = summary.portfolio.unrealizedReturnByCurrency.find(
+ returnItem => returnItem.currency === total.currency,
+ );
+
+ return item
+ ?
+ : null;
})}
-
+
+
+
+ {summary.portfolio.cashBalances.length > 0 && (
+
+
+ {t("accounts.cash")}
+
+
+
+ {summary.portfolio.cashBalances.map(cash => (
+
+ {formatMoney(cash, { withRoundedCents: true })}
+
+ ))}
+
+
)}
);
@@ -61,13 +256,13 @@ type BrokerageAccountSourceStatusProps = {
const BrokerageAccountSourceStatus: FC = (
props,
) => {
- const { source } = props;
const { t } = useTranslation();
-
+ const { source } = props;
const isSyncing = source?.lastAttempt.state === "running";
- const errorText
- = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
+ const errorText = source?.lastAttempt.state === "failed"
+ ? t("accounts.refreshError")
+ : null;
return (
<>
@@ -87,86 +282,110 @@ const BrokerageAccountSourceStatus: FC = (
);
};
+type AccountDescriptionProps = {
+ account: BrokerageAccountCard;
+};
+
+const AccountDescription: FC = (props) => {
+ const { t } = useTranslation();
+ const { account, summary } = props.account;
+ if (summary.state !== "available") return null;
+
+ const returns = summary.portfolio.unrealizedReturnByCurrency.map(item =>
+ t("accounts.description.return", {
+ absolute: formatMoney(
+ { currency: item.currency, value: item.absolute },
+ { withSign: true },
+ ),
+ currency: item.currency,
+ percent: formatPercent(item.percent),
+ }),
+ );
+
+ const structures = summary.portfolio.structure.currencies.map((item) => {
+ const categories = item.categories.map(category =>
+ t("accounts.description.structureCategory", {
+ category: t(portfolioCategoryPresentation[category.type].label),
+ value: formatMoney(
+ { currency: item.currency, value: category.value },
+ { withRoundedCents: true },
+ ),
+ }),
+ ).join(", ");
+
+ return t("accounts.description.structure", {
+ currency: item.currency,
+ value: categories,
+ });
+ });
+
+ const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some(
+ item => item.partial,
+ );
+
+ const details = [...returns, ...structures].join(". ");
+
+ return (
+
+ {t("accounts.description.start", {
+ name: account.name,
+ type: t(accountTypeKey(account.type)),
+ })}
+ {t("accounts.description.currentValue", {
+ value: summary.portfolio.totalsByCurrency
+ .map(total => formatMoney(total)).join(", "),
+ })}
+ {details ? `${details}. ` : ""}
+ {hasPartialReturn ? t("accounts.brokerDescription") : ""}
+
+ );
+};
+
const BrokerageAccountList: FC = (props) => {
- const { accounts } = props;
const { t } = useTranslation();
return (
-
- {accounts.map(({ account, positionCounts, source, summary }) => (
-
-
-
-
+
+ {props.accounts.map(({ account, positionCounts, source, summary }) => (
+
+
+
+ {account.name}
+
+
{t(accountTypeKey(account.type))}
-
-
-
+
-
- {account.name}
-
-
-
-
-
- {t("accounts.currentValue")}
-
-
-
-
+
+ {positionCounts && (
+
+ {t("accounts.positions", {
+ count: positionCounts.total,
+ })}
+
+ )}
- {!!positionCounts && (
-
-
-
{t("accounts.stocks")}
+
+
-
- {new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
-
-
-
-
-
{t("accounts.bonds")}
-
-
- {new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
-
-
-
- )}
-
-
- {!!account.openedOn && (
-
- {t("accounts.openedOn", {
- date: formatDate(account.openedOn),
- })}
-
+ {summary.state === "available" && (
+
+ {summary.portfolio.structure.currencies.map(item => (
+
+ ))}
+
)}
-
- {t("accounts.openAccount")}
-
-
- →
-
-
-
-
+
+
+
))}
-
+
);
};
diff --git a/src/entities/brokerage-event/index.ts b/src/entities/brokerage-event/index.ts
index aab5711..b425ed3 100644
--- a/src/entities/brokerage-event/index.ts
+++ b/src/entities/brokerage-event/index.ts
@@ -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,
diff --git a/src/entities/brokerage-event/model/event-key.test.ts b/src/entities/brokerage-event/model/event-key.test.ts
new file mode 100644
index 0000000..3f4d094
--- /dev/null
+++ b/src/entities/brokerage-event/model/event-key.test.ts
@@ -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();
+ });
+});
diff --git a/src/entities/brokerage-event/model/event-key.ts b/src/entities/brokerage-event/model/event-key.ts
index 1d460bd..5a72442 100644
--- a/src/entities/brokerage-event/model/event-key.ts
+++ b/src/entities/brokerage-event/model/event-key.ts
@@ -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 };
diff --git a/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx b/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx
new file mode 100644
index 0000000..f08e78f
--- /dev/null
+++ b/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx
@@ -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(
+
+
+
+
+ ,
+ );
+}
+
+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);
+ });
+});
diff --git a/src/entities/brokerage-event/ui/BrokerageEventGroup.tsx b/src/entities/brokerage-event/ui/BrokerageEventGroup.tsx
new file mode 100644
index 0000000..8c2228c
--- /dev/null
+++ b/src/entities/brokerage-event/ui/BrokerageEventGroup.tsx
@@ -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 = (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 (
+ }
+ valueLabel={t("payout.forecast")}
+ wrap
+ />
+ )}
+ label={accessibleName}
+ >
+ {props.events.map((event) => {
+ const value = event.projectedAmount
+ ? formatMoney(event.projectedAmount)
+ : t("events.noAmount");
+
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export { BrokerageEventGroup };
diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
index 5855d0a..4e4818b 100644
--- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
+++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
@@ -5,13 +5,18 @@ 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;
};
type BrokerageEventsPreviewState
@@ -19,6 +24,7 @@ type BrokerageEventsPreviewState
items: BrokerageEvent[];
state: "loaded" | "partial";
to: string;
+ totalVisible?: boolean;
}
| { state: "empty"
| "error"
@@ -42,16 +48,35 @@ const BrokerageEventsPreview: FC = (props) => {
{events.state === "partial" && {t("investment.partial")} }
- {(events.state === "loaded" || events.state === "partial")
- && events.items.map(event => (
-
- ))}
+ {(events.state === "loaded" || events.state === "partial") && (
+
+ {groupItems(
+ events.items,
+ props.allAccounts ? brokerageEventGroupKey : () => null,
+ ).map(entry => entry.type === "group"
+ ? (
+
+ )
+ : (
+
+
+
+ ))}
+
+ )}
- {t("events.open")}
+ {props.moreLabel ?? t("events.open")}
);
diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx
index e2dc24d..d00f246 100644
--- a/src/features/auth/model/auth.test.tsx
+++ b/src/features/auth/model/auth.test.tsx
@@ -3,8 +3,9 @@ import type { FC, PropsWithChildren } from "react";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, onTestFinished, vi } from "vitest";
+import * as sharedApi from "@/shared/api";
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
@@ -398,4 +399,23 @@ describe("AuthProvider", () => {
expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
);
});
+
+ it("registers auth handlers once until unmount", async () => {
+ const configure = vi.spyOn(sharedApi, "configureAuthClient");
+ onTestFinished(() => configure.mockRestore());
+
+ const { rerender, result, unmount } = renderHook(useAuth, {
+ wrapper: createAuthWrapper(createTestQueryClient()),
+ });
+
+ await waitFor(() => expect(result.current.ready).toBe(true));
+ const registrations = configure.mock.calls.length;
+
+ rerender();
+ expect(configure).toHaveBeenCalledTimes(registrations);
+
+ unmount();
+ expect(configure).toHaveBeenCalledTimes(registrations + 1);
+ expect(configure).toHaveBeenLastCalledWith();
+ });
});
diff --git a/src/features/auth/model/auth.vm.ts b/src/features/auth/model/auth.vm.ts
index 5cfa2a8..80c71c8 100644
--- a/src/features/auth/model/auth.vm.ts
+++ b/src/features/auth/model/auth.vm.ts
@@ -161,7 +161,8 @@ function useAuthProviderVm() {
refresh,
unavailable: () => unavailableRef.current,
});
- });
+ // `user` tracks access-token changes, which configureAuthClient publishes as session state.
+ }, [clearSession, refresh, user]);
useEffect(() => () => configureAuthClient(), []);
const authContextValue = {
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
index 8874f94..15994eb 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
@@ -237,4 +237,79 @@ 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],
+ },
+ isError: false,
+ isLoading: false,
+ meta: {
+ availability: "available",
+ completeness: "complete",
+ lastAttempt: {
+ finishedAt: null,
+ problems: [],
+ startedAt: null,
+ state: "none",
+ },
+ },
+ refetch: vi.fn(),
+ } as never);
+
+ render(
+
+
+
+ }
+ path="/accounts/:accountId/events"
+ />
+
+
+ ,
+ );
+
+ 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();
+ });
});
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
index 51ff5eb..92ef995 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
@@ -18,6 +18,7 @@ import {
type AccountEventsPageViewModel
= | {
+ allAccounts: boolean;
canApply: boolean;
draftPeriod: [null | string, null | string];
events: BrokerageEvent[];
@@ -29,6 +30,7 @@ type AccountEventsPageViewModel
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
+ totalVisible: boolean;
}
| { onReset: () => void; state: "error" }
| { state: "loading" };
@@ -100,6 +102,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
: { state: "loading" };
return {
+ allAccounts: accountId === "all",
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo),
@@ -121,6 +124,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
partial: events.meta?.completeness === "incomplete",
source: events.meta,
state: "loaded",
+ totalVisible: events.meta?.completeness === "complete",
};
}
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
index e095af6..4fbd2f7 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
@@ -13,8 +13,11 @@ import { useTranslation } from "react-i18next";
import {
BrokerageEventCard,
+ BrokerageEventGroup,
+ brokerageEventGroupKey,
brokerageEventKey,
} from "@/entities/brokerage-event";
+import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -30,14 +33,32 @@ const AccountEventsPageLoadedEvents: FC = (
props,
) => {
if (props.events.length > 0) {
+ const entries = groupItems(
+ props.events,
+ props.allAccounts ? brokerageEventGroupKey : () => null,
+ );
+
return (
-
- {props.events.map(event => (
-
- ))}
+
+ {entries.map(entry => entry.type === "group"
+ ? (
+
+ )
+ : (
+
+
+
+ ))}
);
}
diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
index c821122..c1e8038 100644
--- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
@@ -100,7 +100,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 +116,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
@@ -680,4 +683,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();
+ });
});
diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
index ae0a8e8..4c1d679 100644
--- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
@@ -31,6 +31,7 @@ type AccountIncomePageViewModel
onSubmit: (values: IncomeForm) => void;
partial?: boolean;
state: "loaded";
+ totalVisible: boolean;
}
| {
period: IncomePeriod;
@@ -116,6 +117,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
onSubmit,
partial: analytics.meta?.completeness === "incomplete",
state: "loaded",
+ totalVisible: analytics.meta?.completeness === "complete",
};
};
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
index bef3258..33fbcb8 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
@@ -53,7 +53,11 @@ const AccountIncomePageLoaded: FC = (props) => {
{t("income.taxBoundary")}
-
+
);
};
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
index ab7507b..c03c1dc 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
@@ -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 = (props) => {
const { t } = useTranslation();
@@ -19,11 +28,24 @@ const IncomePayments: FC = (props) => {
{t("income.payments")}
- {props.entries.map(({ key, operation }) => (
-
-
-
- ))}
+ {groupItems(
+ props.entries,
+ entry => props.allAccounts
+ ? brokerageOperationGroupKey(entry.operation)
+ : null,
+ ).map(entry => entry.type === "group"
+ ? (
+
+ )
+ : (
+
+
+
+ ))}
);
diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx
index 968c1b9..b0ef764 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx
@@ -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,12 @@ function configureTestAuth() {
});
}
-function renderPage(search = "") {
+function renderPage(search = "", accountId = "account-1") {
return render(
{
});
});
+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,6 +518,33 @@ describe("AccountOperationsPage", () => {
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
});
+ it("does not show the history loading alert when coverage is partial", async () => {
+ configureTestAuth();
+ server.use(
+ 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("История догружается"),
+ ).toBeInTheDocument();
+ });
+
it("renders an error state", async () => {
configureTestAuth();
server.use(
@@ -602,3 +669,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();
+ });
+});
diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
index 55eb5f2..a585843 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
+++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
@@ -25,6 +25,7 @@ const operationCategorySet = new Set(operationCategories);
type AccountOperationsPageViewModel
= | {
+ allAccounts: boolean;
canApply: boolean;
draftCategories: OperationCategory[];
draftPeriod: [null | string, null | string];
@@ -43,6 +44,7 @@ type AccountOperationsPageViewModel
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
+ totalVisible: boolean;
}
| { onReset: () => void; state: "error" }
| { state: "loading" };
@@ -147,14 +149,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: () => {
@@ -183,6 +184,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded",
+ totalVisible: operations.meta?.completeness === "complete",
};
};
diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx
index 40d513a..afe7301 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx
@@ -12,7 +12,13 @@ import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
-import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
+import {
+ brokerageOperationGroupKey,
+ LedgerEntryRow,
+ operationCategories,
+ OperationGroup,
+} from "@/entities/broker-operation";
+import { groupItems } from "@/shared/lib";
import { 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 => (
- ({
+ key: JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
- ])}
- operation={operation}
- />
- ));
+ ]),
+ operation,
+ })),
+ entry => props.allAccounts
+ ? brokerageOperationGroupKey(entry.operation)
+ : null,
+ );
+
+ return (
+
+ {entries.map(entry => entry.type === "group"
+ ? (
+
+ )
+ : (
+
+
+
+ ))}
+
+ );
}
if (props.partial) {
diff --git a/src/pages/account/model/concentration.test.ts b/src/pages/account/model/concentration.test.ts
new file mode 100644
index 0000000..80fd3f0
--- /dev/null
+++ b/src/pages/account/model/concentration.test.ts
@@ -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] {
+ 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");
+ });
+});
diff --git a/src/pages/account/model/concentration.ts b/src/pages/account/model/concentration.ts
new file mode 100644
index 0000000..e9eb18a
--- /dev/null
+++ b/src/pages/account/model/concentration.ts
@@ -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,
+ 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();
+ 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,
+};
diff --git a/src/pages/account/model/positions.fixtures.ts b/src/pages/account/model/positions.fixtures.ts
index 54508f6..ff4718e 100644
--- a/src/pages/account/model/positions.fixtures.ts
+++ b/src/pages/account/model/positions.fixtures.ts
@@ -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",
},
diff --git a/src/pages/account/model/positions.test.ts b/src/pages/account/model/positions.test.ts
index b6f905d..e70943c 100644
--- a/src/pages/account/model/positions.test.ts
+++ b/src/pages/account/model/positions.test.ts
@@ -2,9 +2,19 @@ import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
-import { toAccountPagePositionSections } from "./positions";
+import {
+ formatPositionReturnPercent,
+ toAccountPagePositionSections,
+ toUnrealizedReturns,
+} from "./positions";
import { portfolio, position, second } from "./positions.fixtures";
+function makePosition(
+ overrides: Partial,
+): Portfolio["positions"][number] {
+ return { ...position, ...overrides };
+}
+
function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({
...portfolio,
@@ -82,6 +92,8 @@ describe("account position sections", () => {
{
displayName: "Акция",
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
+ returnPercent: null,
+ returnPercentText: null,
returnText: "+10 ₽",
returnTone: "positive",
shareText: "66,7 %",
@@ -95,6 +107,8 @@ describe("account position sections", () => {
{
displayName: "Облигация",
logoSrc: null,
+ returnPercent: "-11.111111",
+ returnPercentText: "-11,11 %",
returnText: "-5 ₽",
returnTone: "negative",
shareText: "33,3 %",
@@ -107,6 +121,8 @@ describe("account position sections", () => {
{
displayName: "Фонд",
logoSrc: null,
+ returnPercent: null,
+ returnPercentText: null,
returnText: null,
returnTone: undefined,
shareText: null,
@@ -187,3 +203,109 @@ describe("account position sections", () => {
expect(source).toEqual(before);
});
});
+
+describe("account position returns", () => {
+ it.each([
+ {
+ absoluteReturn: { currency: "RUB", value: "25" },
+ averagePurchasePrice: { currency: "RUB", value: "280" },
+ expected: "7.142857",
+ name: "gain",
+ quantity: "1.25",
+ type: "STOCK",
+ },
+ {
+ absoluteReturn: { currency: "RUB", value: "-25" },
+ averagePurchasePrice: { currency: "RUB", value: "280" },
+ expected: "-7.142857",
+ name: "loss",
+ quantity: "1.25",
+ type: "STOCK",
+ },
+ {
+ absoluteReturn: { currency: "RUB", value: "-5" },
+ averagePurchasePrice: { currency: "RUB", value: "45" },
+ expected: "-11.111111",
+ name: "bond without accrued interest",
+ quantity: "1",
+ type: "BOND",
+ },
+ ] as const)("calculates $name from the clean purchase cost", (testCase) => {
+ const result = sections([makePosition({
+ absoluteReturn: testCase.absoluteReturn,
+ averagePurchasePrice: testCase.averagePurchasePrice,
+ quantity: testCase.quantity,
+ type: testCase.type,
+ })]);
+
+ expect(result[0]?.groups[0]?.positions[0].returnPercent)
+ .toBe(testCase.expected);
+ });
+
+ it.each([
+ { name: "zero quantity", overrides: { quantity: "0" } },
+ { name: "negative quantity", overrides: { quantity: "-1" } },
+ { name: "missing average price", overrides: { averagePurchasePrice: null } },
+ {
+ name: "currency mismatch",
+ overrides: { averagePurchasePrice: { currency: "USD", value: "280" } },
+ },
+ { name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } },
+ ])("returns null for $name", ({ overrides }) => {
+ const result = sections([makePosition({
+ absoluteReturn: { currency: "RUB", value: "25" },
+ averagePurchasePrice: { currency: "RUB", value: "280" },
+ quantity: "1.25",
+ ...overrides,
+ })]);
+
+ expect(result[0]?.groups[0]?.positions[0].returnPercent).toBeNull();
+ });
+
+ it.each([
+ { absoluteReturn: "1", expected: "0.000001" },
+ { absoluteReturn: "-1", expected: "-0.000001" },
+ ])("rounds an exact half away from zero for $absoluteReturn", (testCase) => {
+ const result = sections([makePosition({
+ absoluteReturn: { currency: "RUB", value: testCase.absoluteReturn },
+ averagePurchasePrice: { currency: "RUB", value: "200000000" },
+ quantity: "1",
+ })]);
+
+ expect(result[0]?.groups[0]?.positions[0].returnPercent)
+ .toBe(testCase.expected);
+ });
+
+ it.each([
+ { expected: "+1.01", value: "1.005" },
+ { expected: "-1.01", value: "-1.005" },
+ ])("formats $value for display with a sign and two decimals", (testCase) => {
+ expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected);
+ });
+
+ it("sums complete unrealized returns by currency", () => {
+ const result = toUnrealizedReturns([
+ makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }),
+ makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }),
+ makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }),
+ ]);
+
+ expect(result).toEqual([
+ { currency: "RUB", value: "0.3" },
+ { currency: "USD", value: "-2.5" },
+ ]);
+ });
+
+ it("omits only currencies with incomplete returns", () => {
+ const result = toUnrealizedReturns([
+ makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }),
+ makePosition({
+ absoluteReturn: null,
+ currentValue: { currency: "RUB", value: "100" },
+ }),
+ makePosition({ absoluteReturn: { currency: "USD", value: "2" } }),
+ ]);
+
+ expect(result).toEqual([{ currency: "USD", value: "2" }]);
+ });
+});
diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts
index 63414f3..8fd431f 100644
--- a/src/pages/account/model/positions.ts
+++ b/src/pages/account/model/positions.ts
@@ -1,9 +1,19 @@
import { formatShare, type Portfolio } from "@/entities/portfolio";
-import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
+import {
+ type Decimal,
+ decimalFromString,
+ decimalToString,
+ formatDecimal,
+ formatMoney,
+ instrumentLogoUrl,
+ sumDecimals,
+} from "@/shared/lib";
type AccountPagePosition = Portfolio["positions"][number] & {
instrumentTo?: string;
logoSrc: null | string;
+ returnPercent: null | string;
+ returnPercentText: null | string;
returnText: null | string;
returnTone?: AccountPageReturnTone;
shareText: null | string;
@@ -21,10 +31,44 @@ type AccountPagePositionSection = {
type AccountPageReturnTone = "negative" | "positive";
+type Money = NonNullable;
+
type PositionSectionKind = "bonds"
| "other"
| "stocks";
+const RADIX = 10n;
+const PERCENT_SCALE = 6;
+const DISPLAY_PERCENT_SCALE = 2;
+const PERCENT_FACTOR = 100n;
+const ROUNDING_FACTOR = 2n;
+
+function formatPositionReturnPercent(value: string): string {
+ const decimal = decimalFromString(value);
+ let rounded = decimal.value;
+
+ if (decimal.scale > DISPLAY_PERCENT_SCALE) {
+ const divisor = RADIX ** BigInt(decimal.scale - DISPLAY_PERCENT_SCALE);
+ const remainder = rounded % divisor;
+ const absoluteRemainder = remainder < 0n ? -remainder : remainder;
+ rounded /= divisor;
+
+ if (absoluteRemainder * ROUNDING_FACTOR >= divisor) {
+ rounded += decimal.value < 0n ? -1n : 1n;
+ }
+ }
+ else {
+ rounded *= RADIX ** BigInt(DISPLAY_PERCENT_SCALE - decimal.scale);
+ }
+
+ const sign = rounded > 0n ? "+" : "";
+
+ return `${sign}${decimalToString({
+ scale: DISPLAY_PERCENT_SCALE,
+ value: rounded,
+ })}`;
+}
+
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
@@ -67,6 +111,41 @@ function positionIdentity(position: AccountPagePosition) {
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
}
+function returnPercent(
+ position: Portfolio["positions"][number],
+): null | string {
+ const result = position.absoluteReturn;
+ const price = position.averagePurchasePrice;
+ if (!result || !price || result.currency !== price.currency) return null;
+
+ const quantity = decimalFromString(position.quantity);
+ const purchasePrice = decimalFromString(price.value);
+
+ const cost = {
+ scale: quantity.scale + purchasePrice.scale,
+ value: quantity.value * purchasePrice.value,
+ };
+
+ if (quantity.value <= 0n || cost.value <= 0n) return null;
+
+ const absoluteReturn = decimalFromString(result.value);
+
+ const numerator = absoluteReturn.value * PERCENT_FACTOR
+ * RADIX ** BigInt(cost.scale + PERCENT_SCALE);
+
+ const denominator = cost.value * RADIX ** BigInt(absoluteReturn.scale);
+ const remainder = numerator % denominator;
+
+ const isRoundsUp = (remainder < 0n ? -remainder : remainder) * ROUNDING_FACTOR
+ >= denominator;
+
+ const rounding = isRoundsUp ? 1n : 0n;
+ const signedRounding = numerator < 0n ? -rounding : rounding;
+ const rounded = numerator / denominator + signedRounding;
+
+ return decimalToString({ scale: PERCENT_SCALE, value: rounded });
+}
+
function toAccountPagePositionSections(
portfolio: Portfolio,
): AccountPagePositionSection[] {
@@ -111,6 +190,8 @@ function toPosition(
const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
+ const percent = returnPercent(position);
+
return {
...position,
instrumentTo:
@@ -120,6 +201,10 @@ function toPosition(
logoSrc: isExpanded
? instrumentLogoUrl(position.logoName)
: null,
+ returnPercent: percent,
+ returnPercentText: percent
+ ? `${formatDecimal(formatPositionReturnPercent(percent))} %`
+ : null,
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: null,
@@ -162,9 +247,46 @@ function toSection(
};
}
+function toUnrealizedReturns(
+ positions: Portfolio["positions"],
+): Money[] {
+ const totals = new Map();
+
+ for (const position of positions) {
+ const currencies = position.absoluteReturn
+ ? [position.absoluteReturn.currency]
+ : [...new Set([
+ position.averagePurchasePrice?.currency,
+ position.currentValue?.currency,
+ ].filter((currency): currency is string => !!currency))];
+
+ for (const currency of currencies) {
+ const total = totals.get(currency) ?? { complete: true, values: [] };
+
+ if (position.absoluteReturn) {
+ total.values.push(decimalFromString(position.absoluteReturn.value));
+ }
+ else {
+ total.complete = false;
+ }
+
+ totals.set(currency, total);
+ }
+ }
+
+ return [...totals].flatMap(([currency, total]) => total.complete
+ ? [{ currency, value: decimalToString(sumDecimals(total.values)) }]
+ : []);
+}
+
export {
type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection,
+ formatPositionReturnPercent,
toAccountPagePositionSections,
+ toUnrealizedReturns,
};
diff --git a/src/pages/account/model/previews.ts b/src/pages/account/model/previews.ts
index 55e255e..448379b 100644
--- a/src/pages/account/model/previews.ts
+++ b/src/pages/account/model/previews.ts
@@ -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",
};
}
diff --git a/src/pages/account/ui/AccountPage/AccountPage.module.css b/src/pages/account/ui/AccountPage/AccountPage.module.css
index 9c0e130..b43f25a 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.module.css
+++ b/src/pages/account/ui/AccountPage/AccountPage.module.css
@@ -98,21 +98,13 @@
font-family: var(--mantine-font-family-monospace);
}
-.accordionControl {
- transition:
- background-color 150ms ease,
- color 150ms ease;
+.tabsList {
+ flex-wrap: nowrap;
+ overflow-x: auto;
}
-.accordionLabel {
- font-size: var(--mantine-h2-font-size);
- font-weight: var(--mantine-h2-font-weight);
- line-height: var(--mantine-h2-line-height);
-}
-
-.accordionPanel {
- padding-left: 0;
- padding-right: 0;
+.positionFilter {
+ overflow-x: auto;
}
.accountPositions {
diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
index 2ad05c9..296a08a 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
@@ -1,15 +1,19 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
+ fireEvent,
render,
screen,
waitFor,
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";
@@ -23,13 +27,30 @@ import type {
AccountPageRecentOperations,
} from "../../model/previews";
-import { toAccountPagePositionSections } from "../../model/positions";
+import { toConcentration } from "../../model/concentration";
+import {
+ toAccountPagePositionSections,
+ toUnrealizedReturns,
+} from "../../model/positions";
import { AccountPage } from "./AccountPage";
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";
+function AccountSwitcher() {
+ const navigate = useNavigate();
+
+ return (
+ navigate("/accounts/account-2")}
+ type="button"
+ >
+ Другой счёт
+
+ );
+}
+
function configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
@@ -45,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
+
+
} path="/accounts/:accountId" />
@@ -79,8 +102,10 @@ const portfolio
displayName: "Вторая",
figi: "FIGI-2",
isin: null,
+ issuer: null,
logoName: "vtoraya.png",
quantity: "2",
+ sector: null,
ticker: null,
type: "STOCK",
},
@@ -91,8 +116,10 @@ const portfolio
displayName: "Первая",
figi: "FIGI-1",
isin: "RU000A0JX0J2",
+ issuer: null,
logoName: null,
quantity: "1",
+ sector: null,
ticker: null,
type: "BOND",
},
@@ -111,8 +138,10 @@ function position(
displayName: "Инструмент",
figi: null,
isin: null,
+ issuer: null,
logoName: null,
quantity: "1",
+ sector: null,
ticker: null,
type: "STOCK",
...overrides,
@@ -129,6 +158,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,
@@ -136,16 +178,371 @@ function emptyProps() {
};
}
+const LoadedHarness = (
+ props: Omit[0]>, "onTabChange">,
+) => {
+ const [tab, setTab] = useState(props.tab ?? "positions");
+
+ return (
+ 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("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(
+ ,
+ );
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(screen.getByText("SBER")).toBeInTheDocument();
+
+ view.unmount();
+
+ renderPage(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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", () =>
+ HttpResponse.json(resource(portfolio, { freshness: "stale" })),
+ ),
+ );
+ renderAccountRoute(createTestQueryClient());
+
+ expect(
+ await screen.findByText("Данные требуют обновления"),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/Данные на 14\.08\.2026/)).toBeInTheDocument();
+ });
+
it.each(
["account-1", "all"].flatMap(accountId =>
["STOCK", "BOND"].flatMap(type =>
@@ -246,7 +643,7 @@ describe("AccountPage states", () => {
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
expect(
screen.getAllByRole("definition").map(node => node.textContent),
- ).toEqual(["1 234,5 ₽", "0,5 $"]);
+ ).toEqual(["1 234,5 ₽"]);
});
it("keeps initial placeholders decorative without amounts or focus targets", () => {
const { container } = renderPage( );
@@ -286,7 +683,7 @@ describe("AccountPage states", () => {
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage(
- {
/>,
);
+ fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
+
const balances = within(
- screen.getByRole("region", { name: "Денежные остатки" }),
+ screen.getByRole("tabpanel", { name: "Деньги" }),
);
expect(
@@ -318,7 +717,12 @@ describe("AccountPage states", () => {
).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
expect(
screen.getAllByRole("definition").map(node => node.textContent),
- ).toEqual(["1 234,5 ₽", "234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
+ ).toEqual([
+ "1 234,5 ₽",
+ "234,500000000000000001 ₽",
+ "-0,5 $",
+ "3,00 XYZ",
+ ]);
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
expect(
screen.queryByText(/полная оценка|общая стоимость/i),
@@ -341,6 +745,7 @@ describe("AccountPage states", () => {
);
renderAccountRoute(createTestQueryClient());
await screen.findByRole("heading", { level: 1, name: "Портфель" });
+ fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
expect(screen.getByRole("definition")).toHaveTextContent("0,5 $");
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
},
@@ -381,9 +786,10 @@ describe("AccountPage states", () => {
).not.toBeInTheDocument();
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
expect(
within(
- screen.getByRole("region", { name: "Денежные остатки" }),
+ screen.getByRole("tabpanel", { name: "Деньги" }),
).queryByRole("definition"),
).not.toBeInTheDocument();
expect(
@@ -395,11 +801,25 @@ describe("AccountPage states", () => {
},
);
- it("renders the empty state", () => {
- renderPage( );
+ it("renders the empty state with self-descriptive preview links", () => {
+ renderPage(
+ ,
+ );
expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Открыть журнал" }))
+ .toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Открыть будущие события" }))
+ .toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Открыть пассивный доход" }))
+ .toBeInTheDocument();
});
it("keeps a null portfolio envelope loading instead of an empty portfolio", async () => {
@@ -447,7 +867,7 @@ describe("AccountPage states", () => {
});
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
- renderPage( );
+ renderPage( );
expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }),
@@ -455,18 +875,22 @@ describe("AccountPage states", () => {
expect(screen.queryByText(/T-Bank временно недоступен/))
.not.toBeInTheDocument();
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument();
expect(
- screen.getByRole("button", { name: "Облигации" }),
+ screen.getByRole("radio", { name: "Облигации" }),
).toBeInTheDocument();
- const positions = screen
- .getAllByText(/Вторая|Первая/)
- .map(element => element.textContent);
-
- expect(positions).toEqual(["Вторая", "Первая"]);
- expect(screen.getByText("10 ₽")).toBeInTheDocument();
+ expect(
+ screen.getAllByRole("radio").map(radio => radio.value),
+ ).toEqual(["stocks", "bonds"]);
+ expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(screen.getByText("-1 ₽")).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute(
+ "src",
+ "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png",
+ );
+ fireEvent.click(screen.getByRole("radio", { name: "Облигации" }));
+ expect(screen.getByText("10 ₽")).toBeInTheDocument();
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
expect(
screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"),
@@ -475,17 +899,13 @@ describe("AccountPage states", () => {
screen.getByText("Количество: 1 · RU000A0JX0J2"),
).toBeInTheDocument();
expect(screen.queryByText("—")).not.toBeInTheDocument();
- expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute(
- "src",
- "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png",
- );
});
it.each(["STOCK", "BOND"] as const)(
"renders %s logos, fallbacks and signed returns including zero",
(type) => {
renderPage(
- {
expect(screen.getByText("Б")).toBeInTheDocument();
expect(screen.getByText("Н")).toBeInTheDocument();
expect(screen.getAllByRole("img")).toHaveLength(1);
- for (const value of ["+3 ₽", "+2 ₽", "0 ₽"])
+
+ for (const value of ["+3 ₽", "+2 ₽", "0 ₽"]) {
expect(screen.getByText(value)).toBeInTheDocument();
+ }
+
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
},
);
@@ -525,7 +948,7 @@ describe("AccountPage states", () => {
"groups %s supporting metrics into quiet single lines",
(type) => {
renderPage(
- {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
- {
expect(screen.queryByText("STOCK-ISIN")).not.toBeInTheDocument();
expect(screen.getByText("Количество: 2 · FALLBACK-ISIN"))
.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("radio", { name: "Облигации" }));
expect(screen.getByText("Количество: 3 · BOND-ISIN"))
.toBeInTheDocument();
expect(screen.queryByText("OFZ")).not.toBeInTheDocument();
@@ -571,7 +995,7 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
- {
it("uses the cached account title when available", () => {
renderPage(
- {
it("keeps the portfolio visible when recent operations fail", () => {
renderPage(
- {
/>,
);
+ fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
expect(
screen.getByText("Пока недоступно"),
).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it("uses the ledger row for a recent outgoing trade operation", () => {
renderPage(
- {
/>,
);
+ fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
expect(screen.getByText("Сделка")).toBeInTheDocument();
expect(screen.getByText("−12 ₽")).toBeInTheDocument();
});
@@ -674,7 +1101,7 @@ describe("AccountPage states", () => {
};
renderPage(
- {
/>,
);
- const section = screen.getByRole("region", { name: "Будущие события" });
+ fireEvent.click(screen.getByRole("tab", { name: "События" }));
+ const section = screen.getByRole("tabpanel", { name: "События" });
const content = within(section);
const text = section.textContent ?? "";
@@ -734,7 +1162,7 @@ describe("AccountPage states", () => {
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
expect(content.queryByText(/^\+/)).not.toBeInTheDocument();
expect(
- content.getByRole("link", { name: "Открыть будущие события" }),
+ content.getByRole("link", { name: "Подробнее" }),
).toHaveAttribute("href", "/accounts/account-1/events");
});
@@ -742,14 +1170,16 @@ describe("AccountPage states", () => {
"keeps the upcoming %s state and calendar action",
(state) => {
renderPage(
- ,
);
+ fireEvent.click(screen.getByRole("tab", { name: "События" }));
+
const content = within(
- screen.getByRole("region", { name: "Будущие события" }),
+ screen.getByRole("tabpanel", { name: "События" }),
);
if (state === "loading") {
@@ -770,14 +1200,14 @@ describe("AccountPage states", () => {
}
expect(
- content.getByRole("link", { name: "Открыть будущие события" }),
+ content.getByRole("link", { name: "Подробнее" }),
).toHaveAttribute("href", "/accounts/account-1/events");
},
);
it("keeps the portfolio visible when upcoming events fail", () => {
renderPage(
- {
/>,
);
+ fireEvent.click(screen.getByRole("tab", { name: "События" }));
expect(
screen.getByText("Данные появятся здесь, когда чтение восстановится."),
).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
@@ -857,7 +1289,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(
screen.getAllByRole("definition").map(node => node.textContent),
- ).toEqual(["1 234,5 ₽", "0,5 $"]);
+ ).toEqual(["1 234,5 ₽"]);
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
void queryClient.invalidateQueries({
@@ -867,7 +1299,7 @@ describe("AccountPage states", () => {
expect(structure).toHaveTextContent("10 ₽");
expect(
screen.getAllByRole("definition").map(node => node.textContent),
- ).toEqual(["1 234,5 ₽", "0,5 $"]);
+ ).toEqual(["1 234,5 ₽"]);
resolveRetry?.(
HttpResponse.json(
resource(
@@ -886,7 +1318,7 @@ describe("AccountPage states", () => {
await waitFor(() =>
expect(
screen.getAllByRole("definition").map(node => node.textContent),
- ).toEqual(["-0,5 $", "2,50 €"]),
+ ).toEqual(["-0,5 $"]),
);
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(structure).not.toHaveTextContent("10 ₽");
@@ -928,7 +1360,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(
screen.getAllByRole("definition").map(node => node.textContent),
- ).toEqual(["1 234,5 ₽", "0,5 $"]);
+ ).toEqual(["1 234,5 ₽"]);
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(
screen.queryByRole("status", { name: "Загрузка" }),
@@ -938,4 +1370,164 @@ describe("AccountPage states", () => {
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
});
+
+ it("opens positions by default and switches between local tabs", () => {
+ renderPage( );
+
+ expect(screen.getByRole("tab", { name: "Позиции" }))
+ .toHaveAttribute("aria-selected", "true");
+ expect(screen.getByText("Вторая")).toBeVisible();
+
+ fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
+ expect(screen.getByRole("tabpanel", { name: "Деньги" }))
+ .toBeVisible();
+ expect(screen.queryByText("Вторая")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
+ expect(screen.getByText("Последних событий пока нет.")).toBeVisible();
+ });
+
+ it("links operation, event and income previews to their detail pages", () => {
+ renderPage(
+ ,
+ );
+
+ for (const [tab, href] of [
+ ["Операции", "/accounts/account-1/operations"],
+ ["События", "/accounts/account-1/events"],
+ ["Доход", "/accounts/account-1/income"],
+ ]) {
+ fireEvent.click(screen.getByRole("tab", { name: tab }));
+ expect(screen.getByRole("link", { name: "Подробнее" }))
+ .toHaveAttribute("href", href);
+ }
+ });
+
+ it("filters positions by the kinds present in the portfolio", () => {
+ renderPage( );
+
+ expect(
+ screen.getAllByRole("radio").map(radio => radio.value),
+ ).toEqual(["stocks", "bonds"]);
+ expect(screen.getByText("Вторая")).toBeVisible();
+ expect(screen.queryByText("Первая")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("radio", { name: "Облигации" }));
+ expect(screen.getByText("Первая")).toBeVisible();
+ expect(screen.queryByText("Вторая")).not.toBeInTheDocument();
+ });
+
+ it("hides the position filter when only one kind exists", () => {
+ renderPage(
+ ,
+ );
+
+ expect(screen.queryByRole("radiogroup", { name: "Тип позиции" }))
+ .not.toBeInTheDocument();
+ });
+
+ it("hides unrealized returns for a partial portfolio", () => {
+ renderPage( );
+
+ expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
+ .not.toBeInTheDocument();
+ });
+
+ it("omits an unrealized currency total when any return is incomplete", () => {
+ renderPage(
+ ,
+ );
+
+ 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);
+ });
});
diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx
index 81b5417..e742f7f 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.tsx
@@ -25,12 +25,21 @@ const AccountPage: FC = () => {
);
};
diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts
index fe1a61b..5ecf856 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts
+++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts
@@ -1,3 +1,5 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio";
@@ -17,9 +19,17 @@ import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
+import {
+ type Concentration,
+ concentrationCurrencies,
+ type ConcentrationLabels,
+ type Slice,
+ toConcentration,
+} from "../../model/concentration";
import {
type AccountPagePositionSection,
toAccountPagePositionSections,
+ toUnrealizedReturns,
} from "../../model/positions";
import {
type AccountPageIncomeSummary,
@@ -44,10 +54,18 @@ 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[];
+ slice: Slice;
source?: InvestmentResourceMeta;
state: "loaded";
+ tab: string;
+ unrealizedReturns: Portfolio["totalsByCurrency"];
};
type AccountPageViewModel
@@ -57,14 +75,31 @@ type AccountPageViewModel
| { state: "loading" };
function isPortfolioAccessFailure(code?: string) {
- return isApiErrorCode(code, ApiErrorCode.Forbidden)
- || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
+ return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
}
function useAccountPageViewModel(): 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) =>
+ setSelection({ ...current, ...change });
+
const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, {
@@ -121,18 +156,43 @@ function useAccountPageViewModel(): AccountPageViewModel {
};
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),
recentEvents,
recentOperations,
+ slice: current.slice,
source: portfolio.meta,
state: "loaded" as const,
+ tab: current.tab,
+ unrealizedReturns: toUnrealizedReturns(portfolio.data.positions),
};
}
diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
index d3377a6..5e336a9 100644
--- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
@@ -46,9 +46,15 @@ const AccountPageEmpty: FC = (props) => {
structure={{ currencies: [], unvaluedCategories: [] }}
/>
-
+
-
+
{!!props.incomeSummary && }
diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
index 1dde06a..8348501 100644
--- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
@@ -1,6 +1,12 @@
-import type { FC } from "react";
-
-import { Accordion, Alert, Stack } from "@mantine/core";
+import {
+ Alert,
+ Group,
+ SegmentedControl,
+ Stack,
+ Tabs,
+ Text,
+} from "@mantine/core";
+import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
@@ -8,9 +14,11 @@ import type { Portfolio } from "@/entities/portfolio";
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 type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
import type { AccountPagePositionSection } from "../../../model/positions";
import type {
AccountPageIncomeSummary,
@@ -20,6 +28,7 @@ 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 { PositionSection } from "./PositionSection.view";
@@ -28,25 +37,44 @@ 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[];
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
+ slice: Slice;
source?: InvestmentResourceMeta;
+ tab: string;
+ unrealizedReturns: Portfolio["totalsByCurrency"];
};
const AccountPageLoaded: FC = (props) => {
const { t } = useTranslation();
+ const [filter, setFilter] = useState();
+ const tab = props.tab;
- const defaultValue = [
- ...props.positionSections.map(section => section.kind),
- "cash",
- "recentOperations",
- "recentEvents",
- "incomeSummary",
- ];
+ const onTabChange = props.onTabChange;
+
+ const activeSection = props.positionSections.find(
+ section => section.kind === filter,
+ ) ?? props.positionSections[0];
+
+ const positionKindLabels: Record = {
+ bonds: t("portfolio.positionKinds.bonds"),
+ other: t("portfolio.positionKinds.other"),
+ stocks: t("portfolio.positionKinds.stocks"),
+ };
+
+ const filterOptions = props.positionSections.map(section => ({
+ label: positionKindLabels[section.kind],
+ value: section.kind,
+ }));
return (
@@ -59,9 +87,31 @@ const AccountPageLoaded: FC = (props) => {
/>
{props.partial
- ? {t("investment.partial")}
+ ? tab !== "concentration" && (
+ {t("investment.partial")}
+ )
: }
+ {!props.partial && props.unrealizedReturns.length > 0 && (
+
+ {t("portfolio.unrealizedResult")}
+
+
+ {props.unrealizedReturns.map(total => (
+ {formatMoney(total)}
+ ))}
+
+
+
+ {t("portfolio.unrealizedResultCaption")}
+
+
+ )}
+
{!props.partial && (
= (props) => {
/>
)}
-
- {props.positionSections.map(section => (
-
-
- {t(positionSectionTitleKey(section.kind))}
-
+
+
+ {t("portfolio.tabs.positions")}
+
-
-
-
-
- ))}
+ {props.accountId !== "all" && (
+
+ {t("portfolio.concentration.title")}
+
+ )}
-
- {t("portfolio.cashBalances")}
+ {t("portfolio.tabs.cash")}
-
-
-
-
+
+ {t("portfolio.tabs.operations")}
+
-
- {t("ledger.recentTitle")}
+ {t("portfolio.tabs.events")}
-
-
-
-
+ {t("portfolio.tabs.income")}
+
-
- {t("events.recentTitle")}
-
-
-
-
-
-
-
- {t("income.summaryTitle")}
-
-
- {!!props.incomeSummary && (
-
+
+
+ {props.positionSections.length > 1 && (
+
+
+
)}
-
-
-
+
+ {activeSection && }
+
+
+
+ {props.accountId !== "all" && (
+
+ {props.concentration && (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!!props.incomeSummary && (
+
+ )}
+
+
);
};
-function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
- if (kind === "stocks") return "portfolio.stocks";
-
- return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
-}
-
export { AccountPageLoaded };
diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx
index acf5c80..23ce70b 100644
--- a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx
@@ -34,7 +34,9 @@ const AccountPageLoading: FC = () => {
- {["first", "second", "third"].map(placeholder => )}
+
+
+
);
};
diff --git a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css
new file mode 100644
index 0000000..5a204c1
--- /dev/null
+++ b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css
@@ -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; }
+}
diff --git a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx
new file mode 100644
index 0000000..9f9783a
--- /dev/null
+++ b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx
@@ -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 = props => (
+
+
+ {props.index}
+
+
+ {props.logo && }
+
+
+ {props.name}
+
+ {props.logo?.subtitle && (
+ {props.logo.subtitle}
+ )}
+
+
+
+
+
+
+
+ {amountFor(props.item, props.currency)}
+
+ {props.item.share}
+
+
+);
+
+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 = (props) => {
+ const row = props.rows[Number(props.payload?.[0]?.name)];
+ if (!row || !props.active) return;
+
+ return (
+
+ {row.label}
+
+ {row.amount} · {row.share}
+
+ );
+};
+
+const Concentration: FC = (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: };
+
+ const totalText = props.model.total
+ ? formatMoney({ currency, value: props.model.total })
+ : "";
+
+ return (
+
+
+
+
+ {props.model.currencies.length > 1 && (
+
+ )}
+
+
+ {props.partial && (
+ {t("portfolio.concentration.incomplete")}
+ )}
+
+ {props.model.empty
+ ? (
+
+ {t(sliceLabels[props.slice])}
+ {props.currency && ` · ${props.currency}`}
+
+
+ {t("portfolio.concentration.empty")}
+
+ )
+ : (
+
+
+
+
+
+ {props.model.top5Share && (
+ {t("portfolio.concentration.top5", {
+ share: props.model.top5Share,
+ })}
+
+ )}
+
+ {totalText}
+
+
+
+ {chartText}
+
+
+
+
+ {t("portfolio.concentration.title")}
+
+
+ {props.model.items.map((item, index) => (
+
+ ))}
+
+
+ {props.model.other && (
+ <>
+
+ {t("portfolio.concentration.other")} · {t(
+ "portfolio.concentration.otherCount",
+ { count: props.model.other.count },
+ )}
+
+
+ {amountFor(props.model.other, currency)} · {
+ props.model.other.share
+ }
+
+
+
+
+ {props.model.other.entries.map((item, index) => (
+
+ ))}
+
+ >
+ )}
+
+
+ )}
+
+ {props.model.unaccounted > 0 && (
+ {t(
+ "portfolio.concentration.unaccounted",
+ { count: props.model.unaccounted },
+ )}
+
+ )}
+
+ );
+};
+
+export { Concentration };
diff --git a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
index 56d91a3..d6a3130 100644
--- a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
@@ -9,8 +9,8 @@ import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
-type IncomeSummaryProps
- = | {
+type IncomeSummaryProps = { moreLabel?: string } & (
+ | {
state: "empty";
to: string;
}
@@ -27,7 +27,8 @@ type IncomeSummaryProps
| {
state: "partial";
to: string;
- };
+ }
+);
const IncomeSummary: FC = (props) => {
const { t } = useTranslation();
@@ -56,7 +57,7 @@ const IncomeSummary: FC = (props) => {
{t("income.summaryEmpty")}
- {t("income.open")}
+ {props.moreLabel ?? t("income.open")}
);
@@ -75,7 +76,7 @@ const IncomeSummary: FC = (props) => {
))}
- {t("income.open")}
+ {props.moreLabel ?? t("income.open")}
);
diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx
index 526694e..32f91a0 100644
--- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx
+++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx
@@ -12,11 +12,15 @@ const syntheticPosition
displayName: "Учебные технологии",
figi: null,
isin: null,
+ issuer: null,
logoName: null,
logoSrc: null,
quantity: "100",
+ returnPercent: "25",
+ returnPercentText: "+25,00 %",
returnText: "+5 000 ₽",
returnTone: "positive",
+ sector: null,
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
@@ -73,6 +77,8 @@ export const BondLoss: Story
"Учебные облигации для долгосрочных накоплений, выпуск 2030",
isin: "DEMO-BOND-2030",
quantity: "10",
+ returnPercent: "-5",
+ returnPercentText: "-5,00 %",
returnText: "−500 ₽",
returnTone: "negative",
shareText: "7,6%",
@@ -123,6 +129,8 @@ export const MissingValuation: Story
averagePurchasePrice: null,
currentValue: null,
displayName: "Учебная позиция без текущей оценки",
+ returnPercent: null,
+ returnPercentText: null,
returnText: null,
returnTone: undefined,
shareText: null,
diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx
index a83205f..0ae8ee2 100644
--- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx
@@ -6,7 +6,10 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
-import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions";
+import type {
+ AccountPagePosition,
+ AccountPagePositionSection,
+} from "../../../../model/positions";
import classes from "../../AccountPage.module.css";
import { PositionLogo } from "../PositionLogo.view";
@@ -26,8 +29,15 @@ const PositionCard: FC = (props) => {
: undefined;
let returnText = position.returnText;
- if (returnText && !isExpanded)
+
+ if (returnText && position.returnPercentText) {
+ returnText += ` · ${position.returnPercentText}`;
+ }
+
+ if (returnText && !isExpanded) {
returnText = t("portfolio.return", { value: returnText });
+ }
+
const instrumentTo = position.instrumentTo;
const identifier
diff --git a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx
index 25e0ddd..2e5753f 100644
--- a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx
@@ -6,7 +6,10 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
-import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions";
+import type {
+ AccountPagePositionGroup,
+ AccountPagePositionSection,
+} from "../../../model/positions";
import classes from "../AccountPage.module.css";
import { PositionLogo } from "./PositionLogo.view";
@@ -60,6 +63,16 @@ const PositionGroupCard: FC = (props) => {
? classes[position.returnTone]
: undefined;
+ const returnPercent = position.returnPercentText
+ ? ` · ${position.returnPercentText}`
+ : "";
+
+ const returnValue = `${position.returnText ?? ""}${returnPercent}`;
+
+ const returnText = kind === "other"
+ ? t("portfolio.return", { value: returnValue })
+ : returnValue;
+
return (
= (props) => {
{!!position.returnText && (
- {kind === "other"
- ? t("portfolio.return", { value: position.returnText })
- : position.returnText}
+ {returnText}
)}
diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
index 84d5918..4aa7e60 100644
--- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
@@ -5,11 +5,18 @@ 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;
};
@@ -31,20 +38,44 @@ const RecentOperations: FC = (props) => {
{operations.state === "partial" && {t("investment.partial")} }
- {(operations.state === "loaded" || operations.state === "partial")
- && operations.items.map(operation => (
-
- ))}
+ {(operations.state === "loaded" || operations.state === "partial") && (
+
+ {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"
+ ? (
+
+ )
+ : (
+
+
+
+ ))}
+
+ )}
- {t("ledger.open")}
+ {props.moreLabel ?? t("ledger.open")}
);
diff --git a/src/pages/account/ui/AccountPage/position-groups.test.tsx b/src/pages/account/ui/AccountPage/position-groups.test.tsx
index 278bb1e..12c869b 100644
--- a/src/pages/account/ui/AccountPage/position-groups.test.tsx
+++ b/src/pages/account/ui/AccountPage/position-groups.test.tsx
@@ -36,6 +36,8 @@ describe("Positions grouped by security", () => {
).toHaveAttribute("href", "/accounts/a");
expect(within(rows[0]).getByText("Количество: 1.25")).toBeInTheDocument();
expect(within(rows[0]).getByText("375 ₽")).toBeInTheDocument();
+ expect(within(rows[0]).getByText("+25 ₽ · +7,14 %"))
+ .toBeInTheDocument();
expect(
within(rows[0]).getByText("Средняя цена: 280 ₽"),
).toBeInTheDocument();
diff --git a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx
index 62ff297..64885df 100644
--- a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx
+++ b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx
@@ -37,10 +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,
diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
index 851d087..6da65ee 100644
--- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
@@ -31,6 +31,19 @@ function configureTestAuth() {
});
}
+function portfolioSummary(totals: { currency: string; value: string }[]) {
+ return {
+ bondPositionCount: 1,
+ cashBalances: [],
+ fundPositionCount: 0,
+ stockPositionCount: 0,
+ structure: { currencies: [], unvaluedCategories: [] },
+ totalPositionCount: 1,
+ totalsByCurrency: totals,
+ unrealizedReturnByCurrency: [],
+ };
+}
+
function renderPage(queryClient: QueryClient) {
configureTestAuth();
@@ -46,6 +59,65 @@ function renderPage(queryClient: QueryClient) {
}
describe("AccountsPage", () => {
+ it("summarizes currencies separately and reports excluded accounts", async () => {
+ server.use(http.get("*/api/v1/brokerage-accounts", () =>
+ HttpResponse.json(resource([
+ {
+ ...account,
+ portfolioSummary: portfolioSummary([
+ { currency: "RUB", value: "100.10" },
+ { currency: "USD", value: "20" },
+ ]),
+ },
+ {
+ ...account,
+ id: "account-2",
+ name: "Второй",
+ portfolioSummary: portfolioSummary([
+ { currency: "RUB", value: "50.20" },
+ ]),
+ },
+ {
+ ...account,
+ id: "account-3",
+ name: "Без сводки",
+ portfolioSummary: null,
+ },
+ {
+ ...account,
+ id: "account-4",
+ name: "Только деньги",
+ portfolioSummary: {
+ ...portfolioSummary([{ currency: "RUB", value: "25" }]),
+ bondPositionCount: 0,
+ cashBalances: [{ currency: "RUB", value: "25" }],
+ totalPositionCount: 0,
+ },
+ },
+ {
+ ...account,
+ id: "account-5",
+ name: "Без сумм",
+ portfolioSummary: {
+ ...portfolioSummary([]),
+ totalPositionCount: 0,
+ },
+ },
+ ])),
+ ));
+ const client = createTestQueryClient();
+
+ renderPage(client);
+
+ expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
+ expect(screen.getAllByText("20,00 USD")).not.toHaveLength(0);
+ expect(screen.getByText("5 счетов")).toBeInTheDocument();
+ expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
+ expect(screen.getByText(
+ "Ещё 1 счёт не включён: нет актуального портфеля",
+ )).toBeInTheDocument();
+ });
+
it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (null | string)[] = [];
server.use(
diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
index ec58f96..96d5d88 100644
--- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
@@ -1,6 +1,6 @@
import type { FC } from "react";
-import { Button, Stack, Text, Title } from "@mantine/core";
+import { Button, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -10,15 +10,82 @@ import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
+import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
+const DECIMAL_BASE = 10n;
+const DECIMAL_PART_COUNT = 2;
+const ZERO = 0n;
+
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta;
};
+type Money = { currency: string; value: string };
+
+function accountTotals(accounts: BrokerageAccountCard[]) {
+ const values = new Map();
+ let excludedCount = 0;
+
+ for (const { summary } of accounts) {
+ if (summary.state === "empty") continue;
+
+ if (summary.state === "unavailable") {
+ excludedCount += 1;
+ continue;
+ }
+
+ for (const total of summary.portfolio.totalsByCurrency) {
+ values.set(total.currency, [
+ ...values.get(total.currency) ?? [],
+ total.value,
+ ]);
+ }
+ }
+
+ const totals: Money[] = Array.from(values, ([currency, amounts]) => ({
+ currency,
+ value: sumDecimals(amounts),
+ }));
+
+ return { excludedCount, totals };
+}
+
+function sumDecimals(values: string[]) {
+ const scale = Math.max(
+ 0,
+ ...values.map(value =>
+ value.split(".", DECIMAL_PART_COUNT)[1]?.length ?? 0),
+ );
+
+ const factor = DECIMAL_BASE ** BigInt(scale);
+ let sum = ZERO;
+
+ for (const value of values) {
+ const isNegative = value.startsWith("-");
+
+ const [integer = "0", fraction = ""] = (isNegative
+ ? value.slice(1)
+ : value).split(".", DECIMAL_PART_COUNT);
+
+ const units = BigInt(integer) * factor
+ + BigInt(fraction.padEnd(scale, "0") || "0");
+
+ sum += isNegative ? -units : units;
+ }
+
+ const isNegative = sum < ZERO;
+ const digits = (isNegative ? -sum : sum).toString().padStart(scale + 1, "0");
+ const integer = scale ? digits.slice(0, -scale) : digits;
+ const fraction = scale ? `.${digits.slice(-scale)}` : "";
+
+ return `${isNegative ? "-" : ""}${integer}${fraction}`;
+}
+
const AccountsPageLoaded: FC = (props) => {
const { t } = useTranslation();
+ const { excludedCount, totals } = accountTotals(props.accounts);
return (
@@ -28,6 +95,37 @@ const AccountsPageLoaded: FC = (props) => {
{t("accounts.subtitle")}
+
+
+
+
+
+
+ {t("accounts.currentValue")}
+
+
+ {totals.length > 0
+ ? totals.map(total => (
+
+ {formatMoney(total, { withRoundedCents: true })}
+
+ ))
+ : {t("accounts.summaryNoTotals")} }
+
+
+
+ {t("accounts.count", {
+ count: props.accounts.length,
+ })}
+
+
+ {excludedCount > 0 && (
+
+ {t("accounts.summaryExcluded", { count: excludedCount })}
+
+ )}
+
+
{
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
- return HttpResponse.json({ code: "FORBIDDEN" }, { status });
+ // Any non-transient code; status alone must prevent the retry.
+ return HttpResponse.json(
+ { code: "INTERNAL_SERVER_ERROR", status },
+ { status },
+ );
}),
);
configureAuthClient({
diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts
index 8ac77e2..bd4009c 100644
--- a/src/shared/api/client.ts
+++ b/src/shared/api/client.ts
@@ -166,7 +166,6 @@ function isNetworkFailure(error: unknown): boolean {
function isPermanentCode(code?: string): boolean {
return [
- ApiErrorCode.Forbidden,
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index ba8d0ce..a9a7864 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -731,8 +731,11 @@ export interface components {
ProblemDetailsDto: {
/** @example 401 */
status: number;
- /** @example UNAUTHORIZED */
- code: string;
+ /**
+ * @example UNAUTHORIZED
+ * @enum {string}
+ */
+ code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | "UNSUPPORTED_MEDIA_TYPE" | "VALIDATION_ERROR" | "EMAIL_ALREADY_REGISTERED" | "INVALID_CREDENTIALS" | "INVALID_OPERATIONS_CURSOR" | "BOND_HISTORY_START_UNAVAILABLE" | "BOND_TICKER_AMBIGUOUS" | "STOCK_HISTORY_START_UNAVAILABLE" | "STOCK_TICKER_AMBIGUOUS" | "INVALID_TBANK_TOKEN" | "TBANK_NOT_CONNECTED" | "TBANK_UNAVAILABLE" | "BANK_CARD_ARCHIVED" | "BANK_DEPENDENT_REFUNDS" | "BANK_DUPLICATE_CATEGORY" | "BANK_IMMUTABLE_OPERATION" | "BANK_INTENT_CONFLICT" | "BANK_INVALID_DATE" | "BANK_INVALID_INPUT" | "BANK_INVALID_MONEY" | "BANK_INVALID_REFUND" | "BANK_NOT_FOUND" | "BANK_OPENING_DATE_CONFLICT" | "BANK_OPERATION_DELETED" | "BANK_REFUND_LIMIT" | "BANK_VERSION_CONFLICT" | "BANK_WRITE_CONFLICT" | "MOEX_UNAVAILABLE" | "NCR_UNAVAILABLE" | "INVALID_EVENTS_PERIOD" | "INVALID_INCOME_PERIOD";
/**
* @example [
* {
@@ -909,12 +912,54 @@ export interface components {
version: number;
archived?: boolean;
};
+ PortfolioStructureCategoryDto: {
+ /** @enum {string} */
+ type: "STOCK" | "BOND" | "OTHER" | "CASH";
+ /** @description Exact known value as a decimal string. */
+ value: string;
+ /** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */
+ sharePercent: string | null;
+ };
+ PortfolioStructureCurrencyDto: {
+ currency: string;
+ totalValue: string;
+ /** @description Any negative constituent, even if netted positive. */
+ hasNegativeValues: boolean;
+ categories: components["schemas"]["PortfolioStructureCategoryDto"][];
+ };
+ PortfolioUnvaluedCategoryDto: {
+ /** @enum {string} */
+ type: "STOCK" | "BOND" | "OTHER";
+ count: number;
+ };
+ PortfolioStructureDto: {
+ currencies: components["schemas"]["PortfolioStructureCurrencyDto"][];
+ /** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */
+ unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][];
+ };
+ UnrealizedReturnDto: {
+ currency: string;
+ /** @example 12.345678901 */
+ absolute: string;
+ /** @example 100.00 */
+ costBasis: string;
+ /** @example 12.34 */
+ percent: string | null;
+ partial: boolean;
+ };
PortfolioSummaryDto: {
totalsByCurrency: components["schemas"]["MoneyDto"][];
+ cashBalances: components["schemas"]["MoneyDto"][];
+ structure: components["schemas"]["PortfolioStructureDto"];
+ unrealizedReturnByCurrency: components["schemas"]["UnrealizedReturnDto"][];
/** @description Number of non-zero stock positions, not the number of units. */
stockPositionCount: number;
/** @description Number of non-zero bond positions, not the number of units. */
bondPositionCount: number;
+ /** @description Number of non-zero fund positions, not the number of units. */
+ fundPositionCount: number;
+ /** @description Number of all non-zero positions, not the number of units. */
+ totalPositionCount: number;
};
BrokerageAccountDto: {
id: string;
@@ -1011,6 +1056,10 @@ export interface components {
id: string;
name: string;
};
+ PortfolioIssuerDto: {
+ id: string;
+ name: string;
+ };
PortfolioPositionDto: {
instrumentUid?: string | null;
account?: components["schemas"]["AccountReferenceDto"];
@@ -1025,31 +1074,8 @@ export interface components {
isin: string | null;
ticker: string | null;
logoName: string | null;
- };
- PortfolioStructureCategoryDto: {
- /** @enum {string} */
- type: "STOCK" | "BOND" | "OTHER" | "CASH";
- /** @description Exact known value as a decimal string. */
- value: string;
- /** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */
- sharePercent: string | null;
- };
- PortfolioStructureCurrencyDto: {
- currency: string;
- totalValue: string;
- /** @description Any negative constituent, even if netted positive. */
- hasNegativeValues: boolean;
- categories: components["schemas"]["PortfolioStructureCategoryDto"][];
- };
- PortfolioUnvaluedCategoryDto: {
- /** @enum {string} */
- type: "STOCK" | "BOND" | "OTHER";
- count: number;
- };
- PortfolioStructureDto: {
- currencies: components["schemas"]["PortfolioStructureCurrencyDto"][];
- /** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */
- unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][];
+ sector: string | null;
+ issuer: components["schemas"]["PortfolioIssuerDto"] | null;
};
PortfolioDto: {
/** Format: date-time */
@@ -1363,6 +1389,8 @@ export interface components {
isin: string | null;
ticker: string | null;
logoName: string | null;
+ sector: string | null;
+ issuer: components["schemas"]["PortfolioIssuerDto"] | null;
returnPercent: string | null;
};
StockPositionDto: {
diff --git a/src/shared/api/problem-details.ts b/src/shared/api/problem-details.ts
index b0c2c63..bcea7d5 100644
--- a/src/shared/api/problem-details.ts
+++ b/src/shared/api/problem-details.ts
@@ -1,3 +1,5 @@
+import type { components } from "./generated/schema";
+
enum ApiErrorCode {
BankDependentRefunds = "BANK_DEPENDENT_REFUNDS",
BankIntentConflict = "BANK_INTENT_CONFLICT",
@@ -6,7 +8,6 @@ enum ApiErrorCode {
BankVersionConflict = "BANK_VERSION_CONFLICT",
BankWriteConflict = "BANK_WRITE_CONFLICT",
BondTickerAmbiguous = "BOND_TICKER_AMBIGUOUS",
- Forbidden = "FORBIDDEN",
InvalidOperationsCursor = "INVALID_OPERATIONS_CURSOR",
InvalidTbankToken = "INVALID_TBANK_TOKEN",
NotFound = "NOT_FOUND",
@@ -16,6 +17,16 @@ enum ApiErrorCode {
Unauthorized = "UNAUTHORIZED",
}
+/**
+Compile-time drift guard: collapses to `never` when any enum member is absent
+from the generated ProblemDetails contract, so every call site fails to build.
+*/
+type ContractApiErrorCode
+ = Exclude<
+ `${ApiErrorCode}`,
+ components["schemas"]["ProblemDetailsDto"]["code"]
+ > extends never ? ApiErrorCode : never;
+
function apiErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
@@ -23,7 +34,10 @@ function apiErrorCode(error: unknown): string | undefined {
}
// Incoming codes are an open wire string; unknown values retain their fallback.
-function isApiErrorCode(value: unknown, expected: ApiErrorCode): boolean {
+function isApiErrorCode(
+ value: unknown,
+ expected: ContractApiErrorCode,
+): boolean {
return value === expected;
}
diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts
index 4476aef..ca6ad06 100644
--- a/src/shared/api/resource-query.ts
+++ b/src/shared/api/resource-query.ts
@@ -42,7 +42,6 @@ function invalidateResourceAccess(
const errorCode = apiErrorCode(error);
if (
[
- ApiErrorCode.Forbidden,
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json
index 8ed9d81..d3881bd 100644
--- a/src/shared/api/spec/openapi.json
+++ b/src/shared/api/spec/openapi.json
@@ -5204,7 +5204,44 @@
},
"code": {
"type": "string",
- "example": "UNAUTHORIZED"
+ "example": "UNAUTHORIZED",
+ "enum": [
+ "BAD_REQUEST",
+ "INTERNAL_SERVER_ERROR",
+ "NOT_FOUND",
+ "UNAUTHORIZED",
+ "UNSUPPORTED_MEDIA_TYPE",
+ "VALIDATION_ERROR",
+ "EMAIL_ALREADY_REGISTERED",
+ "INVALID_CREDENTIALS",
+ "INVALID_OPERATIONS_CURSOR",
+ "BOND_HISTORY_START_UNAVAILABLE",
+ "BOND_TICKER_AMBIGUOUS",
+ "STOCK_HISTORY_START_UNAVAILABLE",
+ "STOCK_TICKER_AMBIGUOUS",
+ "INVALID_TBANK_TOKEN",
+ "TBANK_NOT_CONNECTED",
+ "TBANK_UNAVAILABLE",
+ "BANK_CARD_ARCHIVED",
+ "BANK_DEPENDENT_REFUNDS",
+ "BANK_DUPLICATE_CATEGORY",
+ "BANK_IMMUTABLE_OPERATION",
+ "BANK_INTENT_CONFLICT",
+ "BANK_INVALID_DATE",
+ "BANK_INVALID_INPUT",
+ "BANK_INVALID_MONEY",
+ "BANK_INVALID_REFUND",
+ "BANK_NOT_FOUND",
+ "BANK_OPENING_DATE_CONFLICT",
+ "BANK_OPERATION_DELETED",
+ "BANK_REFUND_LIMIT",
+ "BANK_VERSION_CONFLICT",
+ "BANK_WRITE_CONFLICT",
+ "MOEX_UNAVAILABLE",
+ "NCR_UNAVAILABLE",
+ "INVALID_EVENTS_PERIOD",
+ "INVALID_INCOME_PERIOD"
+ ]
},
"errors": {
"type": "array",
@@ -5819,6 +5856,135 @@
"version"
]
},
+ "PortfolioStructureCategoryDto": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "STOCK",
+ "BOND",
+ "OTHER",
+ "CASH"
+ ]
+ },
+ "value": {
+ "type": "string",
+ "description": "Exact known value as a decimal string."
+ },
+ "sharePercent": {
+ "type": "string",
+ "nullable": true,
+ "description": "Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions."
+ }
+ },
+ "required": [
+ "type",
+ "value",
+ "sharePercent"
+ ]
+ },
+ "PortfolioStructureCurrencyDto": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "type": "string"
+ },
+ "totalValue": {
+ "type": "string"
+ },
+ "hasNegativeValues": {
+ "type": "boolean",
+ "description": "Any negative constituent, even if netted positive."
+ },
+ "categories": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PortfolioStructureCategoryDto"
+ }
+ }
+ },
+ "required": [
+ "currency",
+ "totalValue",
+ "hasNegativeValues",
+ "categories"
+ ]
+ },
+ "PortfolioUnvaluedCategoryDto": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "STOCK",
+ "BOND",
+ "OTHER"
+ ]
+ },
+ "count": {
+ "type": "number",
+ "minimum": 1
+ }
+ },
+ "required": [
+ "type",
+ "count"
+ ]
+ },
+ "PortfolioStructureDto": {
+ "type": "object",
+ "properties": {
+ "currencies": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PortfolioStructureCurrencyDto"
+ }
+ },
+ "unvaluedCategories": {
+ "description": "Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PortfolioUnvaluedCategoryDto"
+ }
+ }
+ },
+ "required": [
+ "currencies",
+ "unvaluedCategories"
+ ]
+ },
+ "UnrealizedReturnDto": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "type": "string"
+ },
+ "absolute": {
+ "type": "string",
+ "example": "12.345678901"
+ },
+ "costBasis": {
+ "type": "string",
+ "example": "100.00"
+ },
+ "percent": {
+ "type": "string",
+ "nullable": true,
+ "example": "12.34"
+ },
+ "partial": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "currency",
+ "absolute",
+ "costBasis",
+ "percent",
+ "partial"
+ ]
+ },
"PortfolioSummaryDto": {
"type": "object",
"properties": {
@@ -5828,6 +5994,21 @@
"$ref": "#/components/schemas/MoneyDto"
}
},
+ "cashBalances": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/MoneyDto"
+ }
+ },
+ "structure": {
+ "$ref": "#/components/schemas/PortfolioStructureDto"
+ },
+ "unrealizedReturnByCurrency": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/UnrealizedReturnDto"
+ }
+ },
"stockPositionCount": {
"type": "integer",
"minimum": 0,
@@ -5837,12 +6018,27 @@
"type": "integer",
"minimum": 0,
"description": "Number of non-zero bond positions, not the number of units."
+ },
+ "fundPositionCount": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of non-zero fund positions, not the number of units."
+ },
+ "totalPositionCount": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of all non-zero positions, not the number of units."
}
},
"required": [
"totalsByCurrency",
+ "cashBalances",
+ "structure",
+ "unrealizedReturnByCurrency",
"stockPositionCount",
- "bondPositionCount"
+ "bondPositionCount",
+ "fundPositionCount",
+ "totalPositionCount"
]
},
"BrokerageAccountDto": {
@@ -6227,6 +6423,21 @@
"name"
]
},
+ "PortfolioIssuerDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ },
"PortfolioPositionDto": {
"type": "object",
"properties": {
@@ -6299,6 +6510,19 @@
"logoName": {
"type": "string",
"nullable": true
+ },
+ "sector": {
+ "type": "string",
+ "nullable": true
+ },
+ "issuer": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/PortfolioIssuerDto"
+ }
+ ]
}
},
"required": [
@@ -6311,105 +6535,9 @@
"absoluteReturn",
"isin",
"ticker",
- "logoName"
- ]
- },
- "PortfolioStructureCategoryDto": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "enum": [
- "STOCK",
- "BOND",
- "OTHER",
- "CASH"
- ]
- },
- "value": {
- "type": "string",
- "description": "Exact known value as a decimal string."
- },
- "sharePercent": {
- "type": "string",
- "nullable": true,
- "description": "Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions."
- }
- },
- "required": [
- "type",
- "value",
- "sharePercent"
- ]
- },
- "PortfolioStructureCurrencyDto": {
- "type": "object",
- "properties": {
- "currency": {
- "type": "string"
- },
- "totalValue": {
- "type": "string"
- },
- "hasNegativeValues": {
- "type": "boolean",
- "description": "Any negative constituent, even if netted positive."
- },
- "categories": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/PortfolioStructureCategoryDto"
- }
- }
- },
- "required": [
- "currency",
- "totalValue",
- "hasNegativeValues",
- "categories"
- ]
- },
- "PortfolioUnvaluedCategoryDto": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "enum": [
- "STOCK",
- "BOND",
- "OTHER"
- ]
- },
- "count": {
- "type": "number",
- "minimum": 1
- }
- },
- "required": [
- "type",
- "count"
- ]
- },
- "PortfolioStructureDto": {
- "type": "object",
- "properties": {
- "currencies": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/PortfolioStructureCurrencyDto"
- }
- },
- "unvaluedCategories": {
- "description": "Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.",
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/PortfolioUnvaluedCategoryDto"
- }
- }
- },
- "required": [
- "currencies",
- "unvaluedCategories"
+ "logoName",
+ "sector",
+ "issuer"
]
},
"PortfolioDto": {
@@ -7812,6 +7940,19 @@
"type": "string",
"nullable": true
},
+ "sector": {
+ "type": "string",
+ "nullable": true
+ },
+ "issuer": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/PortfolioIssuerDto"
+ }
+ ]
+ },
"returnPercent": {
"type": "string",
"nullable": true
@@ -7828,6 +7969,8 @@
"isin",
"ticker",
"logoName",
+ "sector",
+ "issuer",
"returnPercent"
]
},
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index caadfb6..1e32cb3 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -5,7 +5,20 @@ const ru = {
all: "Все счета",
allDescription: "Портфель по всем доступным инвестиционным счетам",
bonds: "Облигации",
+ brokerDescription: "По данным брокера",
+ cash: "Деньги на счёте",
+ count_few: "{{count}} счёта",
+ count_many: "{{count}} счетов",
+ count_one: "{{count}} счёт",
+ count_other: "{{count}} счёта",
currentValue: "Текущая стоимость",
+ description: {
+ currentValue: "Текущая стоимость: {{value}}. ",
+ return: "Доходность {{currency}}: {{percent}}, {{absolute}}",
+ start: "Счёт {{name}}, {{type}}. ",
+ structure: "Структура {{currency}}: {{value}}",
+ structureCategory: "{{category}} {{value}}",
+ },
emptyText:
"Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.",
emptyTitle: "Нет инвестиционных счетов",
@@ -18,6 +31,11 @@ const ru = {
openAll: "Портфель по всем счетам",
openedOn: "Открыт {{date}}",
openSettings: "Открыть настройки T-Bank",
+ partial: "частично",
+ positions_few: "{{count}} позиции",
+ positions_many: "{{count}} позиций",
+ positions_one: "{{count}} позиция",
+ positions_other: "{{count}} позиции",
refreshError:
"Не удалось обновить данные. Показаны последние полученные.",
refreshing: "Обновляем данные",
@@ -28,8 +46,22 @@ const ru = {
retry: "Повторить",
scope: "Счёт",
stocks: "Акции",
+ structure: {
+ category: "{{category}} {{percent}} %",
+ incomplete: "{{category}}: доля неизвестна",
+ label: "Структура в {{currency}}: {{value}}",
+ },
subtitle: "Ваши инвестиции по счетам",
summaryEmpty: "Нет текущих сумм",
+ summaryExcluded_few:
+ "Ещё {{count}} счёта не включены: нет актуального портфеля",
+ summaryExcluded_many:
+ "Ещё {{count}} счетов не включены: нет актуального портфеля",
+ summaryExcluded_one:
+ "Ещё {{count}} счёт не включён: нет актуального портфеля",
+ summaryExcluded_other:
+ "Ещё {{count}} счёта не включены: нет актуального портфеля",
+ summaryNoTotals: "Нет данных для сводки",
summaryUnavailable: "Сводка временно недоступна",
title: "Счета",
types: {
@@ -318,6 +350,9 @@ const ru = {
operations: "Операции",
settings: "Настройки",
},
+ entryGroup: {
+ totalUnavailable: "Итог недоступен",
+ },
events: {
dateKind: { event: "Дата события", registry: "Дата реестра" },
emptyText:
@@ -420,10 +455,11 @@ const ru = {
next: "Далее",
noInstrument: "Без инструмента",
noMoney: "Без движения денег",
- occurredAt: "{{date}}",
+ occurredAt: "Исполнено {{date}}",
open: "Открыть журнал",
operationType: {
buy: "Покупка",
+ coupon: "Купон",
dividend: "Зачисление дивидендов",
fee: "Комиссия",
input: "Зачисление ценных бумаг",
@@ -450,6 +486,7 @@ const ru = {
accounts: "Счета",
bonds: "Облигации",
cards: "Карты",
+ primary: "Основная навигация",
settings: "Настройки",
stocks: "Акции",
},
@@ -472,6 +509,26 @@ const ru = {
bonds: "Облигации",
byAccount: "Позиции по счетам",
cashBalances: "Денежные остатки",
+ concentration: {
+ chart: "Распределение позиций",
+ currency: "Валюта концентрации",
+ empty: "В этом разрезе нет позиций с положительной оценкой.",
+ incomplete: "Данные неполные",
+ issuers: "Эмитенты",
+ missingIssuer: "Эмитент не указан",
+ missingSector: "Сектор не указан",
+ other: "Прочее",
+ otherCount: "{{count}} элементов",
+ otherCount_few: "{{count}} элемента",
+ otherCount_many: "{{count}} элементов",
+ otherCount_one: "{{count}} элемент",
+ otherCount_other: "{{count}} элемента",
+ sectors: "Секторы",
+ slice: "Разрез концентрации",
+ title: "Концентрация",
+ top5: "Топ-5 · {{share}}",
+ unaccounted: "Не учтено позиций: {{count}}",
+ },
connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.",
connectionErrorTitle: "Проверьте подключение T-Bank",
currencies: {
@@ -481,13 +538,21 @@ const ru = {
},
emptyText: "На счете нет позиций и доступных денежных остатков.",
emptyTitle: "Портфель пока пуст",
+ more: "Подробнее",
otherPositions: "Другие позиции",
+ positionFilter: "Тип позиции",
+ positionKinds: {
+ bonds: "Облигации",
+ other: "Прочее",
+ stocks: "Акции",
+ },
positions: "Позиции",
quantity: "Количество: {{value}}",
refreshError:
"Не удалось обновить портфель. Показан последний полученный снимок.",
refreshing: "Обновляем портфель",
return: "Доход: {{value}}",
+ sections: "Разделы счёта",
share: "Доля: {{value}}",
staleWarning:
"T-Bank временно недоступен. Показан последний успешный снимок.",
@@ -513,7 +578,16 @@ const ru = {
unvalued: "Нет оценки",
unvaluedCategory: "{{category}}: без оценки — {{count}}",
},
+ tabs: {
+ cash: "Деньги",
+ events: "События",
+ income: "Доход",
+ operations: "Операции",
+ positions: "Позиции",
+ },
title: "Портфель",
+ unrealizedResult: "Нереализованный результат",
+ unrealizedResultCaption: "по цене, без НКД и купонов",
updatedAt: "Данные получены {{date}}",
},
routeError: {
@@ -706,6 +780,7 @@ const ru = {
consumer: "Потребительский сектор",
energy: "Энергетика",
financial: "Финансы",
+ government: "Государственный сектор",
health_care: "Здравоохранение",
industrials: "Промышленность",
it: "Информационные технологии",
diff --git a/src/shared/lib/decimal.test.ts b/src/shared/lib/decimal.test.ts
new file mode 100644
index 0000000..3fcdfca
--- /dev/null
+++ b/src/shared/lib/decimal.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+
+import { sumMoney } from "./decimal";
+
+describe("sumMoney", () => {
+ it("adds exact signed decimals independently per currency", () => {
+ expect(sumMoney([
+ { currency: "RUB", value: "0.1" },
+ { currency: "USD", value: "0.01" },
+ { currency: "RUB", value: "0.2" },
+ { currency: "RUB", value: "-1500000.45" },
+ { currency: "USD", value: "-0.02" },
+ { currency: "RUB", value: "200" },
+ ])).toEqual([
+ { currency: "RUB", value: "-1499800.15" },
+ { currency: "USD", value: "-0.01" },
+ ]);
+ });
+
+ it("preserves significant fractional digits and a compensated zero", () => {
+ expect(sumMoney([
+ { currency: "RUB", value: "100.5" },
+ { currency: "RUB", value: "49.50" },
+ { currency: "RUB", value: "0.005" },
+ ])).toEqual([{ currency: "RUB", value: "150.005" }]);
+ expect(sumMoney([
+ { currency: "RUB", value: "100" },
+ { currency: "RUB", value: "-100" },
+ ])).toEqual([{ currency: "RUB", value: "0" }]);
+ });
+
+ it.each(["", ".", "1.2.3", "not-a-number"])(
+ "rejects malformed amount %s instead of treating it as zero",
+ (value) => {
+ expect(sumMoney([
+ { currency: "RUB", value: "100" },
+ { currency: "RUB", value },
+ ])).toBeNull();
+ },
+ );
+});
diff --git a/src/shared/lib/decimal.ts b/src/shared/lib/decimal.ts
new file mode 100644
index 0000000..90473b4
--- /dev/null
+++ b/src/shared/lib/decimal.ts
@@ -0,0 +1,75 @@
+type Decimal = { scale: number; value: bigint };
+
+type Money = { currency: string; value: string };
+
+const DECIMAL_PARTS = 2;
+const RADIX = 10n;
+// Decimal grammar is bounded to digits and one optional dot.
+// eslint-disable-next-line security/detect-unsafe-regex
+const decimalPattern = /^-?\d+(?:\.\d+)?$/;
+
+function decimalFromString(value: string): Decimal {
+ if (!decimalPattern.test(value)) throw new SyntaxError("Invalid 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 sumDecimals(decimals: readonly Decimal[]): Decimal {
+ const scale = Math.max(...decimals.map(decimal => decimal.scale));
+
+ const value = decimals.reduce(
+ (sum, decimal) =>
+ sum + decimal.value * RADIX ** BigInt(scale - decimal.scale),
+ 0n,
+ );
+
+ return { scale, value };
+}
+
+function sumMoney(amounts: readonly Money[]): Money[] | null {
+ const decimals = new Map();
+
+ for (const amount of amounts) {
+ try {
+ const values = decimals.get(amount.currency) ?? [];
+ values.push(decimalFromString(amount.value));
+ decimals.set(amount.currency, values);
+ }
+ catch {
+ return null;
+ }
+ }
+
+ return Array.from(decimals, ([currency, values]) => ({
+ currency,
+ value: decimalToString(sumDecimals(values)),
+ }));
+}
+
+export {
+ type Decimal,
+ decimalFromString,
+ decimalToString,
+ sumDecimals,
+ sumMoney,
+};
diff --git a/src/shared/lib/display.test.ts b/src/shared/lib/display.test.ts
index 578282f..2b2c656 100644
--- a/src/shared/lib/display.test.ts
+++ b/src/shared/lib/display.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
formatDate,
formatMoney,
+ formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
@@ -22,6 +23,10 @@ describe("shared display helpers", () => {
}).format(new Date(instant));
expect(formatSnapshotTime(instant)).toBe(expected);
+ expect(formatSnapshotDay(instant)).toBe(
+ new Intl.DateTimeFormat("ru-RU", { dateStyle: "short" })
+ .format(new Date(instant)),
+ );
});
it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe(
@@ -42,6 +47,28 @@ describe("shared display helpers", () => {
formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true }),
).toBe("1,50 ₽");
expect(formatMoney(null)).toBe("—");
+ expect(formatMoney(
+ { currency: "RUB", value: "136900" },
+ { withSign: true },
+ )).toBe("+136 900 ₽");
+ expect(formatMoney(
+ { currency: "RUB", value: "-4200" },
+ { withSign: true },
+ )).toBe("−4 200 ₽");
+ expect(formatMoney(
+ { currency: "RUB", value: "-0.00" },
+ { withSign: true },
+ )).toBe("0,00 ₽");
+ });
+
+ it("rounds to the currency's minor units and drops the sign of a rounded zero", () => {
+ const rounded = { withRoundedCents: true };
+ expect(formatMoney({ currency: "RUB", value: "-0.004" }, rounded)).toBe("0,00 ₽");
+ expect(formatMoney({ currency: "RUB", value: "-0.005" }, rounded)).toBe("-0,01 ₽");
+ expect(formatMoney({ currency: "RUB", value: "999.995" }, rounded)).toBe("1 000,00 ₽");
+ expect(formatMoney({ currency: "JPY", value: "1500.5" }, rounded)).toBe("1 501 JPY");
+ expect(formatMoney({ currency: "KWD", value: "1.2345" }, rounded)).toBe("1,235 KWD");
+ expect(formatMoney({ currency: "ZZZ", value: "1.234" }, rounded)).toBe("1,23 ZZZ");
});
it("builds the brand logo url from an upstream file name", () => {
diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts
index db44e99..345cea4 100644
--- a/src/shared/lib/display.ts
+++ b/src/shared/lib/display.ts
@@ -1,10 +1,19 @@
const DECIMAL_PART_COUNT = 2;
-const CENTS_PRECISION = 2;
+const DEFAULT_MINOR_UNITS = 2;
type FormatMoneyOptions = {
+ withRoundedCents?: boolean;
+ withSign?: boolean;
withZeroCents?: boolean;
};
+const portfolioCategoryPresentation = {
+ BOND: { color: "grape.6", label: "portfolio.bonds" },
+ CASH: { color: "green.6", label: "portfolio.structure.cash" },
+ OTHER: { color: "orange.6", label: "portfolio.structure.other" },
+ STOCK: { color: "blue.6", label: "portfolio.stocks" },
+} as const;
+
type Money = { currency: string; value: string };
const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
@@ -14,6 +23,17 @@ const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
year: "numeric",
});
+// ISO 4217 minor units (JPY 0, KWD 3); unknown codes fall back to 2.
+function currencyMinorUnits(currency: string): number {
+ try {
+ return new Intl.NumberFormat("en", { currency, style: "currency" })
+ .resolvedOptions().maximumFractionDigits ?? DEFAULT_MINOR_UNITS;
+ }
+ catch {
+ return DEFAULT_MINOR_UNITS;
+ }
+}
+
function formatDate(value: string): string {
return dateFormatter.format(new Date(value));
}
@@ -39,17 +59,27 @@ function formatMoney(
money: Money
| null
| undefined,
- { withZeroCents = false }: FormatMoneyOptions = {},
+ {
+ withRoundedCents = false,
+ withSign = false,
+ withZeroCents = false,
+ }: FormatMoneyOptions = {},
): string {
if (!money) return "—";
- const [integerPart = "", fractionPart = ""] = money.value.split(
+ const minorUnits = currencyMinorUnits(money.currency);
+
+ const value = withRoundedCents
+ ? roundDecimal(money.value, minorUnits)
+ : money.value;
+
+ const [integerPart = "", fractionPart = ""] = value.split(
".",
DECIMAL_PART_COUNT,
);
- const normalizedFraction = withZeroCents
- ? fractionPart.padEnd(CENTS_PRECISION, "0")
+ const normalizedFraction = withRoundedCents || withZeroCents
+ ? fractionPart.padEnd(minorUnits, "0")
: fractionPart;
const decimal = normalizedFraction
@@ -57,8 +87,46 @@ function formatMoney(
: integerPart;
const currency = money.currency === "RUB" ? "₽" : money.currency;
+ const formatted = `${formatDecimal(decimal)} ${currency}`;
- return `${formatDecimal(decimal)} ${currency}`;
+ if (!withSign) return formatted;
+ if (isZeroDecimal(value))
+ return value.startsWith("-") ? formatted.slice(1) : formatted;
+
+ return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
+}
+
+function isZeroDecimal(value: string) {
+ const unsigned = value.startsWith("-") ? value.slice(1) : value;
+
+ const [integer = "", fraction = ""] = unsigned.split(
+ ".",
+ DECIMAL_PART_COUNT,
+ );
+
+ return integer.replaceAll("0", "") === ""
+ && fraction.replaceAll("0", "") === "";
+}
+
+function roundDecimal(value: string, places: number): string {
+ const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT);
+ const digits = integer.startsWith("-") ? integer.slice(1) : integer;
+ const paddedFraction = fraction.padEnd(places + 1, "0");
+ const units = `${digits}${paddedFraction.slice(0, places)}`;
+ const roundingDigit = paddedFraction.at(places) ?? "0";
+
+ const rounded = (roundingDigit >= "5" ? BigInt(units) + 1n : BigInt(units))
+ .toString()
+ .padStart(places + 1, "0");
+
+ // A value rounded to zero has no sign: "-0.004" → "0.00".
+ const sign = integer.startsWith("-") && /[1-9]/.test(rounded) ? "-" : "";
+ const roundedInteger = rounded.slice(0, rounded.length - places);
+ const roundedFraction = rounded.slice(rounded.length - places);
+
+ return roundedFraction
+ ? `${sign}${roundedInteger}.${roundedFraction}`
+ : `${sign}${roundedInteger}`;
}
// Без timeZone Intl берёт часовой пояс браузера пользователя.
@@ -67,6 +135,14 @@ const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
timeStyle: "short",
});
+const snapshotDayFormatter = new Intl.DateTimeFormat("ru-RU", {
+ dateStyle: "short",
+});
+
+function formatSnapshotDay(value: string): string {
+ return snapshotDayFormatter.format(new Date(value));
+}
+
function formatSnapshotTime(value: string): string {
return snapshotFormatter.format(new Date(value));
}
@@ -92,6 +168,8 @@ export {
formatDate,
formatDecimal,
formatMoney,
+ formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
+ portfolioCategoryPresentation,
};
diff --git a/src/shared/lib/group-items.test.ts b/src/shared/lib/group-items.test.ts
new file mode 100644
index 0000000..a7e10e4
--- /dev/null
+++ b/src/shared/lib/group-items.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import { groupItems } from "./group-items";
+
+describe("groupItems", () => {
+ it("keeps the first key position and source order through interleaving", () => {
+ const a1 = { id: "A1", key: "A" };
+ const b = { id: "B", key: null };
+ const a2 = { id: "A2", key: "A" };
+ const c = { id: "C", key: "C" };
+ const a3 = { id: "A3", key: "A" };
+ const input = [a1, b, a2, c, a3];
+
+ const result = groupItems(input, item => item.key);
+
+ expect(result).toEqual([
+ { items: [a1, a2, a3], key: "A", type: "group" },
+ { item: b, type: "single" },
+ { item: c, type: "single" },
+ ]);
+ expect(input.map(item => item.id)).toEqual(["A1", "B", "A2", "C", "A3"]);
+ });
+
+ it("keeps two interleaved groups independent and singleton rows single", () => {
+ const result = groupItems(
+ [
+ { id: "X1", key: "X" },
+ { id: "Y1", key: "Y" },
+ { id: "X2", key: "X" },
+ { id: "Y2", key: "Y" },
+ { id: "X3", key: "X" },
+ { id: "Z1", key: "Z" },
+ ],
+ item => item.key,
+ );
+
+ expect(result.map(entry => entry.type === "group"
+ ? [entry.key, entry.items.map(item => item.id)]
+ : [null, [entry.item.id]])).toEqual([
+ ["X", ["X1", "X2", "X3"]],
+ ["Y", ["Y1", "Y2"]],
+ [null, ["Z1"]],
+ ]);
+ });
+});
diff --git a/src/shared/lib/group-items.ts b/src/shared/lib/group-items.ts
new file mode 100644
index 0000000..7ee195c
--- /dev/null
+++ b/src/shared/lib/group-items.ts
@@ -0,0 +1,47 @@
+type GroupedItem
+ = | { item: T; type: "single" }
+ | { items: [T, T, ...T[]]; key: string; type: "group" };
+
+function groupItems(
+ items: readonly T[],
+ keyOf: (item: T) => null | string,
+): GroupedItem[] {
+ const result: GroupedItem[] = [];
+ const indices = new Map();
+
+ for (const item of items) {
+ const key = keyOf(item);
+
+ if (key === null) {
+ result.push({ item, type: "single" });
+ continue;
+ }
+
+ const index = indices.get(key);
+
+ if (index === undefined) {
+ indices.set(key, result.length);
+ result.push({ item, type: "single" });
+ continue;
+ }
+
+ // eslint-disable-next-line security/detect-object-injection -- Map-owned index.
+ const existing = result[index];
+
+ if (existing?.type === "single") {
+ // eslint-disable-next-line security/detect-object-injection -- Map-owned index.
+ result[index] = {
+ items: [existing.item, item],
+ key,
+ type: "group",
+ };
+ }
+ else if (existing) {
+ existing.items.push(item);
+ }
+ }
+
+ return result;
+}
+
+export { groupItems };
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index ec94855..f64e6b3 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -1,12 +1,23 @@
export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate } from "./calendar";
export { calendarDate } from "./calendar.schema";
+export {
+ type Decimal,
+ decimalFromString,
+ decimalToString,
+ sumDecimals,
+ sumMoney,
+} from "./decimal";
export {
formatChartValue,
formatDate,
formatDecimal,
formatMoney,
+ formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
+ portfolioCategoryPresentation,
} from "./display";
+export { groupItems } from "./group-items";
export { useInitialAvailability } from "./initial-availability";
+export { totalText } from "./total-text";
export { useUrlPeriod } from "./useUrlPeriod";
diff --git a/src/shared/lib/total-text.ts b/src/shared/lib/total-text.ts
new file mode 100644
index 0000000..2cb0b09
--- /dev/null
+++ b/src/shared/lib/total-text.ts
@@ -0,0 +1,13 @@
+import { formatMoney } from "./display";
+
+type Money = { currency: string; value: string };
+
+function totalText(totals: Money[] | null, isSigned: boolean): string {
+ return totals
+ ? totals.map(total =>
+ formatMoney(total, { withSign: isSigned || undefined }),
+ ).join(", ")
+ : "";
+}
+
+export { totalText };
diff --git a/src/shared/ui/EntryGroup/EntryGroup.module.css b/src/shared/ui/EntryGroup/EntryGroup.module.css
new file mode 100644
index 0000000..527ed80
--- /dev/null
+++ b/src/shared/ui/EntryGroup/EntryGroup.module.css
@@ -0,0 +1,41 @@
+.group {
+ overflow: hidden;
+ border-color: var(--wl-color-border);
+ background: var(--wl-color-subtle);
+}
+
+.accounts {
+ padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm);
+ list-style: none;
+}
+
+.accountRow {
+ display: flex;
+ gap: var(--mantine-spacing-sm);
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--mantine-spacing-sm) 0;
+ border-top: 1px solid var(--wl-color-border);
+}
+
+.accountValue {
+ flex-shrink: 0;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.accountCardRow {
+ display: flex;
+ gap: var(--mantine-spacing-sm);
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
+ border-color: var(--wl-color-border);
+ background: var(--wl-color-surface);
+}
+
+@media (max-width: 36em) {
+ .accountRow {
+ align-items: flex-start;
+ }
+}
diff --git a/src/shared/ui/EntryGroup/EntryGroup.tsx b/src/shared/ui/EntryGroup/EntryGroup.tsx
new file mode 100644
index 0000000..9f44271
--- /dev/null
+++ b/src/shared/ui/EntryGroup/EntryGroup.tsx
@@ -0,0 +1,96 @@
+import type { FC, PropsWithChildren, ReactNode } from "react";
+
+import { Paper, Stack, Text, VisuallyHidden } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+import { formatMoney } from "@/shared/lib";
+
+import classes from "./EntryGroup.module.css";
+
+type AccountRowProps = {
+ accountName: string;
+ value: string;
+ valueLabel: string;
+};
+
+type EntryGroupProps = PropsWithChildren<{
+ header: ReactNode;
+ label: string;
+}>;
+
+type EntryGroupTotalProps = {
+ isSigned?: boolean;
+ totals: null | { currency: string; value: string }[];
+};
+
+const AccountRow: FC = (props) => {
+ const accessibleName = `${props.accountName}, ${props.value}`;
+
+ return (
+
+ {props.accountName}
+
+
+ {props.value}
+
+ {props.valueLabel}
+
+
+ );
+};
+
+const AccountCardRow: FC = props => (
+
+
+ {props.accountName}
+
+
+ {props.value}
+
+ {props.valueLabel}
+
+
+
+);
+
+const EntryGroup: FC = props => (
+
+ {props.header}
+
+
+ {props.children}
+
+
+);
+
+const EntryGroupTotal: FC = (props) => {
+ const { t } = useTranslation();
+
+ if (!props.totals) {
+ return {t("entryGroup.totalUnavailable")} ;
+ }
+
+ const isSigned = props.isSigned === true;
+
+ return (
+
+ {props.totals.map(total => (
+
+ {formatMoney(total, { withSign: isSigned || undefined })}
+
+ ))}
+
+ );
+};
+
+export {
+ AccountCardRow,
+ AccountRow,
+ EntryGroup,
+ EntryGroupTotal,
+};
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index e887af9..88a1681 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -1,2 +1,8 @@
+export {
+ AccountCardRow,
+ AccountRow,
+ EntryGroup,
+ EntryGroupTotal,
+} from "./EntryGroup/EntryGroup";
export { PayoutCard } from "./PayoutCard/PayoutCard";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
diff --git a/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts b/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts
index 2bdc39f..6e6cd7f 100644
--- a/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts
+++ b/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts
@@ -5,20 +5,13 @@ import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
-import { formatMoney } from "@/shared/lib";
+import { formatMoney, portfolioCategoryPresentation } from "@/shared/lib";
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
// A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
-const categoryPresentation = {
- BOND: { color: "grape.6", label: "portfolio.bonds" },
- CASH: { color: "green.6", label: "portfolio.structure.cash" },
- OTHER: { color: "orange.6", label: "portfolio.structure.other" },
- STOCK: { color: "blue.6", label: "portfolio.stocks" },
-} as const;
-
const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
const { structure } = props;
const { t } = useTranslation();
@@ -45,7 +38,7 @@ const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
unvalued:
structure?.unvaluedCategories.map(category =>
t("portfolio.structure.unvaluedCategory", {
- category: t(categoryPresentation[category.type].label),
+ category: t(portfolioCategoryPresentation[category.type].label),
count: category.count,
}),
) ?? [],
@@ -61,7 +54,7 @@ function portfolioRows(
if (!selected) return [];
return selected.categories.map((category) => {
- const presentation = categoryPresentation[category.type];
+ const presentation = portfolioCategoryPresentation[category.type];
const percentage
= category.sharePercent === null