diff --git a/hwproj.front/package-lock.json b/hwproj.front/package-lock.json index a0882868d..fe2e3c324 100644 --- a/hwproj.front/package-lock.json +++ b/hwproj.front/package-lock.json @@ -20,6 +20,7 @@ "@mui/lab": "^5.0.0-alpha.99", "@mui/material": "^5.16.11", "@mui/x-charts": "^8.2.0", + "@reduxjs/toolkit": "^2.11.2", "@storybook/addon-knobs": "^6.3.0", "@types/bluebird": "^3.5.36", "@types/classnames": "^2.3.1", @@ -52,6 +53,7 @@ "react-drag-drop-files": "^3.1.0", "react-markdown": "^5.0.0", "react-query": "^3.21.1", + "react-redux": "^9.2.0", "react-router-dom": "^6.5.0", "react-social-login-buttons": "^3.5.1", "react-syntax-highlighter": "^15.5.0", @@ -4564,6 +4566,32 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "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/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -4874,6 +4902,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@storybook/addon-knobs": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@storybook/addon-knobs/-/addon-knobs-6.4.0.tgz", @@ -4977,6 +5017,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.5.16.tgz", "integrity": "sha512-p3DqQi+8QRL5k7jXhXmJZLsE/GqHqyY6PcoA1oNTJr0try48uhTGUOYkgzmqtDaa/qPFO5LP+xCPzZXckGtquQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/api": "6.5.16", @@ -5004,12 +5045,14 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/api": { "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.5.16.tgz", "integrity": "sha512-HOsuT8iomqeTMQJrRx5U8nsC7lJTwRr1DhdD0SzlqL4c80S/7uuCy4IZvOt4sYQjOzW5fOo/kamcoBXyLproTA==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/channels": "6.5.16", @@ -5043,6 +5086,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/builder-webpack4": { @@ -5441,6 +5485,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-6.5.16.tgz", "integrity": "sha512-VylzaWQZaMozEwZPJdyJoz+0jpDa8GRyaqu9TGG6QGv+KU5POoZaGLDkRE7TzWkyyP0KQLo80K99MssZCpgSeg==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2", @@ -5500,6 +5545,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.5.16.tgz", "integrity": "sha512-pxcNaCj3ItDdicPTXTtmYJE3YC1SjxFrBmHcyrN+nffeNyiMuViJdOOZzzzucTUG0wcOOX8jaSyak+nnHg5H1Q==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2", @@ -5514,6 +5560,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.5.16.tgz", "integrity": "sha512-LzBOFJKITLtDcbW9jXl0/PaG+4xAz25PK8JxPZpIALbmOpYWOAPcO6V9C2heX6e6NgWFMUxjplkULEk9RCQMNA==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -5538,6 +5585,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/core": { @@ -5704,6 +5752,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.5.16.tgz", "integrity": "sha512-qMZQwmvzpH5F2uwNUllTPg6eZXr2OaYZQRRN8VZJiuorZzDNdAFmiVWMWdkThwmyLEJuQKXxqCL8lMj/7PPM+g==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2" @@ -5804,6 +5853,7 @@ "version": "0.0.2--canary.4566f4d.1", "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.4566f4d.1.tgz", "integrity": "sha512-9OVvMVh3t9znYZwb0Svf/YQoxX2gVOeQTGe2bses2yj+a3+OJnCrUF3/hGv6Em7KujtOdL2LL+JnG49oMVGFgQ==", + "dev": true, "license": "MIT", "dependencies": { "lodash": "^4.17.15" @@ -6276,6 +6326,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.5.16.tgz", "integrity": "sha512-ZgeP8a5YV/iuKbv31V8DjPxlV4AzorRiR8OuSt/KqaiYXNXlOoQDz/qMmiNcrshrfLpmkzoq7fSo4T8lWo2UwQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -6297,12 +6348,14 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/semver": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz", "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==", + "dev": true, "license": "ISC", "dependencies": { "core-js": "^3.6.5", @@ -6319,6 +6372,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -6332,6 +6386,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6344,6 +6399,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -6359,6 +6415,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -6442,6 +6499,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.5.16.tgz", "integrity": "sha512-hNLctkjaYLRdk1+xYTkC1mg4dYz2wSv6SqbLpcKMbkPHTE0ElhddGPHQqB362md/w9emYXNkt1LSMD8Xk9JzVQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -6462,6 +6520,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/ui": { @@ -7088,6 +7147,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.3.tgz", "integrity": "sha512-/CLhCW79JUeLKznI6mbVieGbl4QU5Hfn+6udw1YHZoofASjbQ5zaP5LzAUZYDpRYEjS4/P+DhEgyJ/PQmGGTWw==", + "dev": true, "license": "MIT" }, "node_modules/@types/isomorphic-fetch": { @@ -7437,6 +7497,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "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/@types/webpack": { "version": "4.41.40", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.40.tgz", @@ -7456,6 +7522,7 @@ "version": "1.18.8", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz", "integrity": "sha512-G9eAoJRMLjcvN4I08wB5I7YofOb/kaJNd5uoCMX+LbKXTPCF+ZIHuqTnFaK9Jz1rgs035f9JUPUhNFtqgucy/A==", + "dev": true, "license": "MIT" }, "node_modules/@types/webpack-sources": { @@ -17528,6 +17595,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "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", @@ -18027,6 +18104,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "dev": true, "license": "MIT" }, "node_modules/is-generator-function": { @@ -18149,6 +18227,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -18226,6 +18305,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -18697,13 +18777,6 @@ "node": ">= 10.13.0" } }, - "node_modules/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT", - "peer": true - }, "node_modules/js-sha3": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", @@ -19343,6 +19416,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", + "dev": true, "license": "MIT" }, "node_modules/map-visit": { @@ -20535,6 +20609,7 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", + "dev": true, "license": "MIT", "dependencies": { "map-or-similar": "^1.5.0" @@ -20591,21 +20666,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -22516,6 +22576,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -22718,6 +22779,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -22870,18 +22932,6 @@ "node": ">=6" } }, - "node_modules/popper.js": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", - "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", - "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/portable-fetch": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/portable-fetch/-/portable-fetch-3.0.0.tgz", @@ -23769,6 +23819,29 @@ } } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+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-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -24076,6 +24149,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/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -27176,6 +27264,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -28052,6 +28141,7 @@ "version": "2.14.4", "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.4.tgz", "integrity": "sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==", + "dev": true, "license": "MIT" }, "node_modules/stream-browserify": { @@ -28685,6 +28775,7 @@ "version": "6.0.8", "resolved": "https://registry.npmjs.org/telejson/-/telejson-6.0.8.tgz", "integrity": "sha512-nerNXi+j8NK1QEfBHtZUN/aLdDcyupA//9kAboYLrtzZlPLpUfqbVGWb9zz91f/mIjRbAYhbgtnJHY8I1b5MBg==", + "dev": true, "license": "MIT", "dependencies": { "@types/is-function": "^1.0.0", @@ -28701,6 +28792,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -29222,6 +29314,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -30114,6 +30207,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/util.promisify": { @@ -31969,21 +32063,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/hwproj.front/package.json b/hwproj.front/package.json index 0ddb05582..9698a7bd2 100644 --- a/hwproj.front/package.json +++ b/hwproj.front/package.json @@ -16,6 +16,7 @@ "@mui/lab": "^5.0.0-alpha.99", "@mui/material": "^5.16.11", "@mui/x-charts": "^8.2.0", + "@reduxjs/toolkit": "^2.11.2", "@storybook/addon-knobs": "^6.3.0", "@types/bluebird": "^3.5.36", "@types/classnames": "^2.3.1", @@ -48,6 +49,7 @@ "react-drag-drop-files": "^3.1.0", "react-markdown": "^5.0.0", "react-query": "^3.21.1", + "react-redux": "^9.2.0", "react-router-dom": "^6.5.0", "react-social-login-buttons": "^3.5.1", "react-syntax-highlighter": "^15.5.0", diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index a8744fee3..e16874ced 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -1,7 +1,6 @@ import * as React from "react"; -import {FC, useEffect, useState} from "react"; +import {FC, useCallback, useEffect, useState} from "react"; import {useNavigate, useParams, useSearchParams} from "react-router-dom"; -import {AccountDataDto, CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; import StudentStats from "./StudentStats"; import NewCourseStudents from "./NewCourseStudents"; import ApiSingleton from "../../api/ApiSingleton"; @@ -32,8 +31,7 @@ import {QRCodeSVG} from 'qrcode.react'; import QrCode2Icon from '@mui/icons-material/QrCode2'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; -import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; -import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import {useCourseLoader, useCourseFiles, useIsCourseMentor, useCoursePageData, useUnratedSolutionsCount} from "@/store/storeHooks/courseHooks"; type TabValue = "homeworks" | "stats" | "applications" @@ -41,17 +39,6 @@ function isAcceptableTabValue(str: string): str is TabValue { return str === "homeworks" || str === "stats" || str === "applications"; } -interface ICourseState { - isFound: boolean; - course: CourseViewModel; - courseHomeworks: HomeworkViewModel[]; - mentors: AccountDataDto[]; - acceptedStudents: AccountDataDto[]; - newStudents: AccountDataDto[]; - studentSolutions: StatisticsCourseMatesModel[]; - showQrCode: boolean; -} - interface IPageState { tabValue: TabValue } @@ -61,113 +48,83 @@ const Course: React.FC = () => { const [searchParams] = useSearchParams() const navigate = useNavigate() - const [courseState, setCourseState] = useState({ - isFound: false, - course: {}, - courseHomeworks: [], - mentors: [], - acceptedStudents: [], - newStudents: [], - studentSolutions: [], - showQrCode: false - }) - const [studentSolutions, setStudentSolutions] = useState(undefined) - - const [pageState, setPageState] = useState({ - tabValue: "homeworks" - }) - const { - isFound, course, + isFound, mentors, newStudents, - acceptedStudents, courseHomeworks, - } = courseState - - const userId = ApiSingleton.authService.getUserId() - - const isLecturer = ApiSingleton.authService.isLecturer() - const isExpert = ApiSingleton.authService.isExpert() - const isMentor = isLecturer || isExpert - const isCourseMentor = mentors.some(t => t.userId === userId) - const isSignedInCourse = newStudents!.some(cm => cm.userId === userId) - - const { - courseFilesState, - updateCourseUnitFiles, - } = FilesUploadWaiter(+courseId!, CourseUnitType.Homework, !isCourseMentor); + studentSolutions, + userId, + isLecturer, + isExpert, + isLecturerOrExpertOnSite, + isSignedInCourse, + isAcceptedStudent, + } = useCoursePageData(); + const isCourseMentor = useIsCourseMentor(); + const unratedSolutionsCount = useUnratedSolutionsCount(); + const {initUser, loadCourse, loadStudentSolutions, resetEditing} = useCourseLoader(+courseId!); + const [showQrCode, setShowQrCode] = useState(false); + const [shouldLoadFilesAfterCourseReload, setShouldLoadFilesAfterCourseReload] = useState(false); + const [pageState, setPageState] = useState({ + tabValue: "homeworks" + }) - const isAcceptedStudent = acceptedStudents!.some(cm => cm.userId === userId) + const {loadCourseFiles} = useCourseFiles(+courseId!); const showStatsTab = isCourseMentor || isAcceptedStudent const showApplicationsTab = isCourseMentor - const changeTab = (newTab: string) => { - if (isAcceptableTabValue(newTab) && newTab !== pageState.tabValue) { - if (newTab === "stats" && !showStatsTab) return; - if (newTab === "applications" && !showApplicationsTab) return; + const changeTab = useCallback((newTab: string) => { + if (!isAcceptableTabValue(newTab)) return; + if (newTab === "stats" && !showStatsTab) return; + if (newTab === "applications" && !showApplicationsTab) return; - setPageState(prevState => ({ + setPageState(prevState => prevState.tabValue === newTab + ? prevState + : { ...prevState, tabValue: newTab - })); - } - } + }); + }, [showApplicationsTab, showStatsTab]) + + const reloadCoursePage = useCallback(async () => { + initUser(); + resetEditing(); + setShouldLoadFilesAfterCourseReload(false); + const loadedCourse = await loadCourse(); + if (loadedCourse == null) return; + setShouldLoadFilesAfterCourseReload(true); + }, [initUser, loadCourse, resetEditing]) - const setCurrentState = async () => { - const course = await ApiSingleton.coursesApi.coursesGetCourseData(+courseId!) + useEffect(() => { + reloadCoursePage() + }, [courseId, reloadCoursePage]) - // У пользователя изменилась роль (иначе он не может стать лектором в курсе), - // однако он все ещё использует токен с прежней ролью - const shouldRefreshToken = - !isMentor && - course && - course.mentors!.some(t => t.userId === userId) - if (shouldRefreshToken) { - const newToken = await ApiSingleton.accountApi.accountRefreshToken() - newToken.value && ApiSingleton.authService.refreshToken(newToken.value.accessToken!) - return - } + useEffect(() => { + if (!shouldLoadFilesAfterCourseReload || userId == null || !isFound) return; - setCourseState(prevState => ({ - ...prevState, - isFound: true, - course: course, - courseHomeworks: course.homeworks!, - createHomework: false, - mentors: course.mentors!, - acceptedStudents: course.acceptedStudents!, - newStudents: course.newStudents!, - })) - } + loadCourseFiles(isCourseMentor); + setShouldLoadFilesAfterCourseReload(false); + }, [shouldLoadFilesAfterCourseReload, userId, isFound, isCourseMentor, loadCourseFiles]) useEffect(() => { - setCurrentState() - }, []) + loadStudentSolutions() + }, [loadStudentSolutions]) useEffect(() => { - ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+courseId!) - .then(res => setStudentSolutions(res)) - }, [courseId]) - - useEffect(() => changeTab(tab || "homeworks"), [tab, courseId, isFound]) + changeTab(tab || "homeworks") + }, [changeTab, tab]) const joinCourse = async () => { - await ApiSingleton.coursesApi.coursesSignInCourse(+courseId!) - .then(() => setCurrentState()); + await ApiSingleton.coursesApi.coursesSignInCourse(+courseId!); + await reloadCoursePage(); } const {tabValue} = pageState const searchedHomeworkId = searchParams.get("homeworkId") - const unratedSolutionsCount = (studentSolutions || []) - .flatMap(x => x.homeworks) - .flatMap(x => x!.tasks) - .filter(t => t!.solution!.slice(-1)[0]?.state === 0) //last solution - .length - const [lecturerStatsState, setLecturerStatsState] = useState(false); const CourseMenu: FC = () => { @@ -206,10 +163,7 @@ const Course: React.FC = () => { Управление } - setCourseState(prevState => ({ - ...prevState, - showQrCode: true - }))}> + setShowQrCode(true)}> @@ -230,8 +184,8 @@ const Course: React.FC = () => { return (
setCourseState(prevState => ({...prevState, showQrCode: false}))} + open={showQrCode} + onClose={() => setShowQrCode(false)} > Поделитесь ссылкой на курс с помощью QR-кода @@ -246,14 +200,14 @@ const Course: React.FC = () => { - {course.isCompleted && + {course?.isCompleted && Курс завершен! {isAcceptedStudent ? "Вы можете отправлять решения и получать уведомления об их проверке." : isCourseMentor && !isExpert ? "Вы продолжите получать уведомления о новых заявках на вступление и решениях." - : !isMentor ? "Вы можете записаться на курс и отправлять решения." : ""} + : !isLecturerOrExpertOnSite ? "Вы можете записаться на курс и отправлять решения." : ""} } { - {NameBuilder.getCourseFullName(course.name!, course.groupName)} + {NameBuilder.getCourseFullName(course?.name || "", course?.groupName || "")} @@ -269,11 +223,11 @@ const Course: React.FC = () => { - + {lecturerStatsState && setLecturerStatsState(false)} /> } @@ -281,7 +235,7 @@ const Course: React.FC = () => { - {!isSignedInCourse && !isMentor && !isAcceptedStudent && ( + {!isSignedInCourse && !isLecturerOrExpertOnSite && !isAcceptedStudent && (
@@ -403,4 +313,4 @@ const Course: React.FC = () => { } -export default Course +export default Course \ No newline at end of file diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index a14a8fe5e..8c352ce42 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -1,668 +1,540 @@ -import * as React from "react"; -import { - FileInfoDTO, - HomeworkTaskViewModel, - HomeworkViewModel, Solution, StatisticsCourseMatesModel, -} from "@/api"; -import { - AlertTitle, - Button, - Fab, - Grid, - Typography, - useMediaQuery, - useTheme, - Zoom -} from "@mui/material"; -import {FC, useEffect, useState} from "react"; -import Timeline from '@mui/lab/Timeline'; -import TimelineItem from '@mui/lab/TimelineItem'; -import TimelineSeparator from '@mui/lab/TimelineSeparator'; -import TimelineConnector from '@mui/lab/TimelineConnector'; -import TimelineContent from '@mui/lab/TimelineContent'; -import TimelineDot from '@mui/lab/TimelineDot'; -import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent'; -import {Alert, Card, CardActions, Chip, Paper, Stack, Tooltip} from "@mui/material"; -import {Link} from "react-router-dom"; -import StudentStatsUtils from "../../services/StudentStatsUtils"; -import {BonusTag, DefaultTags, getTip, isBonusWork, isTestWork, TestTag} from "../Common/HomeworkTags"; -import FileInfoConverter from "components/Utils/FileInfoConverter"; -import CourseHomeworkExperimental from "components/Homeworks/CourseHomeworkExperimental"; -import CourseTaskExperimental from "../Tasks/CourseTaskExperimental"; -import {DotLottieReact} from "@lottiefiles/dotlottie-react"; -import EditIcon from "@mui/icons-material/Edit"; -import ErrorIcon from '@mui/icons-material/Error'; -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import SwitchAccessShortcutIcon from '@mui/icons-material/SwitchAccessShortcut'; -import Lodash from "lodash"; -import {CourseUnitType} from "@/components/Files/CourseUnitType"; - -interface ICourseExperimentalProps { - homeworks: HomeworkViewModel[] - courseFilesInfo: FileInfoDTO[] - studentSolutions: StatisticsCourseMatesModel[] - courseId: number - isMentor: boolean - isStudentAccepted: boolean - userId: string - selectedHomeworkId: number | undefined - onHomeworkUpdate: (update: { homework: HomeworkViewModel } & { - isDeleted?: boolean - }) => void - onTaskUpdate: (update: { task: HomeworkTaskViewModel, isDeleted?: boolean }) => void, - processingFiles: { - [homeworkId: number]: { - isLoading: boolean; - }; - }; - onStartProcessing: (homeworkId: number, - courseUnitType: CourseUnitType, - previouslyExistingFilesCount: number, - waitingNewFilesCount: number, - deletingFilesIds: number[]) => void; -} - -interface ICourseExperimentalState { - initialEditMode: boolean, - selectedItem: { - isHomework: boolean, - id: number | undefined, - } -} - -export const CourseExperimental: FC = (props) => { - const [hideDeferred, setHideDeferred] = useState(false) - const [showOnlyGroupedTest, setShowOnlyGroupedTest] = useState(undefined) - const filterAdded = hideDeferred || showOnlyGroupedTest !== undefined - - // Определяем разрешение экрана пользователя - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - - // Состояние для кнопки "Наверх" - const [showScrollButton, setShowScrollButton] = useState(false); - - const homeworks = props.homeworks.slice().reverse().filter(x => { - if (hideDeferred) return !x.isDeferred - if (showOnlyGroupedTest !== undefined) return x.tags!.includes(TestTag) && x.tags!.includes(showOnlyGroupedTest) - return true - }) - - const {isMentor, studentSolutions, isStudentAccepted, userId, selectedHomeworkId, courseFilesInfo} = props - - const [state, setState] = useState({ - initialEditMode: false, - selectedItem: {id: undefined, isHomework: true}, - }) - - useEffect(() => { - const defaultHomeworkIndex = Math.max(selectedHomeworkId ? homeworks?.findIndex(x => x.id === selectedHomeworkId) : 0, 0) - const defaultHomework = homeworks?.[defaultHomeworkIndex] - setState((prevState) => ({ - ...prevState, - selectedItem: {isHomework: true, id: defaultHomework?.id}, - })) - }, [hideDeferred]) - - // Обработчик прокрутки страницы - useEffect(() => { - const handleScroll = () => { - // Показывать кнопку при прокрутке ниже 400px - const shouldShow = window.scrollY > 400; - if (shouldShow !== showScrollButton) { - setShowScrollButton(shouldShow); - } - }; - - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, [showScrollButton]); - - // Функция прокрутки вверх - const scrollToTop = () => { - window.scrollTo({ - top: 110, - behavior: 'instant' - }); - }; - - const initialEditMode = state.initialEditMode - const {id, isHomework} = state.selectedItem - - const renderDate = (date: Date) => { - date = new Date(date) - const options: Intl.DateTimeFormatOptions = { - month: 'long', - day: 'numeric' - }; - return date.toLocaleString("ru-RU", options) - } - - const renderTime = (date: Date) => { - date = new Date(date) - const options: Intl.DateTimeFormatOptions = { - hour: "2-digit", - minute: "2-digit" - }; - return date.toLocaleString("ru-RU", options) - } - - const clickedItemStyle = { - backgroundColor: "ghostwhite", - borderRadius: 9, - cursor: "pointer", - border: "1px solid lightgrey" - } - - const hoveredItemStyle = {...clickedItemStyle, border: "1px solid lightgrey"} - - const warningTimelineDotStyle = { - borderWidth: 0, - margin: 0, - padding: "4px 0px", - } - - const getStyle = (itemIsHomework: boolean, itemId: number) => - itemIsHomework === isHomework && itemId === id ? clickedItemStyle : {borderRadius: 9} - - const taskSolutionsMap = new Map() - - if (!isMentor && isStudentAccepted) { - studentSolutions - .filter(t => t.id === userId) - .flatMap(t => t.homeworks!) - .flatMap(t => t.tasks!) - .forEach(x => taskSolutionsMap.set(x.id!, x.solution!)) - } - - const showWarningsForEntity = (entity: HomeworkViewModel | HomeworkTaskViewModel, isHomework: boolean) => { - if (!isMentor) return false - if (entity.publicationDateNotSet || entity.hasDeadline && entity.deadlineDateNotSet) return true - - if (!isHomework) return false - const result = validateTestGrouping(entity) - return result !== true && result.hasErrors - } - - const renderHomeworkStatus = (homework: HomeworkViewModel & { isModified?: boolean, hasErrors?: boolean }) => { - const hasErrors = homework.id! < 0 && (homework.hasErrors || homework.tasks!.some((t: HomeworkTaskViewModel & { - hasErrors?: boolean - }) => t.hasErrors)) - if (hasErrors) - return

- if (homework.isModified) - return

- return showWarningsForEntity(homework, true) &&
⚠️
- } - - const renderTaskStatus = (task: HomeworkTaskViewModel & { isModified?: boolean, hasErrors?: boolean }) => { - if (taskSolutionsMap.has(task.id!)) { - const solutions = taskSolutionsMap.get(task.id!) - const { - lastSolution, - lastRatedSolution, - color, - solutionsDescription - } = StudentStatsUtils.calculateLastRatedSolutionInfo(solutions!, task.maxRating!) - if (lastSolution != null) return ( - {solutionsDescription}}> - - - ) - } - if (task.hasErrors) return - if (task.isModified) return - return showWarningsForEntity(task, false) ? ( - - ⚠️ - - ) : - } - - const onSelectedItemMount = () => - setState((prevState) => ({ - ...prevState, - initialEditMode: false, - })) - - const toEditHomework = (homework: HomeworkViewModel) => - setState({ - initialEditMode: true, - selectedItem: {id: homework.id!, isHomework: true}, - }) - - const validateTestGrouping = (homework: HomeworkViewModel) => { - if (!homework.tags!.includes(TestTag)) return true - - const groupingTag = homework.tags!.find(x => !DefaultTags.includes(x)) - if (groupingTag === undefined) return true - - const groupedHomeworks = homeworks.filter(x => x.tags!.includes(TestTag) && x.tags!.includes(groupingTag)) - if (groupedHomeworks.length === 1) return true - - const keys = new Set(groupedHomeworks.map(h => h.tasks!.map(t => t.maxRating).join(";"))) - return {groupingTag: groupingTag, hasErrors: keys.size !== 1} - } - - const getDatesAlert = (entity: HomeworkViewModel | HomeworkTaskViewModel, isHomework: boolean) => { - if (entity.publicationDateNotSet) { - return ( - - {"Не выставлена дата публикации"} - - ) - } - - if (isMentor && entity.hasDeadline && entity.deadlineDateNotSet) return ( - - {"Не выставлена дата дедлайна"} - - ) - - if (entity.id! < 0) { - if (isHomework) - return Новое задание будет добавлено после нажатия на 'Добавить задание' - if ((entity as HomeworkTaskViewModel)?.homeworkId! < 0) - return setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: (entity as HomeworkTaskViewModel).homeworkId! - } - }))} - > - Перейти к заданию - }>Часть добавления нового задания - return Новая задача будет добавлена после нажатия на 'Добавить задачу' - } - - if (entity.isDeferred) return ( - setHideDeferred(true)} - > - Скрыть неопубликованное - }> - {isHomework ? "Задание будет опубликовано " : "Задача будет опубликована "} - {renderDate(entity.publicationDate!) + " " + renderTime(entity.publicationDate!)} - - ) - } - const getGroupingAlert = (homework: HomeworkViewModel) => { - const result = validateTestGrouping(homework) - if (result === true) return null - const {hasErrors, groupingTag} = result - if (!hasErrors) return setShowOnlyGroupedTest(groupingTag)} - > - Задания - }> - Работа сгруппирована по ключу '{groupingTag}'. - - - return setShowOnlyGroupedTest(groupingTag)} - > - Задания - }> - Группировка контрольных работ - Создано несколько контрольных работ, сгруппированных по ключу '{groupingTag}', - однако работы отличаются между собой по количеству задач или их максимальным баллам. -
-
- Количество задач должно быть одинаковым, а баллы между соответствующими задачами равными. -
- } - - const selectedItemHomework = isHomework - ? homeworks.find(x => x.id === id)! - : homeworks.find(x => x.tasks!.some(t => t.id === id))! - - const selectedItem = isHomework - ? selectedItemHomework - : selectedItemHomework?.tasks!.find(x => x.id === id) as HomeworkTaskViewModel - - const [newTaskCounter, setNewTaskCounter] = useState(-1) - - const addNewHomework = () => { - props.onHomeworkUpdate({ - homework: { - courseId: props.courseId, - title: "Новое задание", - publicationDateNotSet: false, - publicationDate: undefined, - hasDeadline: false, - id: -1, - isGroupWork: false, - deadlineDateNotSet: false, - deadlineDate: undefined, - isDeadlineStrict: false, - description: "", - tasks: [], - tags: [] - } - }) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: -1 - } - })) - } - - const addNewTask = (homework: HomeworkViewModel) => { - const id = newTaskCounter - const tags = homework.tags! - const isTest = tags.includes(TestTag) - const isBonus = tags.includes(BonusTag) - - const ratingCandidate = Lodash(homeworks - .map(h => h.tasks![0]) - .filter(x => { - if (x === undefined) return false - const xIsTest = isTestWork(x) - const xIsBonus = isBonusWork(x) - return x.id! > 0 && (isTest && xIsTest || isBonus && xIsBonus || !isTest && !isBonus && !xIsTest && !xIsBonus) - })) - .map(x => x.maxRating!) - .groupBy(x => [x]) - .entries() - .sortBy(x => x[1].length).last()?.[1][0] - - const task = { - homeworkId: homework.id, - maxRating: ratingCandidate || 10, - suggestedMaxRating: ratingCandidate, - title: `Новая задача`, - tags: homework.tags, - isDeferred: homework.isDeferred, - description: "", - id - } - - props.onTaskUpdate({task}) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: false, - id: id - } - })) - setNewTaskCounter(id - 1) - } - - const renderHomework = (homework: HomeworkViewModel & { isModified?: boolean }) => { - const filesInfo = id ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, id) : [] - const homeworkEditMode = homework && (homework.id! < 0 || homework.isModified === true) - return homework && - - {isMentor && getGroupingAlert(homework)} - {isMentor && getDatesAlert(homework, true)} - homeworks} - homeworkAndFilesInfo={{homework, filesInfo}} - isMentor={isMentor} - initialEditMode={initialEditMode || homeworkEditMode} - onMount={onSelectedItemMount} - onAddTask={addNewTask} - onUpdate={update => { - props.onHomeworkUpdate(update) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: update.isDeleted ? undefined : update.homework.id! - } - })) - }} - isProcessing={props.processingFiles[homework.id!]?.isLoading || false} - onStartProcessing={props.onStartProcessing} - /> - - - } - - const renderTask = (task: HomeworkTaskViewModel & { isModified?: boolean }, homework: HomeworkViewModel) => { - const taskEditMode = task && (task.id! < 0 || task.isModified === true) - return task && - {isMentor && getDatesAlert(task, false)} - { - props.onTaskUpdate(update) - if (update.isDeleted) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: homework!.id - } - })) - }} - toEditHomework={() => toEditHomework(homework!)} getAllHomeworks={() => homeworks}/> - {!props.isMentor && props.isStudentAccepted && < CardActions> - - - - } - - } - - const renderGif = () => - - - const renderLecturerWelcomeScreen = () => - - - Спасибо за ещё один курс - Самое время добавить новое задание! - - - - return - - - {props.isMentor && filterAdded && - - {hideDeferred - ? "только опубликованные задания" - : showOnlyGroupedTest - ? `контрольные работы '${showOnlyGroupedTest}'` - : ""} - - } - {props.isMentor && !filterAdded && (homeworks[0]?.id || 1) > 0 && } - {isMentor && homeworks.length === 0 && renderLecturerWelcomeScreen()} - - {homeworks.map((x: HomeworkViewModel & { isModified?: boolean, hasErrors?: boolean }) => { - return
- - { - setState(prevState => ({ - ...prevState, - selectedItem: { - data: x, - isHomework: true, - id: x.id, - homeworkFilesInfo: FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, x.id!) - } - })) - }}> - - {isMentor && renderHomeworkStatus(x)} - {x.title}{getTip(x)} - - {x.isDeferred && !x.publicationDateNotSet && - - {"🕘 " + renderDate(x.publicationDate!) + " " + renderTime(x.publicationDate!)} - } - {x.tasks?.length === 0 && - - - - - } - - {x.tasks!.map(t => { - setState(prevState => ({ - ...prevState, - selectedItem: { - data: t, - isHomework: false, - id: t.id, - homeworkFilesInfo: [] - } - })) - }} - style={getStyle(false, t.id!)} - sx={{":hover": hoveredItemStyle}}> - {!t.deadlineDateNotSet && - - {t.deadlineDate ? renderDate(t.deadlineDate) : ""} -
- {t.deadlineDate ? renderTime(t.deadlineDate) : ""} -
- } - - {renderTaskStatus(t)} - - - - - {t.title}{getTip(t)} - - -
)} -
- {x.id! < 0 && - } -
; - })} -
-
-
- - {isHomework - ? renderHomework(selectedItem as HomeworkViewModel) - : renderTask(selectedItem as HomeworkTaskViewModel, selectedItemHomework!)} - - {renderGif()} - - - - {renderGif()} - - - {/* Кнопка "Наверх" для мобильных устройств */} - - - - - -
-} +import * as React from "react"; +import { + HomeworkTaskViewModel, + HomeworkViewModel, Solution, +} from "@/api"; +import { + AlertTitle, + Button, + Fab, + Grid, + Typography, + useMediaQuery, + useTheme, + Zoom +} from "@mui/material"; +import {FC, useCallback, useEffect, useMemo, useState} from "react"; +import Timeline from '@mui/lab/Timeline'; +import TimelineItem from '@mui/lab/TimelineItem'; +import TimelineSeparator from '@mui/lab/TimelineSeparator'; +import TimelineConnector from '@mui/lab/TimelineConnector'; +import TimelineContent from '@mui/lab/TimelineContent'; +import TimelineDot from '@mui/lab/TimelineDot'; +import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent'; +import {Alert, Card, CardActions, Chip, Paper, Stack, Tooltip} from "@mui/material"; +import {Link} from "react-router-dom"; +import StudentStatsUtils from "../../services/StudentStatsUtils"; +import {DefaultTags, getTip, TestTag} from "../Common/HomeworkTags"; +import FileInfoConverter from "components/Utils/FileInfoConverter"; +import CourseHomeworkExperimental from "components/Homeworks/CourseHomeworkExperimental"; +import CourseTaskExperimental from "../Tasks/CourseTaskExperimental"; +import {DotLottieReact} from "@lottiefiles/dotlottie-react"; +import EditIcon from "@mui/icons-material/Edit"; +import ErrorIcon from '@mui/icons-material/Error'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import SwitchAccessShortcutIcon from '@mui/icons-material/SwitchAccessShortcut'; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import { + useEditingStatus, + useEditingSelection, + useEnsureSelectedHomework, + useMergedHomeworks, + useSelectedCourseItemData, + useVisibleCourseHomeworks +} from "@/store/storeHooks/courseEditingHooks"; +import {useCourseActions} from "@/store/courseActions"; +import {useCourseFiles, useCourseFilePolling, useCoursePageData, useIsCourseMentor} from "@/store/storeHooks/courseHooks"; +import {useCourseState} from "@/store/hooks"; + +interface ICourseExperimentalProps { + courseId: number + selectedHomeworkId: number | undefined +} + +export const CourseExperimental: FC = (props) => { + const [hideDeferred, setHideDeferred] = useState(false) + const [showOnlyGroupedTest, setShowOnlyGroupedTest] = useState(undefined) + const filterAdded = hideDeferred || showOnlyGroupedTest !== undefined + + // Определяем разрешение экрана пользователя + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + // Состояние для кнопки "Наверх" + const [showScrollButton, setShowScrollButton] = useState(false); + + const mergedHomeworks = useMergedHomeworks() + const {createHomework, createTask} = useCourseActions() + const {select: selectItem} = useEditingSelection() + const {isHomeworkEditing, isTaskEditing} = useEditingStatus() + const homeworks = useVisibleCourseHomeworks(mergedHomeworks, hideDeferred, showOnlyGroupedTest) + + const {selectedHomeworkId} = props + const {userId, studentSolutions, isAcceptedStudent} = useCoursePageData(); + const courseFilesInfo = useCourseState(state => state.courseFiles.items); + const isCourseMentor = useIsCourseMentor(); + const {updateFiles, setFileLoading} = useCourseFiles(props.courseId, isCourseMentor); + const {startProcessing} = useCourseFilePolling(props.courseId, updateFiles, setFileLoading); + + const { + selectedItem, + selectedHomework: selectedItemHomework, + selectedItemData, + } = useSelectedCourseItemData(homeworks) + const {id, isHomework} = selectedItem + + useEnsureSelectedHomework(homeworks, selectedHomeworkId, hideDeferred) + + const handleSelectHomework = useCallback((homeworkId: number) => { + selectItem({isHomework: true, id: homeworkId}) + }, [selectItem]) + + const handleSelectTask = useCallback((taskId: number) => { + selectItem({isHomework: false, id: taskId}) + }, [selectItem]) + + const handleResetFilters = useCallback(() => { + setHideDeferred(false) + setShowOnlyGroupedTest(undefined) + }, []) + + const handleShowGroupedTest = useCallback((groupingTag: string) => { + setShowOnlyGroupedTest(groupingTag) + }, []) + + const handleHideDeferred = useCallback(() => { + setHideDeferred(true) + }, []) + + // Обработчик прокрутки страницы + useEffect(() => { + const handleScroll = () => { + // Показывать кнопку при прокрутке ниже 400px + const shouldShow = window.scrollY > 400; + if (shouldShow !== showScrollButton) { + setShowScrollButton(shouldShow); + } + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [showScrollButton]); + + // Функция прокрутки вверх + const scrollToTop = () => { + window.scrollTo({ + top: 110, + behavior: 'instant' + }); + }; + + const renderDate = (date: Date) => { + date = new Date(date) + const options: Intl.DateTimeFormatOptions = { + month: 'long', + day: 'numeric' + }; + return date.toLocaleString("ru-RU", options) + } + + const renderTime = (date: Date) => { + date = new Date(date) + const options: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit" + }; + return date.toLocaleString("ru-RU", options) + } + + const clickedItemStyle = { + backgroundColor: "ghostwhite", + borderRadius: 9, + cursor: "pointer", + border: "1px solid lightgrey" + } + + const hoveredItemStyle = {...clickedItemStyle, border: "1px solid lightgrey"} + + const warningTimelineDotStyle = { + borderWidth: 0, + margin: 0, + padding: "4px 0px", + } + + const getStyle = (itemIsHomework: boolean, itemId: number) => + itemIsHomework === isHomework && itemId === id ? clickedItemStyle : {borderRadius: 9} + + const taskSolutionsMap = useMemo(() => { + const map = new Map() + if (isCourseMentor || !isAcceptedStudent) return map + studentSolutions + .filter(t => t.id === userId) + .flatMap(t => t.homeworks!) + .flatMap(t => t.tasks!) + .forEach(x => map.set(x.id!, x.solution!)) + return map + }, [studentSolutions, userId, isCourseMentor, isAcceptedStudent]) + + const showWarningsForEntity = (entity: HomeworkViewModel | HomeworkTaskViewModel, isHomework: boolean) => { + if (!isCourseMentor) return false + if (entity.publicationDateNotSet || entity.hasDeadline && entity.deadlineDateNotSet) return true + + if (!isHomework) return false + const result = validateTestGrouping(entity) + return result !== true && result.hasErrors + } + + const hasInvalidTaskMaxRating = (t: HomeworkTaskViewModel & { hasErrors?: boolean }) => { + const mr = t.maxRating ?? 0; + return t.hasErrors || mr <= 0 || mr > 100; + }; + + const renderHomeworkStatus = (homework: HomeworkViewModel & { hasErrors?: boolean }) => { + const hasErrors = homework.id! < 0 && (homework.hasErrors || homework.tasks!.some(hasInvalidTaskMaxRating)) + if (hasErrors) + return

+ if (isHomeworkEditing(homework.id!)) + return

+ return showWarningsForEntity(homework, true) &&
⚠️
+ } + + const renderTaskStatus = (task: HomeworkTaskViewModel & { hasErrors?: boolean }) => { + if (taskSolutionsMap.has(task.id!)) { + const solutions = taskSolutionsMap.get(task.id!) + const { + lastSolution, + lastRatedSolution, + color, + solutionsDescription + } = StudentStatsUtils.calculateLastRatedSolutionInfo(solutions!, task.maxRating!) + if (lastSolution != null) return ( + {solutionsDescription}}> + + + ) + } + if (hasInvalidTaskMaxRating(task)) return + if (isTaskEditing(task.id!)) + return + return showWarningsForEntity(task, false) ? ( + + ⚠️ + + ) : + } + + const toEditHomework = (homework: HomeworkViewModel) => { + handleSelectHomework(homework.id!) + } + + const validateTestGrouping = (homework: HomeworkViewModel) => { + if (!homework.tags!.includes(TestTag)) return true + + const groupingTag = homework.tags!.find(x => !DefaultTags.includes(x)) + if (groupingTag === undefined) return true + + const groupedHomeworks = mergedHomeworks.filter(x => x.tags!.includes(TestTag) && x.tags!.includes(groupingTag)) + if (groupedHomeworks.length === 1) return true + + const keys = new Set(groupedHomeworks.map(h => h.tasks!.map(t => t.maxRating).join(";"))) + return {groupingTag: groupingTag, hasErrors: keys.size !== 1} + } + + const getDatesAlert = (entity: HomeworkViewModel | HomeworkTaskViewModel, isHomework: boolean) => { + if (entity.publicationDateNotSet) { + return ( + + {"Не выставлена дата публикации"} + + ) + } + + if (isCourseMentor && entity.hasDeadline && entity.deadlineDateNotSet) return ( + + {"Не выставлена дата дедлайна"} + + ) + + if (entity.id! < 0) { + if (isHomework) + return Новое задание будет добавлено после нажатия на 'Добавить задание' + if ((entity as HomeworkTaskViewModel)?.homeworkId! < 0) + return handleSelectHomework((entity as HomeworkTaskViewModel).homeworkId!)} + > + Перейти к заданию + }>Часть добавления нового задания + return Новая задача будет добавлена после нажатия на 'Добавить задачу' + } + + if (entity.isDeferred) return ( + + Скрыть неопубликованное + }> + {isHomework ? "Задание будет опубликовано " : "Задача будет опубликована "} + {renderDate(entity.publicationDate!) + " " + renderTime(entity.publicationDate!)} + + ) + } + const getGroupingAlert = (homework: HomeworkViewModel) => { + const result = validateTestGrouping(homework) + if (result === true) return null + const {hasErrors, groupingTag} = result + if (!hasErrors) return handleShowGroupedTest(groupingTag)} + > + Задания + }> + Работа сгруппирована по ключу '{groupingTag}'. + + + return handleShowGroupedTest(groupingTag)} + > + Задания + }> + Группировка контрольных работ + Создано несколько контрольных работ, сгруппированных по ключу '{groupingTag}', + однако работы отличаются между собой по количеству задач или их максимальным баллам. +
+
+ Количество задач должно быть одинаковым, а баллы между соответствующими задачами равными. +
+ } + + const handleCreateHomework = () => { + createHomework(props.courseId) + } + + const handleCreateTask = (homework: HomeworkViewModel) => { + createTask(homework) + } + + const renderHomework = (homework: HomeworkViewModel) => { + const filesInfo = homework.id + ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, homework.id) + : [] + const homeworkEditMode = homework && isHomeworkEditing(homework.id!) + return homework && + + {isCourseMentor && getGroupingAlert(homework)} + {isCourseMentor && getDatesAlert(homework, true)} + + + + } + + const renderTask = (task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { + const taskEditMode = task && isTaskEditing(task.id!) + return task && + {isCourseMentor && getDatesAlert(task, false)} + toEditHomework(homework!)}/> + {!isCourseMentor && isAcceptedStudent && < CardActions> + + + + } + + } + + const renderGif = () => + + + const renderLecturerWelcomeScreen = () => + + + Спасибо за ещё один курс + Самое время добавить новое задание! + + + + return + + + {isCourseMentor && filterAdded && + + {hideDeferred + ? "только опубликованные задания" + : showOnlyGroupedTest + ? `контрольные работы '${showOnlyGroupedTest}'` + : ""} + + } + {isCourseMentor && !filterAdded && } + {isCourseMentor && homeworks.length === 0 && renderLecturerWelcomeScreen()} + + {homeworks.map((x: HomeworkViewModel & { hasErrors?: boolean }) => { + return
+ + handleSelectHomework(x.id!)}> + + {isCourseMentor && renderHomeworkStatus(x)} + {x.title}{getTip(x)} + + {x.isDeferred && !x.publicationDateNotSet && + + {"🕘 " + renderDate(x.publicationDate!) + " " + renderTime(x.publicationDate!)} + } + {x.tasks?.length === 0 && + + + + + } + + {x.tasks!.map(t => handleSelectTask(t.id!)} + style={getStyle(false, t.id!)} + sx={{":hover": hoveredItemStyle}}> + {!t.deadlineDateNotSet && + + {t.deadlineDate ? renderDate(t.deadlineDate) : ""} +
+ {t.deadlineDate ? renderTime(t.deadlineDate) : ""} +
+ } + + {renderTaskStatus(t)} + + + + + {t.title}{getTip(t)} + + +
)} +
+ {x.id! < 0 && + } +
; + })} +
+
+
+ + {selectedItemData && (isHomework || selectedItemHomework) + ? isHomework + ? renderHomework(selectedItemData as HomeworkViewModel) + : renderTask(selectedItemData as HomeworkTaskViewModel, selectedItemHomework!) + : null} + + {renderGif()} + + + + {renderGif()} + + + {/* Кнопка "Наверх" для мобильных устройств */} + + + + + +
+} \ No newline at end of file diff --git a/hwproj.front/src/components/Courses/NewCourseStudents.tsx b/hwproj.front/src/components/Courses/NewCourseStudents.tsx index a5a028513..58d26f6ba 100644 --- a/hwproj.front/src/components/Courses/NewCourseStudents.tsx +++ b/hwproj.front/src/components/Courses/NewCourseStudents.tsx @@ -1,29 +1,26 @@ import * as React from 'react'; -import {AccountDataDto, CourseViewModel} from '../../api/'; import ApiSingleton from "../../api/ApiSingleton"; import {FC} from "react"; import {Card, CardContent, CardActions, Grid, Button, Typography, Alert, AlertTitle} from '@mui/material'; +import {useCourseState} from "@/store/hooks"; +import {useRefreshCourse} from "@/store/storeHooks/courseHooks"; -interface INewCourseStudentsProps { - course: CourseViewModel, - students: AccountDataDto[], - onUpdate: () => void, - courseId: string, -} - -const NewCourseStudents: FC = (props) => { +const NewCourseStudents: FC = () => { + const course = useCourseState(state => state.course.currentCourseMeta); + const students = useCourseState(state => state.course.newStudents); + const refreshCourse = useRefreshCourse(); const acceptStudent = async (studentId: string) => { - await ApiSingleton.coursesApi.coursesAcceptStudent(props.course.id!, studentId) - props.onUpdate() - } + await ApiSingleton.coursesApi.coursesAcceptStudent(course?.id!, studentId); + refreshCourse(course?.id!); + }; const rejectStudent = async (studentId: string) => { - await ApiSingleton.coursesApi.coursesRejectStudent(props.course.id!, studentId) - props.onUpdate() - } + await ApiSingleton.coursesApi.coursesRejectStudent(course?.id!, studentId); + refreshCourse(course?.id!); + }; - const studentsLength = props.students.length + const studentsLength = students.length if (studentsLength === 0) { return ( @@ -36,7 +33,7 @@ const NewCourseStudents: FC = (props) => { ) } return - {props.students.map((cm, i) => ( + {students.map((cm, i) => ( diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index 978f921d4..8873e5a8e 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useState, useRef} from "react"; -import {CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; +import {HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; import {useNavigate, useParams} from 'react-router-dom'; import {LinearProgress, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core"; import StudentStatsCell from "../Tasks/StudentStatsCell"; @@ -9,12 +9,12 @@ import StudentStatsUtils from "../../services/StudentStatsUtils"; import ShowChartIcon from "@mui/icons-material/ShowChart"; import {BonusTag, DefaultTags, TestTag} from "../Common/HomeworkTags"; import Lodash from "lodash" -import ApiSingleton from "@/api/ApiSingleton"; import FullscreenIcon from '@mui/icons-material/Fullscreen'; import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; +import {CurrentCourseMeta} from "@/store/slices/courseSlice"; interface IStudentStatsProps { - course: CourseViewModel; + course: CurrentCourseMeta; homeworks: HomeworkViewModel[]; isMentor: boolean; userId: string; @@ -62,7 +62,7 @@ const StudentStats: React.FC = (props) => { }, []) const {searched} = state - const isMentor = ApiSingleton.authService.isMentor() + const isMentor = props.isMentor useEffect(() => { const keyDownHandler = (event: KeyboardEvent) => { diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 512bf8872..913e2622f 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -1,4 +1,4 @@ -import { +import { Alert, CardActions, CardContent, @@ -15,14 +15,13 @@ import {MarkdownEditor, MarkdownPreview} from "components/Common/MarkdownEditor"; import FilesPreviewList from "components/Files/FilesPreviewList"; import {IFileInfo} from "components/Files/IFileInfo"; -import {FC, useEffect, useState} from "react" +import {FC, useState} from "react" import Utils from "services/Utils"; import { - HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel + HomeworkViewModel, ActionOptions, HomeworkTaskViewModel } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; import Tags from "../Common/Tags"; -import apiSingleton from "../../api/ApiSingleton"; import FilesUploader from "../Files/FilesUploader"; import PublicationAndDeadlineDates from "../Common/PublicationAndDeadlineDates"; import * as React from "react"; @@ -30,264 +29,128 @@ import EditIcon from "@mui/icons-material/Edit"; import AddTaskIcon from '@mui/icons-material/AddTask'; import {LoadingButton} from "@mui/lab"; import DeletionConfirmation from "../DeletionConfirmation"; +import UndoIcon from '@mui/icons-material/Undo'; import DeleteIcon from "@mui/icons-material/Delete"; import ActionOptionsUI from "components/Common/ActionOptions"; -import {BonusTag, DefaultTags, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; -import Lodash from "lodash"; +import {DefaultTags, TestTag} from "@/components/Common/HomeworkTags"; import {CourseUnitType} from "../Files/CourseUnitType" -import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; +import {useCourseState} from "@/store/hooks"; +import {useIsCourseMentor} from "@/store/storeHooks/courseHooks"; import {FilesHandler} from "@/components/Files/FilesHandler"; +import {useDraftHomework, getHomeworkDeleteMessage, useHomeworkEditorState} from "@/store/storeHooks/homeworkEditorHooks"; +import {useCourseActions} from "@/store/courseActions"; export interface HomeworkAndFilesInfo { - homework: HomeworkViewModel & { isModified?: boolean }, + homework: HomeworkViewModel, filesInfo: IFileInfo[] } -interface IEditHomeworkState { - publicationDate?: Date; - hasDeadline: boolean; - deadlineDate?: Date; - isDeadlineStrict: boolean; - hasErrors: boolean; -} - const CourseHomeworkEditor: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, - getAllHomeworks: () => HomeworkViewModel[], - onUpdate: (update: { homework: HomeworkViewModel } & { - isDeleted?: boolean, - isSaved?: boolean - }) => void + onDone?: () => void, onStartProcessing: (homeworkId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; }> = (props) => { - const homework = props.homeworkAndFilesInfo.homework - const isNewHomework = homework.id! < 0 - - const [homeworkData, setHomeworkData] = useState<{ - loadedHomework: HomeworkViewModel, - isLoaded: boolean - }>({loadedHomework: homework, isLoaded: isNewHomework || homework.isModified == true}) - - useEffect(() => { - if (homeworkData.isLoaded) return - ApiSingleton.homeworksApi - .homeworksGetForEditingHomework(homework.id!) - .then(homework => setHomeworkData({loadedHomework: homework, isLoaded: true})) - }, []) - - const {loadedHomework, isLoaded} = homeworkData - - const {filesState, setFilesState, handleFilesChange} = FilesHandler(props.homeworkAndFilesInfo.filesInfo) - const initialFilesInfo = props.homeworkAndFilesInfo.filesInfo.filter(x => x.id !== undefined) - - const homeworkId = loadedHomework.id! - const courseId = loadedHomework.courseId! - - const publicationDate = loadedHomework.publicationDateNotSet || !loadedHomework.publicationDate - ? undefined - : new Date(loadedHomework.publicationDate!) - - const deadlineDate = loadedHomework.deadlineDateNotSet || !loadedHomework.deadlineDate - ? undefined - : new Date(loadedHomework.deadlineDate!) - - const isPublished = !loadedHomework.isDeferred - const changedTaskPublicationDates = loadedHomework.tasks! - .filter(t => t.publicationDate != null) - .map(t => new Date(t.publicationDate!)) - - const taskHasErrors = homework.tasks!.some((x: HomeworkTaskViewModel & { - hasErrors?: boolean - }) => x.hasErrors === true) - - const [metadata, setMetadata] = useState({ - publicationDate: publicationDate, - hasDeadline: loadedHomework.hasDeadline!, - deadlineDate: deadlineDate, - isDeadlineStrict: loadedHomework.isDeadlineStrict!, - hasErrors: false, - }) - const [title, setTitle] = useState(loadedHomework.title!) - const [tags, setTags] = useState(loadedHomework.tags!) - const [description, setDescription] = useState(loadedHomework.description!) - - const [hasErrors, setHasErrors] = useState(false) + const loadedHomework = props.homeworkAndFilesInfo.homework + const { + patchHomeworkDraft, + saveHomework, + deleteHomework, + cancelHomeworkEdit, + } = useCourseActions() const [handleSubmitLoading, setHandleSubmitLoading] = useState(false) const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) const [editOptions, setEditOptions] = useState({sendNotification: false}) - const [deadlineSuggestion, setDeadlineSuggestion] = useState(undefined) - const [tagSuggestion, setTagSuggestion] = useState(undefined) - - useEffect(() => { - if (!isNewHomework || !metadata.publicationDate) return - const isTest = tags.includes(TestTag) - const isBonus = tags.includes(BonusTag) - - const dateCandidate = Lodash(props.getAllHomeworks() - .filter(x => { - const xIsTest = isTestWork(x) - const xIsBonus = isBonusWork(x) - return x.id! > 0 && x.hasDeadline && (isTest && xIsTest || isBonus && xIsBonus || !isTest && !isBonus && !xIsTest && !xIsBonus) - }) - .map(x => { - const deadlineDate = new Date(x.deadlineDate!) - return ({ - deadlineDate: deadlineDate, - daysDiff: Math.floor((deadlineDate.getTime() - new Date(x.publicationDate!).getTime()) / (1000 * 3600 * 24)) - }); - })) - .groupBy(x => [x.daysDiff, x.deadlineDate.getHours(), x.deadlineDate.getMinutes()]) - .entries() - .sortBy(x => x[1].length).last()?.[1][0] - if (dateCandidate) { - const publicationDate = new Date(metadata.publicationDate) - const dateTime = dateCandidate.deadlineDate - publicationDate.setDate(publicationDate.getDate() + dateCandidate.daysDiff) - publicationDate.setHours(dateTime.getHours(), dateTime.getMinutes(), 0, 0) - setDeadlineSuggestion(publicationDate) - } else { - setDeadlineSuggestion(undefined) - } - }, [tags, metadata.publicationDate]) - - useEffect(() => { - const update = { - ...homework, - ...metadata, - tasks: homework.tasks, - title: title, - description: description, - tags: tags, - hasErrors: hasErrors, - deadlineDateNotSet: metadata.hasDeadline && !metadata.deadlineDate, - isModified: true, - } - - props.onUpdate({homework: update}) - }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo]) - - useEffect(() => { - setHasErrors(!title || metadata.hasErrors) - }, [title, metadata.hasErrors]) - - useEffect(() => { - const x = title.toLowerCase() - setTagSuggestion( - !tags.includes(TestTag) && ( - x.includes("контрольн") || - x.includes("проверочн") || - x.includes("переписывание") || - x.includes("тест")) - ? TestTag : undefined) - }, [title, tags]); + const {filesState, setFilesState} = FilesHandler(props.homeworkAndFilesInfo.filesInfo) + const initialFilesInfo = props.homeworkAndFilesInfo.filesInfo.filter(x => x.id !== undefined) - const deleteHomework = async () => { - if (!isNewHomework) await ApiSingleton.homeworksApi.homeworksDeleteHomework(homeworkId) + const homeworkId = loadedHomework.id! + const courseId = loadedHomework.courseId! + const { + isNewHomework, + publicationDate, + deadlineDate, + isPublished, + changedTaskPublicationDates, + taskHasErrors, + hasErrors, + deadlineSuggestion, + tagSuggestion, + } = useHomeworkEditorState(loadedHomework) + + const homeworkSaveFileOptions = { + initialFilesInfo, + selectedFilesInfo: filesState.selectedFilesInfo, + onStartProcessing: props.onStartProcessing, + onDone: props.onDone, + } - // Удаляем файлы домашней работы с сервера - var deletingFileIds = initialFilesInfo.filter(fileInfo => fileInfo.id).map(fileInfo => fileInfo.id!) - await ProcessFilesUtils.processFilesWithErrorsHadling({ - courseId: courseId!, - courseUnitType: CourseUnitType.Homework, - courseUnitId: homeworkId, - deletingFileIds: deletingFileIds, - newFiles: [] - }) + const homeworkDeleteFileOptions = { + initialFilesInfo } - props.onUpdate({homework: loadedHomework, isDeleted: true}) + const handleDeleteHomework = async () => { + await deleteHomework(homeworkId, homeworkDeleteFileOptions) } - const getDeleteMessage = (homeworkName: string, filesInfo: IFileInfo[]) => { - let message = `Вы точно хотите удалить задание "${homeworkName}"?`; - if (filesInfo.length > 0) { - message += ` Будет также удален файл ${filesInfo[0].name}`; - if (filesInfo.length > 1) { - message += ` и другие прикрепленные файлы`; - } - } - - return message; + const cancelEditing = () => { + cancelHomeworkEdit(loadedHomework.id!) + props.onDone?.() } - const handleSubmit = async (e: any) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setHandleSubmitLoading(true) - - const update = { - homeworkId: homeworkId, - title: title!, - description: description, - tags: tags, - hasDeadline: metadata.hasDeadline, - deadlineDate: metadata.deadlineDate, - isDeadlineStrict: metadata.isDeadlineStrict, - publicationDate: metadata.publicationDate, - actionOptions: editOptions, - tasks: isNewHomework ? homework.tasks!.map(t => { - const task: PostTaskViewModel = { - ...t, - title: t.title!, - maxRating: t.maxRating! - } - return task - }) : [] + try { + await saveHomework(homeworkId, editOptions, homeworkSaveFileOptions) + } finally { + setHandleSubmitLoading(false) } - - const updatedHomework = isNewHomework - ? await ApiSingleton.homeworksApi.homeworksAddHomework(courseId!, update) - : await ApiSingleton.homeworksApi.homeworksUpdateHomework(+homeworkId!, update) - - const updatedHomeworkId = updatedHomework.value!.id! - await handleFilesChange( - courseId, CourseUnitType.Homework, updatedHomeworkId, - props.onStartProcessing, - () => { - if (isNewHomework) props.onUpdate({ - homework: update, - isDeleted: true - }) // remove fake homework - props.onUpdate({homework: updatedHomework.value!, isSaved: true}); - }, - ); } - const isDisabled = hasErrors || !isLoaded || taskHasErrors + const isDisabled = hasErrors || taskHasErrors return ( - - + + + + { - e.persist() - setHasErrors(prevState => prevState || !e.target.value) - setTitle(e.target.value) - }} + error={!loadedHomework.title} + value={loadedHomework.title || ''} + onChange={(e) => patchHomeworkDraft({ ...loadedHomework, title: e.target.value })} /> - apiSingleton.coursesApi.coursesGetAllTagsForCourse(courseId)}/> + patchHomeworkDraft({ ...loadedHomework, tags })} + isElementSmall={false} + suggestion={tagSuggestion} + requestTags={() => ApiSingleton.coursesApi.coursesGetAllTagsForCourse(courseId)}/> - {tags.includes(TestTag) && + {(loadedHomework.tags || []).includes(TestTag) && Вы можете сгруппировать контрольные работы и переписывания с помощью @@ -299,10 +162,8 @@ const CourseHomeworkEditor: FC<{ label={"Общее описание задания"} height={240} maxHeight={400} - value={description} - onChange={(value) => { - setDescription(value) - }} + value={loadedHomework.description || ''} + onChange={(value) => patchHomeworkDraft({ ...loadedHomework, description: value })} /> @@ -319,21 +180,43 @@ const CourseHomeworkEditor: FC<{ courseUnitType={CourseUnitType.Homework} courseUnitId={homeworkId}/> { - const conflictsWithTasks = changedTaskPublicationDates.some(d => d < metadata.publicationDate!) - setMetadata({ + const conflictsWithTasks = changedTaskPublicationDates.some(d => publicationDate && d < publicationDate) + const currentPublicationDate = loadedHomework.publicationDate instanceof Date + ? loadedHomework.publicationDate.toISOString() + : loadedHomework.publicationDate + const currentDeadlineDate = loadedHomework.deadlineDate instanceof Date + ? loadedHomework.deadlineDate.toISOString() + : loadedHomework.deadlineDate + const nextPublicationDate = state.publicationDate?.toISOString() + const nextDeadlineDate = state.deadlineDate?.toISOString() + const nextDeadlineDateNotSet = state.hasDeadline && !state.deadlineDate + const nextHasErrors = state.hasErrors || conflictsWithTasks + if ( + currentPublicationDate === nextPublicationDate + && currentDeadlineDate === nextDeadlineDate + && (loadedHomework.hasDeadline ?? false) === state.hasDeadline + && (loadedHomework.isDeadlineStrict ?? false) === state.isDeadlineStrict + && (loadedHomework.deadlineDateNotSet ?? false) === nextDeadlineDateNotSet + && (!!(loadedHomework as HomeworkViewModel & { hasErrors?: boolean }).hasErrors) === nextHasErrors + ) { + return + } + patchHomeworkDraft({ + ...loadedHomework, + publicationDate: nextPublicationDate, hasDeadline: state.hasDeadline, + deadlineDate: nextDeadlineDate, isDeadlineStrict: state.isDeadlineStrict, - publicationDate: state.publicationDate, - deadlineDate: state.deadlineDate, - hasErrors: state.hasErrors || conflictsWithTasks, - }) + deadlineDateNotSet: nextDeadlineDateNotSet, + hasErrors: nextHasErrors, + } as unknown as HomeworkViewModel) }} /> @@ -343,7 +226,7 @@ const CourseHomeworkEditor: FC<{ } - {metadata.publicationDate && new Date() >= new Date(metadata.publicationDate) && = new Date(publicationDate) && setEditOptions(value)}/>} setShowDeleteConfirmation(false)} - onSubmit={deleteHomework} + onSubmit={handleDeleteHomework} isOpen={showDeleteConfirmation} dialogTitle={'Удаление задания'} - dialogContentText={getDeleteMessage(homework.title!, initialFilesInfo)} + dialogContentText={getHomeworkDeleteMessage(loadedHomework.title || '', initialFilesInfo)} confirmationWord={''} confirmationText={''} /> @@ -380,46 +263,38 @@ const CourseHomeworkEditor: FC<{ const CourseHomeworkExperimental: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, - getAllHomeworks: () => HomeworkViewModel[], - isMentor: boolean, - initialEditMode: boolean, - onMount: () => void, - onUpdate: (x: { homework: HomeworkViewModel } & { - isDeleted?: boolean - }) => void onAddTask: (homework: HomeworkViewModel) => void, - isProcessing: boolean; onStartProcessing: (homeworkId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; }> = (props) => { + const isCourseMentor = useIsCourseMentor(); + const processingFilesState = useCourseState(state => state.courseFiles.processingFilesState); + const draftHomework = useDraftHomework(props.homeworkAndFilesInfo.homework.id!); + const {startHomeworkEdit} = useCourseActions(); + const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) const tasksCount = homework.tasks!.length const [showEditMode, setShowEditMode] = useState(false) - const [editMode, setEditMode] = useState(false) - - useEffect(() => { - setEditMode(props.initialEditMode) - props.onMount() - }, [homework.id]) - if (editMode) return { - if (update.isSaved) setEditMode(false) - props.onUpdate(update) - }} + if (draftHomework) return + const openEditor = () => { + if (homework.id! < 0) return + startHomeworkEdit(homework.id!) + .catch(() => {}) + } + return setShowEditMode(props.isMentor)} + onMouseEnter={() => setShowEditMode(isCourseMentor)} onMouseLeave={() => setShowEditMode(false)}> - @@ -449,8 +324,8 @@ const CourseHomeworkExperimental: FC<{ { - setEditMode(true) setShowEditMode(false) + openEditor() }}> @@ -463,13 +338,13 @@ const CourseHomeworkExperimental: FC<{ {filesInfo.length > 0 && (
- {props.isProcessing && + {processingFilesState[homework.id!]?.isLoading &&
  Обрабатываем файлы...
} - { const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!); diff --git a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx index 120ac3be7..54bd1f7f6 100644 --- a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx +++ b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx @@ -9,15 +9,12 @@ import { TextField, Typography, Button, - Box, - Link, Checkbox, FormControlLabel } from "@mui/material"; import {MarkdownEditor, MarkdownPreview} from "components/Common/MarkdownEditor"; -import {FC, useEffect, useState, useMemo} from "react" +import {FC, useCallback, useState} from "react" import {ActionOptions, CriterionViewModel, HomeworkTaskViewModel, HomeworkViewModel} from "@/api"; -import ApiSingleton from "../../api/ApiSingleton"; import * as React from "react"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -25,218 +22,174 @@ import {LoadingButton} from "@mui/lab"; import TaskPublicationAndDeadlineDates from "../Common/TaskPublicationAndDeadlineDates"; import DeletionConfirmation from "../DeletionConfirmation"; import ActionOptionsUI from "../Common/ActionOptions"; +import {useDraftTask, useTaskEditorState} from "@/store/storeHooks/taskEditorHooks"; +import {useCourseActions} from "@/store/courseActions"; +import {useIsCourseMentor} from "@/store/storeHooks/courseHooks"; import {Stack} from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; -import Collapse from "@mui/material/Collapse"; -import ExpandLessIcon from "@mui/icons-material/ExpandLess"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import UndoIcon from "@mui/icons-material/Undo"; import TaskCriteria from "./TaskCriteria"; +import TaskCriteriaEditor from "./TaskCriteriaEditor"; import {BonusTag} from "@/components/Common/HomeworkTags"; - -interface IEditTaskMetadataState { - hasDeadline: boolean | undefined; - deadlineDate: Date | undefined; - isDeadlineStrict: boolean | undefined; - publicationDate: Date | undefined; - isPublished: boolean; - hasErrors: boolean -} - type TaskEditData = HomeworkTaskViewModel & { - isModified?: boolean; hasErrors?: boolean; suggestedMaxRating?: number; }; const CourseTaskEditor: FC<{ - speculativeTask: TaskEditData, - speculativeHomework: HomeworkViewModel, - onUpdate: (update: { task: TaskEditData, isDeleted?: boolean, isSaved?: boolean }) => void, - getAllHomeworks: () => HomeworkViewModel[], + task: TaskEditData, + homework: HomeworkViewModel, + onDone?: () => void, toEditHomework: () => void, }> = (props) => { - const [taskData, setTaskData] = useState<{ - task: HomeworkTaskViewModel, - homework: HomeworkViewModel, - isLoaded: boolean - }>({ - task: props.speculativeTask, - homework: props.speculativeHomework, - isLoaded: props.speculativeTask.id! < 0 || props.speculativeTask.isModified === true - }) + const { + patchTaskDraft, + addCriterion, + updateCriterion: updateCriterionInStore, + removeCriterion: removeCriterionInStore, + saveTask, + deleteTask: deleteTaskAction, + cancelTaskEdit, + } = useCourseActions() - const [criteria, setCriteria] = useState(taskData.task.criteria || []) const [isCriteriaOpen, setIsCriteriaOpen] = useState(false) + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) + const [handleSubmitLoading, setHandleSubmitLoading] = useState(false) + const [editOptions, setEditOptions] = useState({sendNotification: false}) - const addDefaultCriterion = () => { - setCriteria(prev => [ - ...prev, - {id: 0, type: 0, name: `Критерий №${prev.length + 1}`, maxPoints: 1} - ]); - setIsCriteriaOpen(true); - }; - - const updateCriterion = (index: number, patch: Partial) => - setCriteria(prev => - prev.map((c, i) => (i === index ? {...c, ...patch} : c)) - ) - - const removeCriterion = (index: number) => - setCriteria(prev => prev.filter((_, i) => i !== index)) - - const criteriaTotalPoints = useMemo( - () => - (criteria).reduce( - (sum, c) => sum + (c.maxPoints || 0), - 0 - ), - [criteria] - ) - - const autoMaxFromCriteria = criteria.length > 0; - - useEffect(() => { - if (autoMaxFromCriteria) setMaxRating(criteriaTotalPoints); - }, [criteriaTotalPoints, autoMaxFromCriteria]); + const task = props.task + const homework = props.homework + const { + criteria, + autoMaxFromCriteria, + maxRating, + isBonusExplicit, + publicationDate, + hasErrors, + isNewTask, + isNewHomework, + homeworkPublicationDateIsSet, + taskPublicationDate, + taskDeadlineDate, + isPublicationDateDisabled, + maxRatingLabel, + } = useTaskEditorState(task, homework) + + const patchTask = useCallback((patch: Partial) => { + patchTaskDraft({ + ...task, + ...patch, + } as TaskEditData) + }, [patchTaskDraft, task]) - const isNewTask = taskData.task.id! < 0 + const addDefaultCriterion = () => { + addCriterion(task, { id: 0, type: 0, name: `Критерий №${criteria.length + 1}`, maxPoints: 1 }) + setIsCriteriaOpen(true) + } - const [metadata, setMetadata] = useState( - isNewTask || taskData.isLoaded ? { - publicationDate: taskData.task.publicationDate, - hasDeadline: taskData.task.hasDeadline, - deadlineDate: taskData.task.deadlineDate, - isDeadlineStrict: taskData.task.isDeadlineStrict, - isPublished: taskData.task.isDeferred || !taskData.homework.isDeferred, - hasErrors: false, - } : undefined) + const updateCriterion = (index: number, patch: Partial) => { + updateCriterionInStore(task, index, patch) + } - useEffect(() => { - if (isNewTask || taskData.isLoaded) return - ApiSingleton.tasksApi - .tasksGetForEditingTask(task.id!) - .then(r => { - const task = r.task! - setTaskData({ - homework: r.homework!, - task: r.task!, - isLoaded: true, - }) - setCriteria(task.criteria || []) - setMetadata({ - hasDeadline: task.hasDeadline!, - deadlineDate: task.deadlineDateNotSet - ? undefined - : new Date(task.deadlineDate!), - isDeadlineStrict: task.isDeadlineStrict!, - publicationDate: task.publicationDateNotSet - ? undefined - : new Date(task.publicationDate!), - isPublished: !task.isDeferred, - hasErrors: false, - }) - }) - }, []) + const removeCriterion = (index: number) => { + removeCriterionInStore(task, index) + } - const {task, homework, isLoaded} = taskData - const {id} = task + const handleTitleChange = (title: string) => { + patchTask({title}) + } - //TODO: suggested max rating - const [title, setTitle] = useState(task.title!) - const [maxRating, setMaxRating] = useState( - criteria.length > 0 ? criteriaTotalPoints : task.maxRating! - ) - const [description, setDescription] = useState(task.description || "") - const [isBonusExplicit, setIsBonusExplicit] = useState(props.speculativeTask.tags!.includes(BonusTag) && !props.speculativeHomework.tags!.includes(BonusTag)) + const handleToggleBonus = () => { + patchTask({ + tags: isBonusExplicit + ? (task.tags || []).filter(t => t !== BonusTag) + : [...(homework.tags || []), BonusTag] + }) + } - const [hasErrors, setHasErrors] = useState(props.speculativeTask.hasErrors || false) - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) + const handleMaxRatingChange = (value: string) => { + if (autoMaxFromCriteria) return - const [handleSubmitLoading, setHandleSubmitLoading] = useState(false); - const [editOptions, setEditOptions] = useState({sendNotification: false}) + const raw = +value || 0 + const clamped = raw < 1 ? 1 : raw > 100 ? 100 : Math.round(raw) + const ratingErrors = clamped < 1 || clamped > 100 - const publicationDate = metadata?.publicationDate || homework.publicationDate + patchTask({ + maxRating: clamped, + hasErrors: ratingErrors || !!(task as TaskEditData).hasErrors, + }) + } - useEffect(() => { - const update = { - ...props.speculativeTask, - ...metadata!, - title: title!, - description: description, - deadlineDateNotSet: metadata?.hasDeadline === true && !metadata.deadlineDate, - maxRating: maxRating, - isBonusExplicit: isBonusExplicit, - tags: isBonusExplicit ? [...homework.tags!, BonusTag] : homework.tags!, - hasErrors: hasErrors, - criteria: criteria, - } - props.onUpdate({task: update}); - }, [title, description, maxRating, metadata, isBonusExplicit, hasErrors, criteria]); + const handleDescriptionChange = (description: string) => { + patchTask({description}) + } - useEffect(() => { - setHasErrors(!title || maxRating <= 0 || metadata?.hasErrors === true) - }, [title, maxRating, metadata?.hasErrors]) + const handleDatesChange = (state: { + hasDeadline?: boolean; + isDeadlineStrict?: boolean; + publicationDate?: Date; + deadlineDate?: Date; + hasErrors: boolean; + }) => { + patchTaskDraft({ + ...task, + hasDeadline: state.hasDeadline, + isDeadlineStrict: state.isDeadlineStrict, + publicationDate: state.publicationDate?.toISOString(), + deadlineDate: state.deadlineDate?.toISOString(), + deadlineDateNotSet: state.hasDeadline && !state.deadlineDate, + hasErrors: state.hasErrors, + } as unknown as HomeworkTaskViewModel) + } - const handleSubmit = async (e: any) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setHandleSubmitLoading(true) - - const update = { - ...metadata!, - title: title!, - description: description, - isBonusExplicit: isBonusExplicit, - maxRating: maxRating, - actionOptions: editOptions, - criteria: criteria, - }; - - const updatedTask = isNewTask - ? await ApiSingleton.tasksApi.tasksAddTask(homework.id!, update) - : await ApiSingleton.tasksApi.tasksUpdateTask(+id!, update) - - if (isNewTask) - props.onUpdate({ - task: props.speculativeTask, - isDeleted: true, - }) - props.onUpdate({task: updatedTask.value!, isSaved: true}) + try { + await saveTask(task.id!, homework.id!, editOptions) + props.onDone?.() + } finally { + setHandleSubmitLoading(false) + } } - const deleteTask = async () => { - if (!isNewTask) await ApiSingleton.tasksApi.tasksDeleteTask(id!) - props.onUpdate({task, isDeleted: true}) + const handleDeleteTask = async () => { + await deleteTaskAction(task.id!, homework.id!) } - const isDisabled = hasErrors || !isLoaded - const isNewHomework = taskData.task.homeworkId! < 0 - - const homeworkPublicationDateIsSet = !homework.publicationDateNotSet + const handleCancel = () => { + cancelTaskEdit(task.id!, homework.id!) + props.onDone?.() + } - const maxRatingLabel = - criteria.length > 0 ? "Критерии" : props.speculativeTask.suggestedMaxRating === maxRating ? "Вычислено" : undefined + const isDisabled = hasErrors return ( - - + + + + { - e.persist() - setTitle(e.target.value) - }} + value={task.title || ''} + onChange={(e) => handleTitleChange(e.target.value)} /> - {!homework.tags!.includes(BonusTag) && { - setIsBonusExplicit(prevState => !prevState) - }} + onChange={handleToggleBonus} /> } />} @@ -266,13 +217,9 @@ const CourseTaskEditor: FC<{ margin="normal" type="number" value={maxRating} + inputProps={{min: 1, max: 100}} InputProps={{readOnly: autoMaxFromCriteria}} - onChange={(e) => { - if (!autoMaxFromCriteria) { - e.persist(); - setMaxRating(+e.target.value); - } - }} + onChange={(e) => handleMaxRatingChange(e.target.value)} /> @@ -282,35 +229,24 @@ const CourseTaskEditor: FC<{ label={"Условие задачи"} height={240} maxHeight={400} - value={description} - onChange={(value) => { - setDescription(value) - }} + value={task.description || ''} + onChange={handleDescriptionChange} /> - {metadata && homeworkPublicationDateIsSet && + {homeworkPublicationDateIsSet && { - setMetadata({ - hasDeadline: state.hasDeadline, - isDeadlineStrict: state.isDeadlineStrict, - publicationDate: state.publicationDate, - deadlineDate: state.deadlineDate, - isPublished: metadata.isPublished, - hasErrors: state.hasErrors - }) - }} + hasDeadline={task.hasDeadline ?? false} + isDeadlineStrict={task.isDeadlineStrict ?? false} + publicationDate={taskPublicationDate} + deadlineDate={taskDeadlineDate} + disabledPublicationDate={isPublicationDateDisabled} + onChange={handleDatesChange} /> } - {metadata && !homeworkPublicationDateIsSet && + {!homeworkPublicationDateIsSet && } - - {criteria.length === 0 && ( - - - - Критерии оценивания не указаны.  - - - - - Добавить критерий оценивания - - - - )} - {criteria.length > 0 && ( - <> - - - setIsCriteriaOpen(prev => !prev)} - > - {isCriteriaOpen ? ( - - ) : ( - - )} - - - - - - Критерии оценивания - - - - - - - {criteria.map((c, index) => ( - - - { - const raw = e.target.value; - const limited = raw.slice(0, 50); - updateCriterion(index, {name: limited}); - }} - /> - - - - { - if (e.key === "-") e.preventDefault(); - }} - onChange={(e) => - updateCriterion(index, { - maxPoints: Math.max(+e.target.value, 1), - }) - } - onBlur={(e) => - updateCriterion(index, { - maxPoints: Math.max(+e.target.value, 1), - }) - } - /> - - - removeCriterion(index)} - color={"error"} - size="small" - > - - - - - ))} - - - - - )} - + setIsCriteriaOpen(prev => !prev)} + onAddCriterion={addDefaultCriterion} + onUpdateCriterion={updateCriterion} + onRemoveCriterion={removeCriterion} + /> {!isNewHomework && publicationDate && new Date() >= new Date(publicationDate) && setShowDeleteConfirmation(false)} - onSubmit={deleteTask} + onSubmit={handleDeleteTask} isOpen={showDeleteConfirmation} dialogTitle={'Удаление задачи'} - dialogContentText={`Вы точно хотите удалить задачу '${title || ""}'?`} + dialogContentText={`Вы точно хотите удалить задачу '${task.title || ""}'?`} confirmationWord={''} confirmationText={''} /> @@ -494,51 +313,38 @@ const CourseTaskEditor: FC<{ const CourseTaskExperimental: FC<{ task: TaskEditData, homework: HomeworkViewModel, - isMentor: boolean, - initialEditMode: boolean, - onMount: () => void, - onUpdate: (x: { task: TaskEditData, isDeleted?: boolean }) => void toEditHomework: () => void, - getAllHomeworks: () => HomeworkViewModel[], }> = (props) => { + const isCourseMentor = useIsCourseMentor(); + const draftTask = useDraftTask(props.task.id!, props.task.homeworkId!); + const {startTaskEdit} = useCourseActions(); + const {task, homework} = props const [showEditMode, setShowEditMode] = useState(false) - const [editMode, setEditMode] = useState(false) - - useEffect(() => { - setEditMode(props.initialEditMode) - props.onMount() - }, [task.id]) - if (editMode) { + if (draftTask) { return { - const updateFix = { - ...update, - task: { - ...update.task, - isModified: !update.isSaved, - } - } - props.onUpdate(updateFix) - if (update.isSaved) setEditMode(false) - }} - getAllHomeworks={props.getAllHomeworks} + task={draftTask} + homework={homework} toEditHomework={props.toEditHomework} /> } + const handleOpenEditor = () => { + if (task.id! < 0) return + startTaskEdit(task.id!) + .catch(() => {}) + } + return ( setShowEditMode(props.isMentor)} + onMouseEnter={() => setShowEditMode(isCourseMentor)} onMouseLeave={() => setShowEditMode(false)} > - - + {task.title} @@ -558,7 +364,7 @@ const CourseTaskExperimental: FC<{ { setShowEditMode(false); - setEditMode(true); + handleOpenEditor(); }} > diff --git a/hwproj.front/src/components/Tasks/TaskCriteriaEditor.tsx b/hwproj.front/src/components/Tasks/TaskCriteriaEditor.tsx new file mode 100644 index 000000000..494e2aa53 --- /dev/null +++ b/hwproj.front/src/components/Tasks/TaskCriteriaEditor.tsx @@ -0,0 +1,170 @@ +import {CriterionViewModel} from "@/api"; +import { + Box, + Button, + Chip, + Collapse, + Grid, + IconButton, + Link, + Stack, + TextField, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import {FC} from "react"; + +const TaskCriteriaEditor: FC<{ + criteria: CriterionViewModel[]; + isOpen: boolean; + onToggleOpen: () => void; + onAddCriterion: () => void; + onUpdateCriterion: (index: number, patch: Partial) => void; + onRemoveCriterion: (index: number) => void; +}> = ({ + criteria, + isOpen, + onToggleOpen, + onAddCriterion, + onUpdateCriterion, + onRemoveCriterion, +}) => { + return ( + + {criteria.length === 0 && ( + + + + Критерии оценивания не указаны.  + + + + + Добавить критерий оценивания + + + + )} + + {criteria.length > 0 && ( + <> + + + + {isOpen ? ( + + ) : ( + + )} + + + + + + Критерии оценивания + + + + + + + {criteria.map((criterion, index) => ( + + + { + const raw = e.target.value; + const limited = raw.slice(0, 50); + onUpdateCriterion(index, {name: limited}); + }} + /> + + + + { + if (e.key === "-") e.preventDefault(); + }} + onChange={(e) => + onUpdateCriterion(index, { + maxPoints: Math.max(+e.target.value, 1), + }) + } + onBlur={(e) => + onUpdateCriterion(index, { + maxPoints: Math.max(+e.target.value, 1), + }) + } + /> + + + + onRemoveCriterion(index)} + color={"error"} + size="small" + > + + + + + ))} + + + + + + )} + + ); +}; + +export default TaskCriteriaEditor; \ No newline at end of file diff --git a/hwproj.front/src/index.tsx b/hwproj.front/src/index.tsx index f63d3e5db..fcbf374f9 100644 --- a/hwproj.front/src/index.tsx +++ b/hwproj.front/src/index.tsx @@ -8,6 +8,8 @@ import {BrowserRouter} from "react-router-dom"; import ThemeProvider from "@material-ui/styles/ThemeProvider"; import {createTheme} from "@material-ui/core/styles"; import {SnackbarProvider} from "notistack"; +import { Provider } from "react-redux"; +import { store } from "./store/store"; const theme = createTheme({ typography: { @@ -22,13 +24,15 @@ const theme = createTheme({ }); ReactDOM.render( - - - - - - - , + + + + + + + + + , document.getElementById("root") ); diff --git a/hwproj.front/src/store/courseActions.ts b/hwproj.front/src/store/courseActions.ts new file mode 100644 index 000000000..71c7269f4 --- /dev/null +++ b/hwproj.front/src/store/courseActions.ts @@ -0,0 +1,26 @@ +import {useHomeworkEditing} from './storeHooks/homeworkEditorHooks'; +import {useTaskEditing} from './storeHooks/taskEditorHooks'; + +export const useCourseActions = () => { + const homework = useHomeworkEditing(); + const task = useTaskEditing(); + return { + createHomework: homework.createHomework, + startHomeworkEdit: homework.startHomeworkEdit, + patchHomeworkDraft: homework.patchHomeworkDraft, + saveHomework: homework.saveHomework, + cancelHomeworkEdit: homework.cancelHomeworkEdit, + deleteHomework: homework.deleteHomework, + + createTask: task.createTask, + startTaskEdit: task.startTaskEdit, + patchTaskDraft: task.patchTaskDraft, + saveTask: task.saveTask, + cancelTaskEdit: task.cancelTaskEdit, + deleteTask: task.deleteTask, + + addCriterion: task.addCriterion, + updateCriterion: task.updateCriterion, + removeCriterion: task.removeCriterion, + }; +}; \ No newline at end of file diff --git a/hwproj.front/src/store/hooks.ts b/hwproj.front/src/store/hooks.ts new file mode 100644 index 000000000..a32e197cf --- /dev/null +++ b/hwproj.front/src/store/hooks.ts @@ -0,0 +1,5 @@ +import {useDispatch, useSelector} from 'react-redux'; +import type {RootState, AppDispatch} from './store'; + +export const useCourseDispatch = useDispatch.withTypes(); +export const useCourseState = useSelector.withTypes(); \ No newline at end of file diff --git a/hwproj.front/src/store/slices/courseEditingSlice.ts b/hwproj.front/src/store/slices/courseEditingSlice.ts new file mode 100644 index 000000000..6c6631824 --- /dev/null +++ b/hwproj.front/src/store/slices/courseEditingSlice.ts @@ -0,0 +1,96 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {HomeworkTaskViewModel, HomeworkViewModel} from "@/api"; + +export interface SelectedItem { + isHomework: boolean; + id: number | undefined; +} + +interface CourseEditingState { + draftHomeworks: HomeworkViewModel[]; + draftIdCounter: number; + selectedItem: SelectedItem; +} + +const initialState: CourseEditingState = { + draftHomeworks: [], + draftIdCounter: -1, + selectedItem: {isHomework: true, id: undefined,}, +}; + +const courseEditingSlice = createSlice({ + name: 'editing', + initialState, + reducers: { + addDraftHomework: (state, action: PayloadAction) => { + const exists = state.draftHomeworks.some(dh => dh.id === action.payload.id); + if (!exists) { + state.draftHomeworks.push(action.payload); + } + }, + + updateDraftHomework: (state, action: PayloadAction) => { + const id = state.draftHomeworks.findIndex(dh => dh.id === action.payload.id); + if (id !== -1) { + const existingTasks = state.draftHomeworks[id].tasks; + state.draftHomeworks[id] = {...action.payload, tasks: existingTasks}; + } + }, + + removeDraftHomework: (state, action: PayloadAction) => { + state.draftHomeworks = state.draftHomeworks.filter(dh => dh.id !== action.payload); + }, + + addDraftTask: (state, action: PayloadAction) => { + const hw = state.draftHomeworks.find(dh => dh.id === action.payload.homeworkId); + if (hw) { + const exists = hw.tasks?.some(t => t.id === action.payload.id); + if (!exists) { + if (!hw.tasks) hw.tasks = []; + hw.tasks.push(action.payload); + } + } + }, + + updateDraftTask: (state, action: PayloadAction) => { + const hw = state.draftHomeworks.find(dh => dh.id === action.payload.homeworkId); + if (hw && hw.tasks) { + const idx = hw.tasks.findIndex(t => t.id === action.payload.id); + if (idx !== -1) { + hw.tasks[idx] = action.payload; + } + } + }, + + removeDraftTask: (state, action: PayloadAction<{ homeworkId: number; taskId: number }>) => { + const hw = state.draftHomeworks.find(dh => dh.id === action.payload.homeworkId); + if (hw && hw.tasks) { + hw.tasks = hw.tasks.filter(t => t.id !== action.payload.taskId); + } + }, + + decrementDraftId: (state) => { + state.draftIdCounter -= 1; + }, + + setSelectedItem: (state, action: PayloadAction) => { + state.selectedItem = action.payload; + }, + + resetEditingState: () => initialState, + }, +}); + +export const { + addDraftHomework, + updateDraftHomework, + removeDraftHomework, + addDraftTask, + updateDraftTask, + removeDraftTask, + decrementDraftId, + setSelectedItem, + resetEditingState, +} = courseEditingSlice.actions; + +export default courseEditingSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/courseFileSlice.ts b/hwproj.front/src/store/slices/courseFileSlice.ts new file mode 100644 index 000000000..ab64880bf --- /dev/null +++ b/hwproj.front/src/store/slices/courseFileSlice.ts @@ -0,0 +1,48 @@ +import {createSlice, PayloadAction} from "@reduxjs/toolkit"; +import {FileInfoDTO} from "@/api"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; + +interface ProcessingState { + isLoading: boolean; +} + +interface CourseFilesState { + items: FileInfoDTO[]; + processingFilesState: Record; +} + +const initialState: CourseFilesState = { + items: [], + processingFilesState: {}, +} + +const courseFilesSlice = createSlice({ + name: "courseFiles", + initialState, + reducers: { + setCourseFiles(state, action: PayloadAction) { + state.items = action.payload; + }, + + updateCourseFiles(state, action: PayloadAction<{ + files: FileInfoDTO[]; + unitType: CourseUnitType; + unitId: number; + }>) { + const { files, unitType, unitId } = action.payload; + state.items = [ + ...state.items.filter(f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), + ...files + ]; + }, + + setProcessingLoading(state, action: PayloadAction<{ homeworkId: number; isLoading: boolean }>) { + const { homeworkId, isLoading } = action.payload; + state.processingFilesState[homeworkId] = { isLoading }; + }, + }, +}) + +export const {setCourseFiles, updateCourseFiles, setProcessingLoading} = courseFilesSlice.actions; + +export default courseFilesSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/courseSlice.ts b/hwproj.front/src/store/slices/courseSlice.ts new file mode 100644 index 000000000..a18972d66 --- /dev/null +++ b/hwproj.front/src/store/slices/courseSlice.ts @@ -0,0 +1,64 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {AccountDataDto, CourseViewModel} from '@/api'; + +export type CurrentCourseMeta = Pick< + CourseViewModel, + 'id' | 'name' | 'groupName' | 'isCompleted' +>; + +export const toCurrentCourseMeta = ( + course: CourseViewModel +): CurrentCourseMeta => ({ + id: course.id, + name: course.name, + groupName: course.groupName, + isCompleted: course.isCompleted, +}); + +interface CourseState { + isFound: boolean; + currentCourseMeta: CurrentCourseMeta | null; + mentors: AccountDataDto[]; + acceptedStudents: AccountDataDto[]; + newStudents: AccountDataDto[]; +} + +const initialState: CourseState = { + isFound: false, + currentCourseMeta: null, + mentors: [], + acceptedStudents: [], + newStudents: [], +}; + +const courseSlice = createSlice({ + name: 'course', + initialState, + reducers: { + setCourse(state, action: PayloadAction) { + state.currentCourseMeta = action.payload; + state.isFound = true; + }, + + setMentors(state, action: PayloadAction) { + state.mentors = action.payload; + }, + + setAcceptedStudents(state, action: PayloadAction) { + state.acceptedStudents = action.payload + }, + + setNewStudents(state, action: PayloadAction) { + state.newStudents = action.payload + }, + }, +}); + +export const { + setCourse, + setMentors, + setAcceptedStudents, + setNewStudents, +} = courseSlice.actions; + +export default courseSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/homeworkSlice.ts b/hwproj.front/src/store/slices/homeworkSlice.ts new file mode 100644 index 000000000..b3d435462 --- /dev/null +++ b/hwproj.front/src/store/slices/homeworkSlice.ts @@ -0,0 +1,64 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {HomeworkViewModel, HomeworkTaskViewModel} from '@/api'; + +interface HomeworkState { + items: HomeworkViewModel[]; +} + +const initialState: HomeworkState = { + items: [], +} + +const homeworkSlice = createSlice({ + name: 'homeworks', + initialState, + reducers: { + setHomeworks(state, action: PayloadAction) { + state.items = action.payload; + }, + + deleteHomework(state, action: PayloadAction) { + state.items = state.items.filter(hw => hw.id !== action.payload); + }, + + updateOrInsertHomework(state, action: PayloadAction) { + const index = state.items.findIndex(hw => hw.id === action.payload.id); + if (index !== -1) { + state.items[index] = action.payload; + } else { + state.items.push(action.payload); + } + }, + + updateTask(state, action: PayloadAction) { + const task = action.payload; + const homework = state.items.find(hw => hw.id === task.homeworkId); + if (homework && homework.tasks) { + const taskIndex = homework.tasks.findIndex(t => t.id === task.id); + if (taskIndex !== -1) { + homework.tasks[taskIndex] = task; + } + else { + homework.tasks.push(task); + } + } + }, + + deleteTask(state, action: PayloadAction<{homeworkId: number, taskId: number}>) { + const homework = state.items.find(hw => hw.id === action.payload.homeworkId); + if (homework && homework.tasks) { + homework.tasks = homework.tasks.filter(t => t.id !== action.payload.taskId); + } + }, + }, +}); + +export const { + setHomeworks, + updateOrInsertHomework, + deleteHomework, + updateTask, + deleteTask, +} = homeworkSlice.actions; + +export default homeworkSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/solutionSlice.ts b/hwproj.front/src/store/slices/solutionSlice.ts new file mode 100644 index 000000000..fa0328ed9 --- /dev/null +++ b/hwproj.front/src/store/slices/solutionSlice.ts @@ -0,0 +1,24 @@ +import {createSlice, PayloadAction} from "@reduxjs/toolkit"; +import {StatisticsCourseMatesModel} from '@/api'; + +interface SolutionState { + studentSolutions: StatisticsCourseMatesModel[]; +} + +const initialState: SolutionState = { + studentSolutions: [], +}; + +const solutionSlice = createSlice({ + name: "solution", + initialState, + reducers: { + setStudentSolutions(state, action: PayloadAction) { + state.studentSolutions = action.payload; + }, + }, +}); + +export const {setStudentSolutions} = solutionSlice.actions; + +export default solutionSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/userSlice.ts b/hwproj.front/src/store/slices/userSlice.ts new file mode 100644 index 000000000..5e5024410 --- /dev/null +++ b/hwproj.front/src/store/slices/userSlice.ts @@ -0,0 +1,37 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +export type UserRole = "Lecturer" | "Expert" | "Student" | null; + +interface UserState { + userId: string | null; + isLecturer: boolean; + isExpert: boolean; +} + +type SetUserPayload = { + userId: string | null; + role: UserRole; +} + +const initialState: UserState = { + userId: null, + isLecturer: false, + isExpert: false, +}; + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + const {userId, role} = action.payload; + state.userId = userId; + state.isLecturer = role === "Lecturer"; + state.isExpert = role === "Expert"; + }, + }, +}); + +export const {setUser} = userSlice.actions; + +export default userSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/store.ts b/hwproj.front/src/store/store.ts new file mode 100644 index 000000000..4c21ad128 --- /dev/null +++ b/hwproj.front/src/store/store.ts @@ -0,0 +1,21 @@ +import {configureStore} from '@reduxjs/toolkit'; +import courseReducer from './slices/courseSlice'; +import homeworkReducer from './slices/homeworkSlice'; +import solutionsReducer from './slices/solutionSlice'; +import courseFilesReducer from './slices/courseFileSlice'; +import userReducer from './slices/userSlice'; +import courseEditingReducer from './slices/courseEditingSlice'; + +export const store = configureStore({ + reducer: { + course: courseReducer, + homeworks: homeworkReducer, + solutions: solutionsReducer, + courseFiles: courseFilesReducer, + user: userReducer, + editing: courseEditingReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; \ No newline at end of file diff --git a/hwproj.front/src/store/storeHooks/courseEditingHooks.ts b/hwproj.front/src/store/storeHooks/courseEditingHooks.ts new file mode 100644 index 000000000..e82f9c6fe --- /dev/null +++ b/hwproj.front/src/store/storeHooks/courseEditingHooks.ts @@ -0,0 +1,145 @@ +import {useCallback, useEffect, useMemo} from 'react'; +import {useCourseDispatch, useCourseState} from '../hooks'; +import {setSelectedItem, SelectedItem} from '../slices/courseEditingSlice'; +import {HomeworkViewModel, HomeworkTaskViewModel} from '@/api'; +import {TestTag} from "@/components/Common/HomeworkTags"; + +export const useMergedHomeworks = () => { + const committedHomeworks = useCourseState(state => state.homeworks.items); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + + return useMemo(() => { + const newDrafts = draftHomeworks.filter(dh => dh.id! < 0); + const result: HomeworkViewModel[] = []; + + const mergeTasks = (committed: HomeworkTaskViewModel[] = [], draft: HomeworkTaskViewModel[] = []) => { + const byId = new Map(); + for (const task of committed) byId.set(task.id!, task); + for (const task of draft) byId.set(task.id!, task); + return Array.from(byId.values()); + }; + + for (const committed of committedHomeworks) { + const draft = draftHomeworks.find(dh => dh.id === committed.id); + if (!draft) { + result.push(committed); + continue; + } + + result.push({ + ...committed, + ...draft, + tasks: mergeTasks(committed.tasks, draft.tasks), + }); + } + + result.push(...newDrafts); + return result; + }, [committedHomeworks, draftHomeworks]); +}; + +export const useEditingSelection = () => { + const dispatch = useCourseDispatch(); + const selectedItem = useCourseState(state => state.editing.selectedItem); + + const select = useCallback((item: SelectedItem) => { + dispatch(setSelectedItem(item)); + }, [dispatch]); + + return { selectedItem, select }; +}; + +export const useEditingStatus = () => { + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + + const homeworkEditingIds = useMemo( + () => new Set(draftHomeworks.map(homework => homework.id!)), + [draftHomeworks] + ); + + const taskEditingIds = useMemo( + () => new Set( + draftHomeworks + .flatMap(homework => homework.tasks ?? []) + .map(task => task.id!) + ), + [draftHomeworks] + ); + + const isHomeworkEditing = useCallback((homeworkId: number) => { + return homeworkEditingIds.has(homeworkId); + }, [homeworkEditingIds]); + + const isTaskEditing = useCallback((taskId: number) => { + return taskEditingIds.has(taskId); + }, [taskEditingIds]); + + return { + isHomeworkEditing, + isTaskEditing, + }; +}; + +export const useSelectedCourseItemData = (homeworks: HomeworkViewModel[]) => { + const selectedItem = useCourseState(state => state.editing.selectedItem); + + return useMemo(() => { + const {id, isHomework} = selectedItem; + + const selectedHomework = isHomework + ? homeworks.find(homework => homework.id === id) + : homeworks.find(homework => homework.tasks?.some(task => task.id === id)); + + const selectedItemData = isHomework + ? selectedHomework + : selectedHomework?.tasks?.find(task => task.id === id); + + return { + selectedItem, + selectedHomework, + selectedItemData, + }; + }, [homeworks, selectedItem]); +}; + +export const useVisibleCourseHomeworks = ( + mergedHomeworks: HomeworkViewModel[], + hideDeferred: boolean, + showOnlyGroupedTest: string | undefined +) => { + return useMemo( + () => mergedHomeworks.slice().reverse().filter(homework => { + if (hideDeferred) return !homework.isDeferred; + if (showOnlyGroupedTest !== undefined) { + return homework.tags!.includes(TestTag) && homework.tags!.includes(showOnlyGroupedTest); + } + return true; + }), + [mergedHomeworks, hideDeferred, showOnlyGroupedTest] + ); +}; + +export const useEnsureSelectedHomework = ( + homeworks: HomeworkViewModel[], + selectedHomeworkId: number | undefined, + resetKey: unknown +) => { + const {selectedItem, select} = useEditingSelection(); + + useEffect(() => { + const currentSelectionExists = selectedItem.isHomework + ? homeworks.some(homework => homework.id === selectedItem.id) + : homeworks.some(homework => homework.tasks?.some(task => task.id === selectedItem.id)); + + if (currentSelectionExists) { + return; + } + + const defaultHomeworkIndex = Math.max( + selectedHomeworkId ? homeworks.findIndex(homework => homework.id === selectedHomeworkId) : 0, + 0 + ); + const defaultHomework = homeworks[defaultHomeworkIndex]; + select({isHomework: true, id: defaultHomework?.id}); + }, [homeworks, resetKey, select, selectedHomeworkId, selectedItem.id, selectedItem.isHomework]); +}; \ No newline at end of file diff --git a/hwproj.front/src/store/storeHooks/courseHooks.ts b/hwproj.front/src/store/storeHooks/courseHooks.ts new file mode 100644 index 000000000..05e96345a --- /dev/null +++ b/hwproj.front/src/store/storeHooks/courseHooks.ts @@ -0,0 +1,242 @@ +import {useCallback, useEffect, useRef} from 'react'; +import {useCourseDispatch, useCourseState} from '../hooks'; +import { + setCourse, + setMentors, + setAcceptedStudents, + setNewStudents, + toCurrentCourseMeta +} from '../slices/courseSlice'; +import {setHomeworks} from '../slices/homeworkSlice'; +import {setStudentSolutions} from '../slices/solutionSlice'; +import {setCourseFiles, updateCourseFiles, setProcessingLoading} from '../slices/courseFileSlice'; +import {setUser, UserRole} from '../slices/userSlice'; +import {resetEditingState} from '../slices/courseEditingSlice'; +import ApiSingleton from '@/api/ApiSingleton'; +import {FileInfoDTO, ScopeDTO, StatisticsCourseHomeworksModel, StatisticsCourseMatesModel, StatisticsCourseTasksModel} from '@/api'; +import {CourseUnitType} from '@/components/Files/CourseUnitType'; +import {FileStatus} from '@/components/Files/FileStatus'; +import {enqueueSnackbar} from 'notistack'; +import ErrorsHandler from '@/components/Utils/ErrorsHandler'; + +export type StartProcessingFn = ( + homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[] +) => void; + +export const useIsCourseMentor = () => { + const mentors = useCourseState(state => state.course.mentors); + const userId = useCourseState(state => state.user.userId); + return mentors.some(m => m.userId === userId); +}; + +export const useUnratedSolutionsCount = () => { + const studentSolutions = useCourseState(state => state.solutions.studentSolutions); + return studentSolutions + .flatMap((x: StatisticsCourseMatesModel) => x.homeworks ?? []) + .flatMap((x: StatisticsCourseHomeworksModel) => x.tasks ?? []) + .filter((t: StatisticsCourseTasksModel) => t.solution?.slice(-1)[0]?.state === 0) + .length; +}; + +export const useCoursePageData = () => { + const course = useCourseState(state => state.course.currentCourseMeta); + const isFound = useCourseState(state => state.course.isFound); + const mentors = useCourseState(state => state.course.mentors); + const acceptedStudents = useCourseState(state => state.course.acceptedStudents); + const newStudents = useCourseState(state => state.course.newStudents); + const courseHomeworks = useCourseState(state => state.homeworks.items); + const studentSolutions = useCourseState(state => state.solutions.studentSolutions); + const userId = useCourseState(state => state.user.userId); + const isLecturer = useCourseState(state => state.user.isLecturer); + const isExpert = useCourseState(state => state.user.isExpert); + + return { + course, + isFound, + mentors, + acceptedStudents, + newStudents, + courseHomeworks, + studentSolutions, + userId, + isLecturer, + isExpert, + isLecturerOrExpertOnSite: isLecturer || isExpert, + isSignedInCourse: newStudents?.some(cm => cm.userId === userId) ?? false, + isAcceptedStudent: acceptedStudents?.some(cm => cm.userId === userId) ?? false, + }; +}; + +export const useCourseLoader = (courseId: number) => { + const dispatch = useCourseDispatch(); + const userId = useCourseState(state => state.user.userId); + const isLecturerOrExpertOnSite = useCourseState(state => state.user.isLecturer || state.user.isExpert); + + const initUser = useCallback(() => { + const id = ApiSingleton.authService.getUserId(); + const role = ApiSingleton.authService.getRole() as UserRole; + dispatch(setUser({userId: id, role})); + }, [dispatch]); + + const loadCourse = useCallback(async () => { + const course = await ApiSingleton.coursesApi.coursesGetCourseData(courseId); + + const shouldRefreshToken = !isLecturerOrExpertOnSite && course && course.mentors!.some(t => t.userId === userId); + if (shouldRefreshToken) { + const newToken = await ApiSingleton.accountApi.accountRefreshToken(); + newToken.value && ApiSingleton.authService.refreshToken(newToken.value.accessToken!); + return null; + } + + dispatch(setCourse(toCurrentCourseMeta(course))); + dispatch(setMentors(course.mentors!)); + dispatch(setAcceptedStudents(course.acceptedStudents!)); + dispatch(setNewStudents(course.newStudents!)); + dispatch(setHomeworks(course.homeworks!)); + return course; + }, [dispatch, courseId, userId, isLecturerOrExpertOnSite]); + + const loadStudentSolutions = useCallback(async () => { + const res = await ApiSingleton.statisticsApi.statisticsGetCourseStatistics(courseId); + dispatch(setStudentSolutions(res)); + }, [dispatch, courseId]); + + const resetEditing = useCallback(() => { + dispatch(resetEditingState()); + }, [dispatch]); + + return {initUser, loadCourse, loadStudentSolutions, resetEditing}; +}; + +export const useRefreshCourse = () => { + const dispatch = useCourseDispatch(); + return useCallback(async (courseId: number) => { + const course = await ApiSingleton.coursesApi.coursesGetCourseData(courseId); + dispatch(setCourse(toCurrentCourseMeta(course))); + dispatch(setMentors(course.mentors ?? [])); + dispatch(setAcceptedStudents(course.acceptedStudents ?? [])); + dispatch(setNewStudents(course.newStudents ?? [])); + dispatch(setHomeworks(course.homeworks ?? [])); + }, [dispatch]); +}; + +export const useCourseFiles = (courseId: number, defaultIsCourseMentor?: boolean) => { + const dispatch = useCourseDispatch(); + + const loadCourseFiles = useCallback(async (isCourseMentor = defaultIsCourseMentor ?? false) => { + let files = [] as FileInfoDTO[]; + try { + files = await ApiSingleton.filesApi.filesGetFilesInfo(courseId, !isCourseMentor); + } catch (e) { + const errors = await ErrorsHandler.getErrorMessages(e as Response); + enqueueSnackbar(errors[0], {variant: 'warning', autoHideDuration: 1990}); + } + dispatch(setCourseFiles(files)); + }, [dispatch, courseId, defaultIsCourseMentor]); + + const updateFiles = useCallback((files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { + dispatch(updateCourseFiles({files, unitType, unitId})); + }, [dispatch]); + + const setFileLoading = useCallback((homeworkId: number, isLoading: boolean) => { + dispatch(setProcessingLoading({homeworkId, isLoading})); + }, [dispatch]); + + return {loadCourseFiles, updateFiles, setFileLoading}; +}; + +export const useCourseFilePolling = ( + courseId: number, + updateFiles: (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => void, + setFileLoading: (homeworkId: number, isLoading: boolean) => void +) => { + const intervalsRef = useRef>({}); + + const stopProcessing = useCallback((homeworkId: number) => { + if (intervalsRef.current[homeworkId]) { + const {interval, timeout} = intervalsRef.current[homeworkId]; + clearInterval(interval); + clearTimeout(timeout); + delete intervalsRef.current[homeworkId]; + } + }, []); + + const startProcessing = useCallback( + (homeworkId, courseUnitType, previouslyExistingFilesCount, waitingNewFilesCount, deletingFilesIds) => { + stopProcessing(homeworkId); + let attempt = 0; + const maxAttempts = 10; + const delay = 1000; + const scopeDto: ScopeDTO = { + courseId, + courseUnitType, + courseUnitId: homeworkId, + }; + + const fetchFiles = async () => { + if (attempt >= maxAttempts) { + stopProcessing(homeworkId); + enqueueSnackbar('Превышено допустимое количество попыток получения информации о файлах', { + variant: 'warning', + autoHideDuration: 2000, + }); + return; + } + attempt++; + try { + const files = await ApiSingleton.filesApi.filesGetStatuses(scopeDto); + if ( + waitingNewFilesCount === 0 && + files.filter(f => f.status === FileStatus.ReadyToUse).length === + previouslyExistingFilesCount - deletingFilesIds.length + ) { + updateFiles(files, courseUnitType, homeworkId); + setFileLoading(homeworkId, false); + } + if ( + waitingNewFilesCount > 0 && + files.filter(f => !deletingFilesIds.some(dfi => dfi === f.id)).length === + previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount + ) { + updateFiles(files, courseUnitType, homeworkId); + setFileLoading(homeworkId, false); + } + if ( + files.length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount && + files.every(f => f.status !== FileStatus.Uploading && f.status !== FileStatus.Deleting) + ) { + stopProcessing(homeworkId); + setFileLoading(homeworkId, false); + } + } catch (error) { + console.error(`Ошибка (попытка ${attempt}):`, error); + } + }; + + const interval = setInterval(fetchFiles, delay); + const timeout = setTimeout(() => { + stopProcessing(homeworkId); + setFileLoading(homeworkId, false); + }, 10000); + intervalsRef.current[homeworkId] = {interval, timeout}; + setFileLoading(homeworkId, true); + }, + [courseId, updateFiles, setFileLoading, stopProcessing] + ); + + useEffect(() => { + return () => { + Object.values(intervalsRef.current).forEach(({interval, timeout}) => { + clearInterval(interval); + clearTimeout(timeout); + }); + intervalsRef.current = {}; + }; + }, []); + + return {startProcessing}; +}; \ No newline at end of file diff --git a/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts b/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts new file mode 100644 index 000000000..4baa5938d --- /dev/null +++ b/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts @@ -0,0 +1,372 @@ +import {useCallback, useMemo} from 'react'; +import {useCourseDispatch, useCourseState} from '../hooks'; +import { + addDraftHomework, + updateDraftHomework as updateDraftHomeworkAction, + removeDraftHomework, + decrementDraftId, + setSelectedItem, +} from '../slices/courseEditingSlice'; +import {updateOrInsertHomework, deleteHomework} from '../slices/homeworkSlice'; +import {HomeworkViewModel, CreateHomeworkViewModel, ActionOptions, PostTaskViewModel} from '@/api'; +import ApiSingleton from '@/api/ApiSingleton'; +import {BonusTag, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; +import Lodash from "lodash"; +import {IFileInfo} from "@/components/Files/IFileInfo"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import ProcessFilesUtils from "@/components/Utils/ProcessFilesUtils"; + +type HomeworkFileOptions = { + initialFilesInfo: IFileInfo[]; + selectedFilesInfo: IFileInfo[]; + onStartProcessing: ( + homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[] + ) => void; + onDone?: () => void; +}; + +type HomeworkDeleteFileOptions = { + initialFilesInfo: IFileInfo[]; +}; + +export const useDraftHomework = (homeworkId: number) => + useCourseState(state => state.editing.draftHomeworks.find(dh => dh.id === homeworkId)); + +const normalizeDraftDate = (value: unknown): Date | undefined => { + if (value instanceof Date) return value.toISOString() as unknown as Date; + return value as Date | undefined; +}; + +const normalizeHomeworkDraft = (homework: HomeworkViewModel): HomeworkViewModel => ({ + ...homework, + publicationDate: normalizeDraftDate(homework.publicationDate), + deadlineDate: normalizeDraftDate(homework.deadlineDate), + tasks: homework.tasks?.map(task => ({ + ...task, + publicationDate: normalizeDraftDate(task.publicationDate), + deadlineDate: normalizeDraftDate(task.deadlineDate), + })) ?? [], +}); + +export const useHomeworkEditorState = (homework: HomeworkViewModel) => { + const committedHomeworks = useCourseState(state => state.homeworks.items); + type HomeworkTaskDraft = PostTaskViewModel & { id?: number; hasErrors?: boolean; publicationDate?: Date }; + const isNewHomework = homework.id! < 0; + const publicationDate = homework.publicationDateNotSet || !homework.publicationDate + ? undefined + : new Date(homework.publicationDate); + const deadlineDate = homework.deadlineDateNotSet || !homework.deadlineDate + ? undefined + : new Date(homework.deadlineDate); + const isPublished = !homework.isDeferred; + const changedTaskPublicationDates = (homework.tasks || []) + .filter(task => task.publicationDate != null) + .map(task => new Date(task.publicationDate!)); + const taskHasErrors = (homework.tasks || []).some(task => (task as HomeworkTaskDraft).hasErrors === true); + const hasErrors = !homework.title || !!(homework as HomeworkViewModel & { hasErrors?: boolean }).hasErrors; + + const deadlineSuggestion = useMemo(() => { + if (!isNewHomework || !publicationDate) return undefined; + + const isTest = (homework.tags || []).includes(TestTag); + const isBonus = (homework.tags || []).includes(BonusTag); + type DateCandidate = { deadlineDate: Date; daysDiff: number }; + + const mapped: DateCandidate[] = committedHomeworks + .filter(candidate => { + const candidateIsTest = isTestWork(candidate); + const candidateIsBonus = isBonusWork(candidate); + return candidate.id! > 0 + && candidate.hasDeadline + && ( + (isTest && candidateIsTest) + || (isBonus && candidateIsBonus) + || (!isTest && !isBonus && !candidateIsTest && !candidateIsBonus) + ); + }) + .map(candidate => ({ + deadlineDate: new Date(candidate.deadlineDate!), + daysDiff: Math.floor( + (new Date(candidate.deadlineDate!).getTime() - new Date(candidate.publicationDate!).getTime()) / (1000 * 3600 * 24) + ) + })); + + const dateCandidate = Lodash(mapped) + .groupBy(candidate => [candidate.daysDiff, candidate.deadlineDate.getHours(), candidate.deadlineDate.getMinutes()]) + .entries() + .sortBy((entry: [string, DateCandidate[]]) => entry[1].length) + .last()?.[1][0]; + + if (!dateCandidate) return undefined; + + const suggestedDeadline = new Date(publicationDate); + suggestedDeadline.setDate(suggestedDeadline.getDate() + dateCandidate.daysDiff); + suggestedDeadline.setHours(dateCandidate.deadlineDate.getHours(), dateCandidate.deadlineDate.getMinutes(), 0, 0); + return suggestedDeadline; + }, [committedHomeworks, homework.tags, isNewHomework, publicationDate]); + + const tagSuggestion = useMemo(() => { + const title = (homework.title || '').toLowerCase(); + const tags = homework.tags || []; + if (tags.includes(TestTag)) return undefined; + return (title.includes("контрольн") || title.includes("проверочн") || title.includes("переписывание") || title.includes("тест")) + ? TestTag + : undefined; + }, [homework.title, homework.tags]); + + return { + isNewHomework, + publicationDate, + deadlineDate, + isPublished, + changedTaskPublicationDates, + taskHasErrors, + hasErrors, + deadlineSuggestion, + tagSuggestion, + }; +}; + +export const useHomeworkEditing = () => { + const dispatch = useCourseDispatch(); + const draftIdCounter = useCourseState(state => state.editing.draftIdCounter); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + const committedHomeworks = useCourseState(state => state.homeworks.items); + + const createHomeworkDraft = useCallback((courseId: number) => { + const newId = draftIdCounter; + const newHomework: HomeworkViewModel = { + courseId, + id: newId, + title: 'Новое задание', + description: '', + publicationDate: undefined, + publicationDateNotSet: false, + hasDeadline: false, + deadlineDate: undefined, + deadlineDateNotSet: false, + isDeadlineStrict: false, + isGroupWork: false, + tasks: [], + tags: [], + }; + dispatch(addDraftHomework(newHomework)); + dispatch(decrementDraftId()); + dispatch(setSelectedItem({ isHomework: true, id: newId })); + return newId; + }, [dispatch, draftIdCounter]); + + const setHomeworkDraftFromLoaded = useCallback((hw: HomeworkViewModel) => { + const copy: HomeworkViewModel = normalizeHomeworkDraft({ + ...hw, + tasks: [], + }); + dispatch(addDraftHomework(copy)); + dispatch(setSelectedItem({ isHomework: true, id: hw.id })); + }, [dispatch]); + + const updateHomeworkDraftState = useCallback((hw: HomeworkViewModel) => { + dispatch(updateDraftHomeworkAction(normalizeHomeworkDraft(hw))); + }, [dispatch]); + + const commitHomeworkSave = useCallback((draftId: number, savedHomework: HomeworkViewModel) => { + dispatch(updateOrInsertHomework(savedHomework)); + dispatch(removeDraftHomework(draftId)); + }, [dispatch]); + + const commitHomeworkRemoval = useCallback((homeworkId: number) => { + dispatch(deleteHomework(homeworkId)); + dispatch(removeDraftHomework(homeworkId)); + }, [dispatch]); + + const discardHomeworkDraft = useCallback((homeworkId: number, isNewHomework: boolean) => { + if (isNewHomework) { + dispatch(removeDraftHomework(homeworkId)); + dispatch(setSelectedItem({ isHomework: true, id: undefined })); + } else { + dispatch(removeDraftHomework(homeworkId)); + dispatch(setSelectedItem({ isHomework: true, id: homeworkId })); + } + }, [dispatch]); + + const loadHomeworkForEditing = useCallback((homeworkId: number) => { + return ApiSingleton.homeworksApi.homeworksGetForEditingHomework(homeworkId); + }, []); + + const submitHomeworkApi = useCallback(( + courseId: number, + homeworkId: number, + isNewHomework: boolean, + body: CreateHomeworkViewModel + ) => { + return isNewHomework + ? ApiSingleton.homeworksApi.homeworksAddHomework(courseId, body) + : ApiSingleton.homeworksApi.homeworksUpdateHomework(homeworkId, body); + }, []); + + const deleteHomeworkApi = useCallback((homeworkId: number, isNewHomework: boolean) => { + if (isNewHomework) return Promise.resolve(); + return ApiSingleton.homeworksApi.homeworksDeleteHomework(homeworkId); + }, []); + + const getCurrentHomework = useCallback((homeworkId: number) => { + return draftHomeworks.find(dh => dh.id === homeworkId) + ?? committedHomeworks.find(hw => hw.id === homeworkId); + }, [draftHomeworks, committedHomeworks]); + + const buildHomeworkPayload = useCallback((homework: HomeworkViewModel, editOptions: ActionOptions): CreateHomeworkViewModel => ({ + title: homework.title!, + description: homework.description, + tags: homework.tags || [], + hasDeadline: homework.hasDeadline, + deadlineDate: homework.deadlineDateNotSet || !homework.deadlineDate ? undefined : new Date(homework.deadlineDate), + isDeadlineStrict: homework.isDeadlineStrict, + publicationDate: homework.publicationDateNotSet || !homework.publicationDate ? undefined : new Date(homework.publicationDate), + actionOptions: editOptions, + tasks: homework.id! < 0 ? (homework.tasks || []).map(t => ({ + title: t.title!, + description: t.description, + hasDeadline: t.hasDeadline, + deadlineDate: t.deadlineDate, + isDeadlineStrict: t.isDeadlineStrict, + publicationDate: t.publicationDate, + maxRating: t.maxRating!, + criteria: t.criteria || [], + } as PostTaskViewModel)) : [], + }), []); + + const processHomeworkFiles = useCallback(async ( + courseId: number, + courseUnitId: number, + initialFilesInfo: IFileInfo[], + selectedFilesInfo: IFileInfo[], + onStartProcessing: ( + courseUnitId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[] + ) => void, + onComplete?: () => void, + ) => { + const deletingFileIds = initialFilesInfo + .filter(initialFile => initialFile.id && !selectedFilesInfo.some(selectedFile => selectedFile.id === initialFile.id)) + .map(fileInfo => fileInfo.id!); + const newFiles = selectedFilesInfo + .filter(selectedFile => selectedFile.file && selectedFile.id == undefined) + .map(fileInfo => fileInfo.file!); + + if (deletingFileIds.length + newFiles.length > 0) { + await ProcessFilesUtils.processFilesWithErrorsHadling({ + courseId, + courseUnitType: CourseUnitType.Homework, + courseUnitId, + deletingFileIds, + newFiles, + }); + } + + onComplete?.(); + + if (deletingFileIds.length + newFiles.length > 0) { + onStartProcessing( + courseUnitId, + CourseUnitType.Homework, + initialFilesInfo.length, + newFiles.length, + deletingFileIds, + ); + } + }, []); + + const startHomeworkEdit = useCallback(async (homeworkId: number) => { + const loaded = await loadHomeworkForEditing(homeworkId); + setHomeworkDraftFromLoaded(loaded); + }, [loadHomeworkForEditing, setHomeworkDraftFromLoaded]); + + const saveHomework = useCallback(async ( + homeworkId: number, + editOptions: ActionOptions, + fileOptions?: HomeworkFileOptions, + ) => { + const homework = getCurrentHomework(homeworkId); + if (!homework) throw new Error(`Homework ${homeworkId} not found for saving`); + const courseId = homework.courseId!; + const isNewHomework = homework.id! < 0; + const body = buildHomeworkPayload(homework, editOptions); + const updatedHomework = await submitHomeworkApi(courseId, homeworkId, isNewHomework, body); + const savedHomework = updatedHomework.value!; + const finalize = () => { + commitHomeworkSave(homeworkId, savedHomework); + dispatch(setSelectedItem({isHomework: true, id: savedHomework.id})); + }; + + if (fileOptions) { + await processHomeworkFiles( + savedHomework.courseId!, + savedHomework.id!, + fileOptions.initialFilesInfo, + fileOptions.selectedFilesInfo, + fileOptions.onStartProcessing, + () => { + finalize(); + fileOptions.onDone?.(); + }, + ); + } else { + finalize(); + } + return savedHomework; + }, [getCurrentHomework, buildHomeworkPayload, submitHomeworkApi, commitHomeworkSave, dispatch, processHomeworkFiles]); + + const deleteHomeworkWithFiles = useCallback(async ( + homeworkId: number, + fileOptions?: HomeworkDeleteFileOptions, + ) => { + const homework = getCurrentHomework(homeworkId); + if (!homework) throw new Error(`Homework ${homeworkId} not found for deletion`); + const isNewHomework = homework.id! < 0; + await deleteHomeworkApi(homeworkId, isNewHomework); + const finalize = () => { + commitHomeworkRemoval(homeworkId); + dispatch(setSelectedItem({isHomework: true, id: undefined})); + }; + + if (fileOptions && fileOptions.initialFilesInfo.length > 0) { + await ProcessFilesUtils.processFilesWithErrorsHadling({ + courseId: homework.courseId!, + courseUnitType: CourseUnitType.Homework, + courseUnitId: homeworkId, + deletingFileIds: fileOptions.initialFilesInfo.filter(fileInfo => fileInfo.id).map(fileInfo => fileInfo.id!), + newFiles: [] + }); + finalize(); + } else { + finalize(); + } + }, [getCurrentHomework, deleteHomeworkApi, commitHomeworkRemoval, dispatch]); + + return { + createHomework: createHomeworkDraft, + startHomeworkEdit, + patchHomeworkDraft: updateHomeworkDraftState, + saveHomework, + deleteHomework: deleteHomeworkWithFiles, + cancelHomeworkEdit: (homeworkId: number) => discardHomeworkDraft(homeworkId, homeworkId < 0), + }; +}; + +export const getHomeworkDeleteMessage = (homeworkName: string, filesInfo: { name?: string }[]): string => { + let message = `Вы точно хотите удалить задание "${homeworkName}"?`; + if (filesInfo.length > 0) { + message += ` Будет также удален файл ${filesInfo[0].name ?? ''}`; + if (filesInfo.length > 1) { + message += ` и другие прикрепленные файлы`; + } + } + return message; +}; \ No newline at end of file diff --git a/hwproj.front/src/store/storeHooks/taskEditorHooks.ts b/hwproj.front/src/store/storeHooks/taskEditorHooks.ts new file mode 100644 index 000000000..14c13e31c --- /dev/null +++ b/hwproj.front/src/store/storeHooks/taskEditorHooks.ts @@ -0,0 +1,337 @@ +import {useCallback, useMemo} from 'react'; +import {useCourseDispatch, useCourseState } from '../hooks'; +import { + addDraftHomework, + addDraftTask, + updateDraftTask as updateDraftTaskAction, + removeDraftTask, + removeDraftHomework, + decrementDraftId, + setSelectedItem, +} from '../slices/courseEditingSlice'; +import {updateTask, deleteTask as deleteTaskAction } from '../slices/homeworkSlice'; +import {HomeworkViewModel, HomeworkTaskViewModel, CriterionViewModel, PostTaskViewModel, ActionOptions } from '@/api'; +import ApiSingleton from '@/api/ApiSingleton'; +import {BonusTag, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; +import {useMergedHomeworks} from "@/store/storeHooks/courseEditingHooks"; + +export const useDraftTask = (taskId: number, homeworkId: number) => { + const draftHw = useCourseState(state => + state.editing.draftHomeworks.find(dh => dh.id === homeworkId)); + return draftHw?.tasks?.find(t => t.id === taskId); +}; + +const normalizeDraftDate = (value: unknown): Date | undefined => { + if (value instanceof Date) return value.toISOString() as unknown as Date; + return value as Date | undefined; +}; + +const normalizeTaskDraft = (task: HomeworkTaskViewModel): HomeworkTaskViewModel => ({ + ...task, + publicationDate: normalizeDraftDate(task.publicationDate), + deadlineDate: normalizeDraftDate(task.deadlineDate), +}); + +const getTaskEditorState = ( + task: HomeworkTaskViewModel & { + hasErrors?: boolean; + suggestedMaxRating?: number; + }, + homework: HomeworkViewModel, +) => { + const criteria = task.criteria || []; + const autoMaxFromCriteria = criteria.length > 0; + const maxRating = autoMaxFromCriteria + ? criteria.reduce((sum, c) => sum + (c.maxPoints || 0), 0) + : (task.maxRating ?? 0); + const isBonusExplicit = (task.tags || []).includes(BonusTag) && !(homework.tags || []).includes(BonusTag); + const publicationDate = task.publicationDate ?? homework.publicationDate; + const taskHasErrors = task.hasErrors === true; + const hasErrors = !task.title || maxRating <= 0 || maxRating > 100 || taskHasErrors; + const isNewTask = task.id! < 0; + const isNewHomework = homework.id! < 0; + const homeworkPublicationDateIsSet = !homework.publicationDateNotSet; + const taskPublicationDate = task.publicationDateNotSet + ? undefined + : (task.publicationDate ? new Date(task.publicationDate) : undefined); + const taskDeadlineDate = task.deadlineDateNotSet + ? undefined + : (task.deadlineDate ? new Date(task.deadlineDate) : undefined); + const isPublicationDateDisabled = task.isDeferred || !homework.isDeferred; + const maxRatingLabel = criteria.length > 0 + ? "Критерии" + : task.suggestedMaxRating === maxRating + ? "Вычислено" + : undefined; + + return { + criteria, + autoMaxFromCriteria, + maxRating, + isBonusExplicit, + publicationDate, + taskHasErrors, + hasErrors, + isNewTask, + isNewHomework, + homeworkPublicationDateIsSet, + taskPublicationDate, + taskDeadlineDate, + isPublicationDateDisabled, + maxRatingLabel, + }; +}; + +export const useTaskEditorState = ( + task: HomeworkTaskViewModel & {hasErrors?: boolean;suggestedMaxRating?: number;}, + homework: HomeworkViewModel, +) => useMemo( + () => getTaskEditorState(task, homework), + [task, homework], +); + +export const useTaskEditing = () => { + const dispatch = useCourseDispatch(); + const draftIdCounter = useCourseState(state => state.editing.draftIdCounter); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + const committedHomeworks = useCourseState(state => state.homeworks.items); + const mergedHomeworks = useMergedHomeworks(); + + const ensureHomeworkInDrafts = useCallback((homework: HomeworkViewModel) => { + const existingDraft = draftHomeworks.find(dh => dh.id === homework.id); + if (!existingDraft) { + const copy: HomeworkViewModel = { + ...homework, + tasks: [], + }; + dispatch(addDraftHomework(copy)); + } + }, [draftHomeworks, dispatch]); + + const getSuggestedTaskRating = useCallback((homework: HomeworkViewModel) => { + const tags = homework.tags || []; + const isTest = tags.includes(TestTag); + const isBonus = tags.includes(BonusTag); + const counts = new Map(); + + for (const candidateHomework of mergedHomeworks) { + const firstTask = candidateHomework.tasks?.[0]; + if (!firstTask || firstTask.id! <= 0) continue; + + const candidateIsTest = isTestWork(firstTask); + const candidateIsBonus = isBonusWork(firstTask); + const matchesKind = + (isTest && candidateIsTest) + || (isBonus && candidateIsBonus) + || (!isTest && !isBonus && !candidateIsTest && !candidateIsBonus); + + if (!matchesKind) continue; + + const rating = firstTask.maxRating!; + counts.set(rating, (counts.get(rating) ?? 0) + 1); + } + + let suggestedRating: number | undefined = undefined; + let bestCount = -1; + for (const [rating, count] of counts.entries()) { + if (count >= bestCount) { + suggestedRating = rating; + bestCount = count; + } + } + + return suggestedRating; + }, [mergedHomeworks]); + + const createTaskDraft = useCallback((homework: HomeworkViewModel) => { + ensureHomeworkInDrafts(homework); + const newId = draftIdCounter; + const suggestedMaxRating = getSuggestedTaskRating(homework); + const newTask = { + id: newId, + homeworkId: homework.id, + title: 'Новая задача', + description: '', + maxRating: suggestedMaxRating ?? 10, + suggestedMaxRating, + isDeferred: homework.isDeferred || false, + deadlineDateNotSet: true, + deadlineDate: undefined, + tags: homework.tags || [], + } as HomeworkTaskViewModel; + dispatch(addDraftTask(newTask)); + dispatch(decrementDraftId()); + dispatch(setSelectedItem({ isHomework: false, id: newId })); + return newId; + }, [dispatch, draftIdCounter, ensureHomeworkInDrafts, getSuggestedTaskRating]); + + const setTaskDraftFromLoaded = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { + ensureHomeworkInDrafts(homework); + const copy: HomeworkTaskViewModel = normalizeTaskDraft({ ...task }); + dispatch(addDraftTask(copy)); + dispatch(setSelectedItem({ isHomework: false, id: task.id })); + }, [dispatch, ensureHomeworkInDrafts]); + + const updateTaskDraftState = useCallback((task: HomeworkTaskViewModel) => { + dispatch(updateDraftTaskAction(normalizeTaskDraft(task))); + }, [dispatch]); + + const discardTaskDraftByIds = useCallback((taskId: number, homeworkId: number) => { + dispatch(removeDraftTask({ homeworkId, taskId })); + const draftHw = draftHomeworks.find(dh => dh.id === homeworkId); + if (draftHw && (draftHw.tasks || []).filter(t => t.id !== taskId).length === 0) { + dispatch(removeDraftHomework(homeworkId)); + } + }, [dispatch, draftHomeworks]); + + const commitTaskSave = useCallback((draftId: number, homeworkId: number, savedTask: HomeworkTaskViewModel) => { + dispatch(updateTask(savedTask)); + dispatch(removeDraftTask({ homeworkId, taskId: draftId })); + const draftHw = draftHomeworks.find(dh => dh.id === homeworkId); + if (draftHw && (draftHw.tasks || []).filter(t => t.id !== draftId).length === 0) { + dispatch(removeDraftHomework(homeworkId)); + } + }, [dispatch, draftHomeworks]); + + const commitTaskRemoval = useCallback((taskId: number, homeworkId: number) => { + dispatch(deleteTaskAction({ homeworkId, taskId })); + dispatch(removeDraftTask({ homeworkId, taskId })); + }, [dispatch]); + + const addCriterion = useCallback((task: HomeworkTaskViewModel, criterion: CriterionViewModel) => { + const next = [...(task.criteria || []), criterion]; + dispatch(updateDraftTaskAction({ ...task, criteria: next })); + }, [dispatch]); + + const updateCriterion = useCallback((task: HomeworkTaskViewModel, index: number, patch: Partial) => { + const criteria = task.criteria || []; + const next = criteria.map((c, i) => (i === index ? { ...c, ...patch } : c)); + dispatch(updateDraftTaskAction({ ...task, criteria: next })); + }, [dispatch]); + + const removeCriterion = useCallback((task: HomeworkTaskViewModel, index: number) => { + const criteria = task.criteria || []; + const next = criteria.filter((_, i) => i !== index); + dispatch(updateDraftTaskAction({ ...task, criteria: next })); + }, [dispatch]); + + const persistTask = useCallback(async ( + task: HomeworkTaskViewModel, + homework: HomeworkViewModel, + isNewTask: boolean, + updatePayload: PostTaskViewModel + ) => { + const updatedTask = isNewTask + ? await ApiSingleton.tasksApi.tasksAddTask(homework.id!, updatePayload) + : await ApiSingleton.tasksApi.tasksUpdateTask(+task.id!, updatePayload); + commitTaskSave(task.id!, task.homeworkId!, updatedTask.value!); + dispatch(setSelectedItem({ isHomework: false, id: updatedTask.value!.id })); + return updatedTask; + }, [commitTaskSave, dispatch]); + + const removeTask = useCallback(async ( + task: HomeworkTaskViewModel, + homework: HomeworkViewModel, + isNewTask: boolean + ) => { + if (!isNewTask) await ApiSingleton.tasksApi.tasksDeleteTask(task.id!); + commitTaskRemoval(task.id!, task.homeworkId!); + dispatch(setSelectedItem({ isHomework: true, id: homework.id })); + }, [commitTaskRemoval, dispatch]); + + const discardTaskDraft = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel, isNewTask: boolean) => { + const draftHomework = draftHomeworks.find(dh => dh.id === task.homeworkId) + const remainingTasks = (draftHomework?.tasks || []).filter(t => t.id !== task.id); + discardTaskDraftByIds(task.id!, task.homeworkId!); + if (isNewTask) { + if (homework.id! < 0 && remainingTasks.length > 0) { + dispatch(setSelectedItem({ isHomework: false, id: remainingTasks[0].id })); + } else { + dispatch(setSelectedItem({ isHomework: true, id: homework.id })); + } + } else { + dispatch(setSelectedItem({ isHomework: false, id: task.id })); + } + }, [discardTaskDraftByIds, dispatch]); + + const loadTaskForEditing = useCallback((taskId: number) => { + return ApiSingleton.tasksApi.tasksGetForEditingTask(taskId); + }, []); + + const getCurrentHomework = useCallback((homeworkId: number) => { + return draftHomeworks.find(dh => dh.id === homeworkId) + ?? committedHomeworks.find(hw => hw.id === homeworkId); + }, [draftHomeworks, committedHomeworks]); + + const getCurrentTask = useCallback((taskId: number, homeworkId: number) => { + return draftHomeworks + .find(dh => dh.id === homeworkId) + ?.tasks?.find(t => t.id === taskId) + ?? committedHomeworks + .find(hw => hw.id === homeworkId) + ?.tasks?.find(t => t.id === taskId); + }, [draftHomeworks, committedHomeworks]); + + const buildTaskPayload = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel, editOptions: ActionOptions): PostTaskViewModel => { + const {criteria, maxRating, isBonusExplicit} = getTaskEditorState(task, homework); + + return { + title: task.title!, + description: task.description || '', + isBonusExplicit, + maxRating, + actionOptions: editOptions, + criteria, + hasDeadline: task.hasDeadline, + isDeadlineStrict: task.isDeadlineStrict, + publicationDate: task.publicationDateNotSet ? undefined : task.publicationDate, + deadlineDate: task.deadlineDateNotSet ? undefined : task.deadlineDate, + }; + }, []); + + const startTaskEdit = useCallback(async (taskId: number) => { + const loaded = await loadTaskForEditing(taskId); + setTaskDraftFromLoaded(loaded.task!, loaded.homework!); + }, [loadTaskForEditing, setTaskDraftFromLoaded]); + + const saveTask = useCallback(async ( + taskId: number, + homeworkId: number, + editOptions: ActionOptions, + ) => { + const task = getCurrentTask(taskId, homeworkId); + const homework = getCurrentHomework(homeworkId); + if (!task || !homework) throw new Error(`Task ${taskId} or homework ${homeworkId} not found for saving`); + const isNewTask = task.id! < 0; + const body = buildTaskPayload(task, homework, editOptions); + return persistTask(task, homework, isNewTask, body); + }, [getCurrentTask, getCurrentHomework, buildTaskPayload, persistTask]); + + const deleteTask = useCallback(async ( + taskId: number, + homeworkId: number, + ) => { + const task = getCurrentTask(taskId, homeworkId); + const homework = getCurrentHomework(homeworkId); + if (!task || !homework) throw new Error(`Task ${taskId} or homework ${homeworkId} not found for deletion`); + const isNewTask = task.id! < 0; + return removeTask(task, homework, isNewTask); + }, [getCurrentTask, getCurrentHomework, removeTask]); + + return { + createTask: createTaskDraft, + startTaskEdit, + patchTaskDraft: updateTaskDraftState, + saveTask, + deleteTask, + cancelTaskEdit: (taskId: number, homeworkId: number) => { + const task = getCurrentTask(taskId, homeworkId); + const homework = getCurrentHomework(homeworkId); + if (!task || !homework) throw new Error(`Task ${taskId} or homework ${homeworkId} not found for cancel`); + discardTaskDraft(task, homework, task.id! < 0); + }, + addCriterion, + updateCriterion, + removeCriterion, + }; +}; \ No newline at end of file