diff --git a/package-lock.json b/package-lock.json index 562fbca..d2cb203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "5.7.1", + "@mantine/charts": "9.5.1", "@mantine/core": "9.5.1", "@mantine/dates": "9.5.1", + "@mantine/hooks": "9.5.1", "@tanstack/react-query": "5.101.4", "dayjs": "1.11.23", "i18next": "26.0.2", @@ -21,7 +23,9 @@ "react-dom": "19.2.8", "react-hook-form": "7.85.0", "react-i18next": "16.3.5", + "react-is": "19.2.8", "react-router": "7.18.2", + "recharts": "3.10.1", "zod": "3.25.76" }, "devDependencies": { @@ -1815,6 +1819,19 @@ "@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": { "version": "9.5.1", "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", "integrity": "sha512-2sK9OdWvrzKBOuWxLfQA5qcAJzxpzqNlKumaP7Uq0i7LDBQeY/sYgNiBWLwtW+iZw6fFXwPf0nI7q5VVpvqnNQ==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^19.2.0" } @@ -2697,6 +2713,32 @@ "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": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", @@ -3036,7 +3078,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -3404,6 +3445,69 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3486,6 +3590,12 @@ "dev": true, "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": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", @@ -4393,6 +4503,127 @@ "devOptional": true, "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": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", @@ -4448,6 +4679,12 @@ "dev": true, "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": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4703,7 +4940,6 @@ "version": "1.50.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", - "dev": true, "license": "MIT", "workspaces": [ "docs", @@ -4801,7 +5037,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/expect-type": { @@ -5231,6 +5466,16 @@ "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": { "version": "3.3.1", "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_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": { "version": "3.1.1", "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_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": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -7067,10 +7328,9 @@ } }, "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, + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "license": "MIT" }, "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" } }, + "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": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7191,6 +7474,36 @@ "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": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -7218,6 +7531,21 @@ "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": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -7248,6 +7576,12 @@ "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": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -8553,7 +8887,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -8876,6 +9209,28 @@ "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": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", diff --git a/package.json b/package.json index 8561393..8e07057 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,10 @@ }, "dependencies": { "@hookform/resolvers": "5.7.1", + "@mantine/charts": "9.5.1", "@mantine/core": "9.5.1", "@mantine/dates": "9.5.1", + "@mantine/hooks": "9.5.1", "@tanstack/react-query": "5.101.4", "dayjs": "1.11.23", "i18next": "26.0.2", @@ -43,7 +45,9 @@ "react-dom": "19.2.8", "react-hook-form": "7.85.0", "react-i18next": "16.3.5", + "react-is": "19.2.8", "react-router": "7.18.2", + "recharts": "3.10.1", "zod": "3.25.76" }, "devDependencies": { diff --git a/src/app/routes/AllAccounts.test.tsx b/src/app/routes/AllAccounts.test.tsx index 253455c..5ef6949 100644 --- a/src/app/routes/AllAccounts.test.tsx +++ b/src/app/routes/AllAccounts.test.tsx @@ -39,6 +39,7 @@ const portfolio: Portfolio = { lastSuccessfulAt: "2026-08-15T12:00:00Z", isStale: false, totalsByCurrency: [{ currency: "RUB", value: "1200" }], + structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [], positions: accounts.map((account) => ({ ...position, account })), }; @@ -188,7 +189,13 @@ describe("All accounts routes", () => { ])("opens %s directly", async (path, title) => { renderPage(path); 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(); expect(screen.getByRole("combobox", { name: "Счёт" })).toHaveValue( "Все счета", diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index 4a62312..c91233d 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -38,6 +38,7 @@ describe("usePortfolio", () => { isStale: false, totalsByCurrency: [], positions: [], + structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [], }) : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index fb89b41..e831516 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -90,6 +90,7 @@ const portfolio: Portfolio = { logoName: null, }, ], + structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [{ currency: "USD", value: "0.5" }], }; @@ -120,6 +121,21 @@ function emptyProps() { } 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", () => { const { container } = renderPage(); expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true"); @@ -167,6 +183,7 @@ describe("AccountPage states", () => { currentValue: { currency: "RUB", value: "1000" }, }, ], + structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [ { currency: "RUB", value: "234.500000000000000001" }, { currency: "USD", value: "-0.5" }, @@ -226,6 +243,7 @@ describe("AccountPage states", () => { currentValue: { currency: "RUB", value: "100" }, }, ], + structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [], }; server.use( @@ -762,12 +780,26 @@ describe("AccountPage states", () => { it.each([503, 404])( "preserves totals and UTC snapshot through refresh error %i, retry and success", 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 resolveRetry: ((response: Response) => void) | undefined; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; - if (requestCount === 1) return HttpResponse.json(portfolio); + if (requestCount === 1) return HttpResponse.json(initialPortfolio); if (requestCount === 3) return new Promise((resolve) => { resolveRetry = resolve; @@ -782,6 +814,10 @@ describe("AccountPage states", () => { renderAccountRoute(queryClient); await screen.findByText("Вторая"); + const structure = screen.getByRole("region", { + name: "Структура портфеля", + }); + expect(structure).toHaveTextContent("10 ₽"); const snapshotText = screen.getByText(/Данные получены/).textContent; expect(snapshotText).toContain("10:00"); void queryClient.invalidateQueries({ @@ -807,6 +843,7 @@ describe("AccountPage states", () => { ); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); await waitFor(() => expect(requestCount).toBe(3)); + expect(structure).toHaveTextContent("10 ₽"); expect( screen.getAllByRole("definition").map((node) => node.textContent), ).toEqual(["1 234,5 ₽", "0,5 $"]); @@ -819,6 +856,7 @@ describe("AccountPage states", () => { isStale: false, lastSuccessfulAt: "2026-08-14T11:30:00Z", totalsByCurrency: [{ currency: "USD", value: "-0.5" }], + structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [{ currency: "EUR", value: "2.50" }], }), ); @@ -828,6 +866,8 @@ describe("AccountPage states", () => { ).toEqual(["-0,5 $", "2,50 €"]), ); expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30"); + expect(structure).not.toHaveTextContent("10 ₽"); + expect(structure).toHaveTextContent("Нет положительной стоимости"); expect( screen.queryByText(/Не удалось обновить портфель/), ).not.toBeInTheDocument(); diff --git a/src/pages/account/ui/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage.vm.test.tsx index d2373e1..8e7c760 100644 --- a/src/pages/account/ui/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage.vm.test.tsx @@ -55,6 +55,7 @@ const portfolio: Portfolio = { logoName: "fond.png", }, ], + structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [{ currency: "USD", value: "1" }], }; @@ -145,6 +146,7 @@ describe("useAccountPageViewModel", () => { const snapshot: Portfolio = { ...portfolio, totalsByCurrency: [{ currency: "RUB", value: "1250" }], + structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [{ currency: "RUB", value: "1000" }], positions: [ ...portfolio.positions, diff --git a/src/pages/account/ui/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/components/AccountPageEmpty.view.tsx index c0e35a1..8e8ca97 100644 --- a/src/pages/account/ui/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/components/AccountPageEmpty.view.tsx @@ -12,6 +12,7 @@ import type { import { AccountHeading } from "./AccountHeading.view"; import { IncomeSummary } from "./IncomeSummary.view"; import { PortfolioRefreshState } from "./PortfolioRefreshState.view"; +import { PortfolioStructure } from "./PortfolioStructure"; import { RecentOperations } from "./RecentOperations.view"; type AccountPageEmptyProps = { @@ -43,6 +44,9 @@ const AccountPageEmpty: FC = (props) => { : "portfolio.emptyText", )} /> + {props.incomeSummary ? : null} diff --git a/src/pages/account/ui/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/components/AccountPageLoaded.view.tsx index 21261da..b4cb5fd 100644 --- a/src/pages/account/ui/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/components/AccountPageLoaded.view.tsx @@ -16,6 +16,7 @@ import { AccountHeading } from "./AccountHeading.view"; import { CurrencyCards } from "./CurrencyCards.view"; import { IncomeSummary } from "./IncomeSummary.view"; import { PortfolioRefreshState } from "./PortfolioRefreshState.view"; +import { PortfolioStructure } from "./PortfolioStructure"; import { PositionSection } from "./PositionSection.view"; import { RecentOperations } from "./RecentOperations.view"; @@ -56,6 +57,10 @@ const AccountPageLoaded: FC = (props) => { )} + { ))} + {[0, 1, 2].map((index) => ( ))} diff --git a/src/pages/account/ui/components/PortfolioStructure.module.css b/src/pages/account/ui/components/PortfolioStructure.module.css new file mode 100644 index 0000000..1538391 --- /dev/null +++ b/src/pages/account/ui/components/PortfolioStructure.module.css @@ -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; + } +} diff --git a/src/pages/account/ui/components/PortfolioStructure.stories.tsx b/src/pages/account/ui/components/PortfolioStructure.stories.tsx new file mode 100644 index 0000000..469de63 --- /dev/null +++ b/src/pages/account/ui/components/PortfolioStructure.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; +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: [], + }, + }, +}; diff --git a/src/pages/account/ui/components/PortfolioStructure.test.tsx b/src/pages/account/ui/components/PortfolioStructure.test.tsx new file mode 100644 index 0000000..1b8f51b --- /dev/null +++ b/src/pages/account/ui/components/PortfolioStructure.test.tsx @@ -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) => ( + + + +); + +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 %"); + }); +}); diff --git a/src/pages/account/ui/components/PortfolioStructure.tsx b/src/pages/account/ui/components/PortfolioStructure.tsx new file mode 100644 index 0000000..a1fc00a --- /dev/null +++ b/src/pages/account/ui/components/PortfolioStructure.tsx @@ -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 = (props) => { + const { structure } = props; + const { t } = useTranslation(); + const [currency, setCurrency] = useState(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 ( + 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 }; diff --git a/src/pages/account/ui/components/PortfolioStructure.view.tsx b/src/pages/account/ui/components/PortfolioStructure.view.tsx new file mode 100644 index 0000000..c38fc02 --- /dev/null +++ b/src/pages/account/ui/components/PortfolioStructure.view.tsx @@ -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 = (props) => { + const { t } = useTranslation(); + const titleId = useId(); + return ( + + + + {t("portfolio.structure.title")} + + {props.currencies.length > 1 ? ( +