feat(portfolio): visualize asset allocation
This commit is contained in:
Generated
+364
-9
@@ -9,8 +9,10 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.7.1",
|
"@hookform/resolvers": "5.7.1",
|
||||||
|
"@mantine/charts": "9.5.1",
|
||||||
"@mantine/core": "9.5.1",
|
"@mantine/core": "9.5.1",
|
||||||
"@mantine/dates": "9.5.1",
|
"@mantine/dates": "9.5.1",
|
||||||
|
"@mantine/hooks": "9.5.1",
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
"dayjs": "1.11.23",
|
"dayjs": "1.11.23",
|
||||||
"i18next": "26.0.2",
|
"i18next": "26.0.2",
|
||||||
@@ -21,7 +23,9 @@
|
|||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "7.85.0",
|
"react-hook-form": "7.85.0",
|
||||||
"react-i18next": "16.3.5",
|
"react-i18next": "16.3.5",
|
||||||
|
"react-is": "19.2.8",
|
||||||
"react-router": "7.18.2",
|
"react-router": "7.18.2",
|
||||||
|
"recharts": "3.10.1",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1815,6 +1819,19 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mantine/charts": {
|
||||||
|
"version": "9.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-9.5.1.tgz",
|
||||||
|
"integrity": "sha512-JLa02nNyct+XVA3YSKGOVHoAMzO6Wg44uUB44SifDTejVFAiehHAM2GOcTqrYBAmYf/uodM/bdZktwNAug5irA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mantine/core": "9.5.1",
|
||||||
|
"@mantine/hooks": "9.5.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"recharts": ">=3.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mantine/core": {
|
"node_modules/@mantine/core": {
|
||||||
"version": "9.5.1",
|
"version": "9.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.5.1.tgz",
|
||||||
@@ -1869,7 +1886,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.5.1.tgz",
|
||||||
"integrity": "sha512-2sK9OdWvrzKBOuWxLfQA5qcAJzxpzqNlKumaP7Uq0i7LDBQeY/sYgNiBWLwtW+iZw6fFXwPf0nI7q5VVpvqnNQ==",
|
"integrity": "sha512-2sK9OdWvrzKBOuWxLfQA5qcAJzxpzqNlKumaP7Uq0i7LDBQeY/sYgNiBWLwtW+iZw6fFXwPf0nI7q5VVpvqnNQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -2697,6 +2713,32 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
||||||
@@ -3036,7 +3078,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/utils": {
|
"node_modules/@standard-schema/utils": {
|
||||||
@@ -3404,6 +3445,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -3486,6 +3590,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.4",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
|
||||||
@@ -4393,6 +4503,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
|
||||||
@@ -4448,6 +4679,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-eql": {
|
"node_modules/deep-eql": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
@@ -4703,7 +4940,6 @@
|
|||||||
"version": "1.50.0",
|
"version": "1.50.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
|
||||||
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
|
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"docs",
|
"docs",
|
||||||
@@ -4801,7 +5037,6 @@
|
|||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
@@ -5231,6 +5466,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
||||||
|
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -5291,6 +5536,15 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/interpret": {
|
"node_modules/interpret": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
|
||||||
@@ -6933,6 +7187,13 @@
|
|||||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format/node_modules/react-is": {
|
||||||
|
"version": "17.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prompts": {
|
"node_modules/prompts": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||||
@@ -7067,10 +7328,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-number-format": {
|
"node_modules/react-number-format": {
|
||||||
@@ -7083,6 +7343,29 @@
|
|||||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -7191,6 +7474,36 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^11.1.8",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.2.0",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rechoir": {
|
"node_modules/rechoir": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
||||||
@@ -7218,6 +7531,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regexp-tree": {
|
"node_modules/regexp-tree": {
|
||||||
"version": "0.1.27",
|
"version": "0.1.27",
|
||||||
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
||||||
@@ -7248,6 +7576,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -8553,7 +8887,6 @@
|
|||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
@@ -8876,6 +9209,28 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
||||||
|
|||||||
@@ -31,8 +31,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.7.1",
|
"@hookform/resolvers": "5.7.1",
|
||||||
|
"@mantine/charts": "9.5.1",
|
||||||
"@mantine/core": "9.5.1",
|
"@mantine/core": "9.5.1",
|
||||||
"@mantine/dates": "9.5.1",
|
"@mantine/dates": "9.5.1",
|
||||||
|
"@mantine/hooks": "9.5.1",
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
"dayjs": "1.11.23",
|
"dayjs": "1.11.23",
|
||||||
"i18next": "26.0.2",
|
"i18next": "26.0.2",
|
||||||
@@ -43,7 +45,9 @@
|
|||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "7.85.0",
|
"react-hook-form": "7.85.0",
|
||||||
"react-i18next": "16.3.5",
|
"react-i18next": "16.3.5",
|
||||||
|
"react-is": "19.2.8",
|
||||||
"react-router": "7.18.2",
|
"react-router": "7.18.2",
|
||||||
|
"recharts": "3.10.1",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const portfolio: Portfolio = {
|
|||||||
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
positions: accounts.map((account) => ({ ...position, account })),
|
positions: accounts.map((account) => ({ ...position, account })),
|
||||||
};
|
};
|
||||||
@@ -188,7 +189,13 @@ describe("All accounts routes", () => {
|
|||||||
])("opens %s directly", async (path, title) => {
|
])("opens %s directly", async (path, title) => {
|
||||||
renderPage(path);
|
renderPage(path);
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { level: 1, name: title }),
|
// The lazy route also imports charts; parallel coverage instrumentation
|
||||||
|
// can exceed RTL's one-second default before the route is mounted.
|
||||||
|
await screen.findByRole(
|
||||||
|
"heading",
|
||||||
|
{ level: 1, name: title },
|
||||||
|
{ timeout: 5000 },
|
||||||
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("combobox", { name: "Счёт" })).toHaveValue(
|
expect(screen.getByRole("combobox", { name: "Счёт" })).toHaveValue(
|
||||||
"Все счета",
|
"Все счета",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe("usePortfolio", () => {
|
|||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
positions: [],
|
positions: [],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
})
|
})
|
||||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const portfolio: Portfolio = {
|
|||||||
logoName: null,
|
logoName: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [{ currency: "USD", value: "0.5" }],
|
cashBalances: [{ currency: "USD", value: "0.5" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,6 +121,21 @@ function emptyProps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountPage states", () => {
|
describe("AccountPage states", () => {
|
||||||
|
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json({ ...portfolio, structure: undefined }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderAccountRoute(createTestQueryClient());
|
||||||
|
expect(await screen.findByText("Вторая")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: "Структура портфеля" }),
|
||||||
|
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("definition").map((node) => node.textContent),
|
||||||
|
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||||||
|
});
|
||||||
it("keeps initial placeholders decorative without amounts or focus targets", () => {
|
it("keeps initial placeholders decorative without amounts or focus targets", () => {
|
||||||
const { container } = renderPage(<AccountPageLoading />);
|
const { container } = renderPage(<AccountPageLoading />);
|
||||||
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
|
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
|
||||||
@@ -167,6 +183,7 @@ describe("AccountPage states", () => {
|
|||||||
currentValue: { currency: "RUB", value: "1000" },
|
currentValue: { currency: "RUB", value: "1000" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [
|
cashBalances: [
|
||||||
{ currency: "RUB", value: "234.500000000000000001" },
|
{ currency: "RUB", value: "234.500000000000000001" },
|
||||||
{ currency: "USD", value: "-0.5" },
|
{ currency: "USD", value: "-0.5" },
|
||||||
@@ -226,6 +243,7 @@ describe("AccountPage states", () => {
|
|||||||
currentValue: { currency: "RUB", value: "100" },
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
@@ -762,12 +780,26 @@ describe("AccountPage states", () => {
|
|||||||
it.each([503, 404])(
|
it.each([503, 404])(
|
||||||
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
||||||
async (status) => {
|
async (status) => {
|
||||||
|
const initialPortfolio: Portfolio = {
|
||||||
|
...portfolio,
|
||||||
|
structure: {
|
||||||
|
unvaluedCategories: [{ type: "STOCK", count: 1 }],
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
currency: "RUB",
|
||||||
|
totalValue: "10",
|
||||||
|
hasNegativeValues: false,
|
||||||
|
categories: [{ type: "BOND", value: "10", sharePercent: null }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
let resolveRetry: ((response: Response) => void) | undefined;
|
let resolveRetry: ((response: Response) => void) | undefined;
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||||
requestCount += 1;
|
requestCount += 1;
|
||||||
if (requestCount === 1) return HttpResponse.json(portfolio);
|
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
|
||||||
if (requestCount === 3)
|
if (requestCount === 3)
|
||||||
return new Promise<Response>((resolve) => {
|
return new Promise<Response>((resolve) => {
|
||||||
resolveRetry = resolve;
|
resolveRetry = resolve;
|
||||||
@@ -782,6 +814,10 @@ describe("AccountPage states", () => {
|
|||||||
renderAccountRoute(queryClient);
|
renderAccountRoute(queryClient);
|
||||||
|
|
||||||
await screen.findByText("Вторая");
|
await screen.findByText("Вторая");
|
||||||
|
const structure = screen.getByRole("region", {
|
||||||
|
name: "Структура портфеля",
|
||||||
|
});
|
||||||
|
expect(structure).toHaveTextContent("10 ₽");
|
||||||
const snapshotText = screen.getByText(/Данные получены/).textContent;
|
const snapshotText = screen.getByText(/Данные получены/).textContent;
|
||||||
expect(snapshotText).toContain("10:00");
|
expect(snapshotText).toContain("10:00");
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
@@ -807,6 +843,7 @@ describe("AccountPage states", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
await waitFor(() => expect(requestCount).toBe(3));
|
await waitFor(() => expect(requestCount).toBe(3));
|
||||||
|
expect(structure).toHaveTextContent("10 ₽");
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("definition").map((node) => node.textContent),
|
screen.getAllByRole("definition").map((node) => node.textContent),
|
||||||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||||||
@@ -819,6 +856,7 @@ describe("AccountPage states", () => {
|
|||||||
isStale: false,
|
isStale: false,
|
||||||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -828,6 +866,8 @@ describe("AccountPage states", () => {
|
|||||||
).toEqual(["-0,5 $", "2,50 €"]),
|
).toEqual(["-0,5 $", "2,50 €"]),
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
|
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
|
||||||
|
expect(structure).not.toHaveTextContent("10 ₽");
|
||||||
|
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText(/Не удалось обновить портфель/),
|
screen.queryByText(/Не удалось обновить портфель/),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const portfolio: Portfolio = {
|
|||||||
logoName: "fond.png",
|
logoName: "fond.png",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [{ currency: "USD", value: "1" }],
|
cashBalances: [{ currency: "USD", value: "1" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,6 +146,7 @@ describe("useAccountPageViewModel", () => {
|
|||||||
const snapshot: Portfolio = {
|
const snapshot: Portfolio = {
|
||||||
...portfolio,
|
...portfolio,
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
|
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [{ currency: "RUB", value: "1000" }],
|
cashBalances: [{ currency: "RUB", value: "1000" }],
|
||||||
positions: [
|
positions: [
|
||||||
...portfolio.positions,
|
...portfolio.positions,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
import { AccountHeading } from "./AccountHeading.view";
|
import { AccountHeading } from "./AccountHeading.view";
|
||||||
import { IncomeSummary } from "./IncomeSummary.view";
|
import { IncomeSummary } from "./IncomeSummary.view";
|
||||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||||
|
import { PortfolioStructure } from "./PortfolioStructure";
|
||||||
import { RecentOperations } from "./RecentOperations.view";
|
import { RecentOperations } from "./RecentOperations.view";
|
||||||
|
|
||||||
type AccountPageEmptyProps = {
|
type AccountPageEmptyProps = {
|
||||||
@@ -43,6 +44,9 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
|||||||
: "portfolio.emptyText",
|
: "portfolio.emptyText",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<PortfolioStructure
|
||||||
|
structure={{ currencies: [], unvaluedCategories: [] }}
|
||||||
|
/>
|
||||||
<RecentOperations operations={props.recentOperations} />
|
<RecentOperations operations={props.recentOperations} />
|
||||||
<BrokerageEventsPreview events={props.recentEvents} />
|
<BrokerageEventsPreview events={props.recentEvents} />
|
||||||
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { AccountHeading } from "./AccountHeading.view";
|
|||||||
import { CurrencyCards } from "./CurrencyCards.view";
|
import { CurrencyCards } from "./CurrencyCards.view";
|
||||||
import { IncomeSummary } from "./IncomeSummary.view";
|
import { IncomeSummary } from "./IncomeSummary.view";
|
||||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||||
|
import { PortfolioStructure } from "./PortfolioStructure";
|
||||||
import { PositionSection } from "./PositionSection.view";
|
import { PositionSection } from "./PositionSection.view";
|
||||||
import { RecentOperations } from "./RecentOperations.view";
|
import { RecentOperations } from "./RecentOperations.view";
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||||
|
<PortfolioStructure
|
||||||
|
key={props.accountId}
|
||||||
|
structure={props.portfolio.structure}
|
||||||
|
/>
|
||||||
<Accordion
|
<Accordion
|
||||||
multiple
|
multiple
|
||||||
defaultValue={[
|
defaultValue={[
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const AccountPageLoading: FC = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
<Skeleton height={260} radius="sm" />
|
||||||
{[0, 1, 2].map((index) => (
|
{[0, 1, 2].map((index) => (
|
||||||
<Skeleton key={index} height={52} radius="sm" />
|
<Skeleton key={index} height={52} radius="sm" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
.section {
|
||||||
|
padding-block: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
animation: currency-change 220ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes currency-change {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.content {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--mantine-spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--mantine-spacing-md);
|
||||||
|
padding-block: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.values {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48em) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout > :first-child {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { PortfolioStructure } from "./PortfolioStructure";
|
||||||
|
|
||||||
|
const rub: Portfolio["structure"]["currencies"][number] = {
|
||||||
|
currency: "RUB",
|
||||||
|
totalValue: "1250000",
|
||||||
|
hasNegativeValues: false,
|
||||||
|
categories: [
|
||||||
|
{ type: "STOCK", value: "650000", sharePercent: "52" },
|
||||||
|
{ type: "BOND", value: "375000", sharePercent: "30" },
|
||||||
|
{ type: "OTHER", value: "125000", sharePercent: "10" },
|
||||||
|
{ type: "CASH", value: "100000", sharePercent: "8" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const meta = {
|
||||||
|
title: "Product/PortfolioStructure",
|
||||||
|
component: PortfolioStructure,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
"Структура по типам активов, включая доступные деньги. Синтетические серверные суммы и доли, без запросов API и финансовых расчётов на клиенте. При неполной или отрицательной оценке остаётся текстовый список.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof PortfolioStructure>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
export const Regular: Story = {};
|
||||||
|
export const MultipleCurrencies: Story = {
|
||||||
|
args: {
|
||||||
|
structure: {
|
||||||
|
currencies: [
|
||||||
|
rub,
|
||||||
|
{
|
||||||
|
currency: "USD",
|
||||||
|
totalValue: "500",
|
||||||
|
hasNegativeValues: false,
|
||||||
|
categories: [{ type: "CASH", value: "500", sharePercent: "100" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const CashOnly: Story = {
|
||||||
|
args: {
|
||||||
|
structure: {
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
currency: "RUB",
|
||||||
|
totalValue: "100000",
|
||||||
|
hasNegativeValues: false,
|
||||||
|
categories: rub.categories.map((row) => ({
|
||||||
|
...row,
|
||||||
|
value: row.type === "CASH" ? "100000" : "0",
|
||||||
|
sharePercent: row.type === "CASH" ? "100" : "0",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { structure: { currencies: [], unvaluedCategories: [] } },
|
||||||
|
};
|
||||||
|
export const Incomplete: Story = {
|
||||||
|
args: {
|
||||||
|
structure: {
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
...rub,
|
||||||
|
categories: rub.categories.map((row) => ({
|
||||||
|
...row,
|
||||||
|
sharePercent: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unvaluedCategories: [{ type: "STOCK", count: 2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const UnknownOnly: Story = {
|
||||||
|
args: {
|
||||||
|
structure: {
|
||||||
|
currencies: [],
|
||||||
|
unvaluedCategories: [{ type: "BOND", count: 1 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Negative: Story = {
|
||||||
|
args: {
|
||||||
|
structure: {
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
...rub,
|
||||||
|
hasNegativeValues: true,
|
||||||
|
totalValue: "1150000",
|
||||||
|
categories: rub.categories.map((row) => ({
|
||||||
|
...row,
|
||||||
|
value: row.type === "OTHER" ? "25000" : row.value,
|
||||||
|
sharePercent: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unvaluedCategories: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { PortfolioStructure } from "./PortfolioStructure";
|
||||||
|
|
||||||
|
const rub: Portfolio["structure"]["currencies"][number] = {
|
||||||
|
currency: "RUB",
|
||||||
|
totalValue: "100",
|
||||||
|
hasNegativeValues: false,
|
||||||
|
categories: [
|
||||||
|
{ type: "STOCK", value: "60", sharePercent: "60" },
|
||||||
|
{ type: "BOND", value: "20", sharePercent: "20" },
|
||||||
|
{ type: "OTHER", value: "0", sharePercent: "0" },
|
||||||
|
{ type: "CASH", value: "20", sharePercent: "20" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const ui = (structure: Portfolio["structure"] | null | undefined) => (
|
||||||
|
<MantineProvider>
|
||||||
|
<PortfolioStructure structure={structure} />
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("portfolio structure", () => {
|
||||||
|
it.each([undefined, null])(
|
||||||
|
"keeps an older snapshot without structure available (%s) and recovers on refresh",
|
||||||
|
(structure) => {
|
||||||
|
const view = render(ui(structure));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: "Структура портфеля" }),
|
||||||
|
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/Нет положительной стоимости/),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||||
|
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
|
||||||
|
expect(screen.getByRole("list")).toHaveTextContent("Акции60 ₽60 %");
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Данные о структуре портфеля пока недоступны."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it("shows exact server amounts and cash-inclusive shares without hovering", () => {
|
||||||
|
render(ui({ currencies: [rub], unvaluedCategories: [] }));
|
||||||
|
const list = screen.getByRole("list", { name: "Категории активов" });
|
||||||
|
expect(list).toHaveTextContent("Акции60 ₽60 %");
|
||||||
|
expect(list).toHaveTextContent("Свободные деньги20 ₽20 %");
|
||||||
|
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("switches currencies and falls back when a refresh removes the selection", () => {
|
||||||
|
const usd = { ...rub, currency: "USD" };
|
||||||
|
const view = render(ui({ currencies: [rub, usd], unvaluedCategories: [] }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("combobox", { name: "Валюта структуры портфеля" }),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "USD" }));
|
||||||
|
expect(screen.getByRole("list")).toHaveTextContent("60 USD");
|
||||||
|
expect(screen.getByRole("list")).not.toHaveTextContent("₽");
|
||||||
|
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
|
||||||
|
expect(screen.getByRole("list")).toHaveTextContent("60 ₽");
|
||||||
|
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("retains known amounts and names unvalued categories without inventing a zero", () => {
|
||||||
|
render(
|
||||||
|
ui({
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
...rub,
|
||||||
|
categories: rub.categories.map((row) => ({
|
||||||
|
...row,
|
||||||
|
sharePercent: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unvaluedCategories: [
|
||||||
|
{ type: "OTHER", count: 2 },
|
||||||
|
{ type: "STOCK", count: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Оценка неполная/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("list")).toHaveTextContent("Известно: 60 ₽");
|
||||||
|
expect(screen.getByRole("list")).toHaveTextContent(
|
||||||
|
"Другие активыНет оценки",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByLabelText(/Структура по типам/),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("keeps negative amounts visible without a donut or invented positive shares", () => {
|
||||||
|
render(
|
||||||
|
ui({
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
...rub,
|
||||||
|
hasNegativeValues: true,
|
||||||
|
categories: rub.categories.map((row) => ({
|
||||||
|
...row,
|
||||||
|
value: row.type === "STOCK" ? "-60" : row.value,
|
||||||
|
sharePercent: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unvaluedCategories: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Есть отрицательные/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("list")).toHaveTextContent("-60 ₽");
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("list")).getAllByText("Доля недоступна"),
|
||||||
|
).toHaveLength(4);
|
||||||
|
expect(
|
||||||
|
screen.queryByLabelText(/Структура по типам/),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("handles unknown-only and empty compositions", () => {
|
||||||
|
const view = render(
|
||||||
|
ui({ currencies: [], unvaluedCategories: [{ type: "BOND", count: 1 }] }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||||
|
view.rerender(ui({ currencies: [], unvaluedCategories: [] }));
|
||||||
|
expect(screen.getByText(/Нет положительной стоимости/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("keeps full decimal amounts and describes tiny shares", () => {
|
||||||
|
render(
|
||||||
|
ui({
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
...rub,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
type: "CASH",
|
||||||
|
value: "9007199254740993.000000001",
|
||||||
|
sharePercent: "0.000001",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unvaluedCategories: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("list")).toHaveTextContent(
|
||||||
|
"9 007 199 254 740 993,000000001 ₽",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("list")).toHaveTextContent("< 0,01 %");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { type FC, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
import { PortfolioStructureView } from "./PortfolioStructure.view";
|
||||||
|
|
||||||
|
// A cached snapshot or an older running server can predate this response field.
|
||||||
|
type PortfolioStructureProps = { structure?: Portfolio["structure"] | null };
|
||||||
|
|
||||||
|
const categoryPresentation = {
|
||||||
|
STOCK: { label: "portfolio.stocks", color: "blue.6" },
|
||||||
|
BOND: { label: "portfolio.bonds", color: "grape.6" },
|
||||||
|
OTHER: { label: "portfolio.structure.other", color: "orange.6" },
|
||||||
|
CASH: { label: "portfolio.structure.cash", color: "green.6" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
|
||||||
|
const { structure } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [currency, setCurrency] = useState<string | null>(null);
|
||||||
|
const selected =
|
||||||
|
structure?.currencies.find((item) => item.currency === currency) ??
|
||||||
|
structure?.currencies[0];
|
||||||
|
const incomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
|
||||||
|
const rows = selected
|
||||||
|
? selected.categories.map((category) => {
|
||||||
|
const presentation = categoryPresentation[category.type];
|
||||||
|
const percentage =
|
||||||
|
category.sharePercent === null ? null : Number(category.sharePercent);
|
||||||
|
const unknown = structure?.unvaluedCategories.some(
|
||||||
|
(item) => item.type === category.type,
|
||||||
|
);
|
||||||
|
const amount = formatMoney({
|
||||||
|
currency: selected.currency,
|
||||||
|
value: category.value,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: t(presentation.label),
|
||||||
|
color: presentation.color,
|
||||||
|
amount: unknown
|
||||||
|
? category.value === "0"
|
||||||
|
? t("portfolio.structure.unvalued")
|
||||||
|
: t("portfolio.structure.knownAmount", { amount })
|
||||||
|
: amount,
|
||||||
|
share:
|
||||||
|
percentage === null
|
||||||
|
? t("portfolio.structure.shareUnavailable")
|
||||||
|
: percentage > 0 && percentage < 0.01
|
||||||
|
? t("portfolio.structure.smallShare")
|
||||||
|
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`,
|
||||||
|
value: percentage ?? 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const chartData = rows.filter((row) => row.value > 0);
|
||||||
|
return (
|
||||||
|
<PortfolioStructureView
|
||||||
|
unavailable={!structure}
|
||||||
|
currencies={structure?.currencies.map((item) => item.currency) ?? []}
|
||||||
|
currency={selected?.currency ?? null}
|
||||||
|
onCurrencyChange={setCurrency}
|
||||||
|
rows={rows}
|
||||||
|
chartData={chartData}
|
||||||
|
incomplete={incomplete}
|
||||||
|
negative={selected?.hasNegativeValues ?? false}
|
||||||
|
unvalued={
|
||||||
|
structure?.unvaluedCategories.map((category) =>
|
||||||
|
t("portfolio.structure.unvaluedCategory", {
|
||||||
|
category: t(categoryPresentation[category.type].label),
|
||||||
|
count: category.count,
|
||||||
|
}),
|
||||||
|
) ?? []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PortfolioStructure };
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import "@mantine/charts/styles.css";
|
||||||
|
import { DonutChart } from "@mantine/charts";
|
||||||
|
import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { type FC, useId } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "./PortfolioStructure.module.css";
|
||||||
|
|
||||||
|
type StructureRow = {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
amount: string;
|
||||||
|
share: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
type PortfolioStructureViewProps = {
|
||||||
|
unavailable: boolean;
|
||||||
|
currencies: string[];
|
||||||
|
currency: string | null;
|
||||||
|
onCurrencyChange: (currency: string | null) => void;
|
||||||
|
rows: StructureRow[];
|
||||||
|
chartData: StructureRow[];
|
||||||
|
incomplete: boolean;
|
||||||
|
negative: boolean;
|
||||||
|
unvalued: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const titleId = useId();
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
component="section"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
gap="md"
|
||||||
|
className={classes.section}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Title order={2} size="h4" id={titleId}>
|
||||||
|
{t("portfolio.structure.title")}
|
||||||
|
</Title>
|
||||||
|
{props.currencies.length > 1 ? (
|
||||||
|
<Select
|
||||||
|
aria-label={t("portfolio.structure.currency")}
|
||||||
|
data={props.currencies}
|
||||||
|
value={props.currency}
|
||||||
|
onChange={props.onCurrencyChange}
|
||||||
|
allowDeselect={false}
|
||||||
|
w={112}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
props.unavailable
|
||||||
|
? "portfolio.structure.unavailable"
|
||||||
|
: "portfolio.structure.basis",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{props.incomplete ? (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
|
||||||
|
{props.unvalued.map((message) => (
|
||||||
|
<Text size="sm" c="dimmed" key={message}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
{props.negative ? (
|
||||||
|
<Text size="sm">{t("portfolio.structure.negative")}</Text>
|
||||||
|
) : null}
|
||||||
|
{!props.unavailable &&
|
||||||
|
!props.chartData.length &&
|
||||||
|
!props.incomplete &&
|
||||||
|
!props.negative ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("portfolio.structure.empty")}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
key={props.currency}
|
||||||
|
className={`${classes.content} ${props.chartData.length ? classes.layout : ""}`}
|
||||||
|
>
|
||||||
|
{props.chartData.length ? (
|
||||||
|
<DonutChart
|
||||||
|
data={props.chartData}
|
||||||
|
size={220}
|
||||||
|
thickness={32}
|
||||||
|
strokeWidth={2}
|
||||||
|
startAngle={90}
|
||||||
|
endAngle={-270}
|
||||||
|
chartLabel={props.currency ?? undefined}
|
||||||
|
tooltipDataSource="segment"
|
||||||
|
pieProps={{ isAnimationActive: false }}
|
||||||
|
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
|
||||||
|
aria-label={t("portfolio.structure.chartLabel")}
|
||||||
|
tooltipProps={{
|
||||||
|
content: ({ active, payload }) => {
|
||||||
|
const row = props.chartData.find(
|
||||||
|
(item) => item.name === payload?.[0]?.name,
|
||||||
|
);
|
||||||
|
return active && row ? (
|
||||||
|
<Paper shadow="sm" p="sm" radius="md">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{row.amount} · {row.share}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
) : null;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{props.rows.length ? (
|
||||||
|
<ul
|
||||||
|
className={classes.list}
|
||||||
|
aria-label={t("portfolio.structure.categories")}
|
||||||
|
>
|
||||||
|
{props.rows.map((row) => (
|
||||||
|
<li key={row.name} className={classes.row}>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
bg={row.color}
|
||||||
|
className={classes.marker}
|
||||||
|
/>
|
||||||
|
<Text size="sm">{row.name}</Text>
|
||||||
|
</Group>
|
||||||
|
<div className={classes.values}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.amount}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{row.share}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PortfolioStructureView };
|
||||||
@@ -32,6 +32,7 @@ function sections(positions: Portfolio["positions"]) {
|
|||||||
lastSuccessfulAt: "2026-09-09T12:00:00Z",
|
lastSuccessfulAt: "2026-09-09T12:00:00Z",
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
positions,
|
positions,
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+26
@@ -379,6 +379,31 @@ export interface components {
|
|||||||
ticker: string | null;
|
ticker: string | null;
|
||||||
logoName: 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"][];
|
||||||
|
};
|
||||||
PortfolioDto: {
|
PortfolioDto: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
lastSuccessfulAt: string;
|
lastSuccessfulAt: string;
|
||||||
@@ -386,6 +411,7 @@ export interface components {
|
|||||||
totalsByCurrency: components["schemas"]["MoneyDto"][];
|
totalsByCurrency: components["schemas"]["MoneyDto"][];
|
||||||
positions: components["schemas"]["PortfolioPositionDto"][];
|
positions: components["schemas"]["PortfolioPositionDto"][];
|
||||||
cashBalances: components["schemas"]["MoneyDto"][];
|
cashBalances: components["schemas"]["MoneyDto"][];
|
||||||
|
structure: components["schemas"]["PortfolioStructureDto"];
|
||||||
};
|
};
|
||||||
UpcomingEventDto: {
|
UpcomingEventDto: {
|
||||||
account?: components["schemas"]["AccountReferenceDto"];
|
account?: components["schemas"]["AccountReferenceDto"];
|
||||||
|
|||||||
@@ -2062,6 +2062,104 @@
|
|||||||
"logoName"
|
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
"PortfolioDto": {
|
"PortfolioDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2089,6 +2187,9 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/MoneyDto"
|
"$ref": "#/components/schemas/MoneyDto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"structure": {
|
||||||
|
"$ref": "#/components/schemas/PortfolioStructureDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -2096,7 +2197,8 @@
|
|||||||
"isStale",
|
"isStale",
|
||||||
"totalsByCurrency",
|
"totalsByCurrency",
|
||||||
"positions",
|
"positions",
|
||||||
"cashBalances"
|
"cashBalances",
|
||||||
|
"structure"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"UpcomingEventDto": {
|
"UpcomingEventDto": {
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ const ru = {
|
|||||||
bonds: "Облигации",
|
bonds: "Облигации",
|
||||||
otherPositions: "Другие позиции",
|
otherPositions: "Другие позиции",
|
||||||
cashBalances: "Денежные остатки",
|
cashBalances: "Денежные остатки",
|
||||||
|
structure: {
|
||||||
|
title: "Структура портфеля",
|
||||||
|
other: "Другие активы",
|
||||||
|
cash: "Свободные деньги",
|
||||||
|
currency: "Валюта структуры портфеля",
|
||||||
|
categories: "Категории активов",
|
||||||
|
chartLabel:
|
||||||
|
"Структура по типам активов. Суммы и доли — в списке рядом.",
|
||||||
|
basis: "Доли учитывают свободные деньги и округлены до сотых процента.",
|
||||||
|
incomplete:
|
||||||
|
"Оценка неполная: ниже показаны только известные суммы. Валюта оценки части позиций неизвестна, поэтому доли и диаграмма недоступны.",
|
||||||
|
unvaluedCategory: "{{category}}: без оценки — {{count}}",
|
||||||
|
negative:
|
||||||
|
"Есть отрицательные стоимости. Кольцевая диаграмма и доли недоступны; в списке сохранены суммы со знаком.",
|
||||||
|
empty: "Нет положительной стоимости для кольцевой диаграммы.",
|
||||||
|
unavailable: "Данные о структуре портфеля пока недоступны.",
|
||||||
|
shareUnavailable: "Доля недоступна",
|
||||||
|
unvalued: "Нет оценки",
|
||||||
|
knownAmount: "Известно: {{amount}}",
|
||||||
|
smallShare: "< 0,01 %",
|
||||||
|
},
|
||||||
quantity: "Количество: {{value}}",
|
quantity: "Количество: {{value}}",
|
||||||
return: "Доход: {{value}}",
|
return: "Доход: {{value}}",
|
||||||
avgPrice: "Средняя цена: {{value}}",
|
avgPrice: "Средняя цена: {{value}}",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const handlers = [
|
|||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
positions: [],
|
positions: [],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user