Merge pull request #227 from Worklenz/fix/task-drag-and-drop-improvement
Fix/task drag and drop improvement
This commit is contained in:
139
worklenz-frontend/package-lock.json
generated
139
worklenz-frontend/package-lock.json
generated
@@ -17,8 +17,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@paddle/paddle-js": "^1.3.3",
|
||||
"@reduxjs/toolkit": "^2.2.7",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tinymce/tinymce-react": "^5.1.1",
|
||||
@@ -48,6 +50,7 @@
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"react-timer-hook": "^3.0.8",
|
||||
"react-virtuoso": "^4.13.0",
|
||||
"react-window": "^1.8.11",
|
||||
"react-window-infinite-loader": "^1.0.10",
|
||||
"socket.io-client": "^4.8.1",
|
||||
@@ -73,7 +76,7 @@
|
||||
"postcss": "^8.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||
"rollup": "^4.40.2",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.3.5",
|
||||
@@ -92,7 +95,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -1657,11 +1659,19 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/react": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
|
||||
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">= 16 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
@@ -1744,7 +1754,6 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
@@ -1758,7 +1767,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -1768,7 +1776,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
@@ -1788,7 +1795,6 @@
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -2296,6 +2302,18 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/forms": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
|
||||
"integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mini-svg-data-uri": "^1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
@@ -2841,7 +2859,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2851,7 +2868,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -2952,14 +2968,12 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
@@ -2973,7 +2987,6 @@
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
@@ -3111,7 +3124,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
@@ -3127,7 +3139,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3140,7 +3151,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -3150,7 +3160,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -3247,7 +3256,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3363,7 +3371,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
@@ -3388,7 +3395,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -3407,7 +3413,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3420,7 +3425,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@@ -3439,7 +3443,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3539,7 +3542,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -3576,7 +3578,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
@@ -3668,7 +3669,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/diff-sequences": {
|
||||
@@ -3685,7 +3685,6 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
@@ -3739,7 +3738,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
@@ -3753,7 +3751,6 @@
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
@@ -3943,7 +3940,6 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
@@ -3960,7 +3956,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -3973,7 +3968,6 @@
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@@ -3989,7 +3983,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -4028,7 +4021,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -4074,7 +4066,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -4157,7 +4148,6 @@
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
@@ -4178,7 +4168,6 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
@@ -4388,7 +4377,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
@@ -4416,7 +4404,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4426,7 +4413,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4436,7 +4422,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -4449,7 +4434,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -4459,14 +4443,12 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
@@ -4851,7 +4833,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -4968,7 +4949,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -4978,7 +4958,6 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -5019,11 +4998,19 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-svg-data-uri": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mini-svg-data-uri": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
@@ -5039,7 +5026,6 @@
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -5080,7 +5066,6 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
@@ -5137,7 +5122,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5166,7 +5150,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5176,7 +5159,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
@@ -5213,7 +5195,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5229,7 +5210,6 @@
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
@@ -5246,7 +5226,6 @@
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
@@ -5306,7 +5285,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -5319,7 +5297,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5329,7 +5306,6 @@
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5367,7 +5343,6 @@
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
@@ -5385,7 +5360,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
|
||||
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelcase-css": "^2.0.1"
|
||||
@@ -5405,7 +5379,6 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
|
||||
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -5441,7 +5414,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -5467,7 +5439,6 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@@ -5481,7 +5452,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
@@ -5677,7 +5647,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6578,6 +6547,16 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtuoso": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.13.0.tgz",
|
||||
"integrity": "sha512-XHv2Fglpx80yFPdjZkV9d1baACKghg/ucpDFEXwaix7z0AfVQj+mF6lM+YQR6UC/TwzXG2rJKydRMb3+7iV3PA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16 || >=17 || >= 18 || >= 19",
|
||||
"react-dom": ">=16 || >=17 || >= 18 || >=19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "1.8.11",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
|
||||
@@ -6621,7 +6600,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
@@ -6631,7 +6609,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
@@ -6721,7 +6698,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@@ -6816,7 +6792,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6889,7 +6864,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -6902,7 +6876,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6919,7 +6892,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -7063,7 +7035,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
@@ -7082,7 +7053,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -7097,14 +7067,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7117,7 +7085,6 @@
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
@@ -7134,7 +7101,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7147,7 +7113,6 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -7179,7 +7144,6 @@
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
@@ -7250,7 +7214,6 @@
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -7288,7 +7251,6 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -7333,7 +7295,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
@@ -7343,7 +7304,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
@@ -7466,7 +7426,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -7491,7 +7450,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tsconfck": {
|
||||
@@ -7586,7 +7544,6 @@
|
||||
"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/utrie": {
|
||||
@@ -7868,7 +7825,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@@ -7905,7 +7861,6 @@
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
@@ -7924,7 +7879,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -7942,14 +7896,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -7964,7 +7916,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7977,7 +7928,6 @@
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -8026,7 +7976,6 @@
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@paddle/paddle-js": "^1.3.3",
|
||||
"@reduxjs/toolkit": "^2.2.7",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tinymce/tinymce-react": "^5.1.1",
|
||||
@@ -52,6 +54,7 @@
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"react-timer-hook": "^3.0.8",
|
||||
"react-virtuoso": "^4.13.0",
|
||||
"react-window": "^1.8.11",
|
||||
"react-window-infinite-loader": "^1.0.10",
|
||||
"socket.io-client": "^4.8.1",
|
||||
@@ -77,7 +80,7 @@
|
||||
"postcss": "^8.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||
"rollup": "^4.40.2",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.3.5",
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { RouteObject } from 'react-router-dom';
|
||||
import AccountSetup from '@/pages/account-setup/account-setup';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
|
||||
const AccountSetup = lazy(() => import('@/pages/account-setup/account-setup'));
|
||||
|
||||
const accountSetupRoute: RouteObject = {
|
||||
path: '/worklenz/setup',
|
||||
element: <AccountSetup />,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<AccountSetup />
|
||||
</Suspense>
|
||||
),
|
||||
};
|
||||
|
||||
export default accountSetupRoute;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { RouteObject } from 'react-router-dom';
|
||||
import { Suspense } from 'react';
|
||||
import AdminCenterLayout from '@/layouts/admin-center-layout';
|
||||
import { adminCenterItems } from '@/pages/admin-center/admin-center-constants';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
|
||||
|
||||
const AdminCenterGuard = ({ children }: { children: React.ReactNode }) => {
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
@@ -24,7 +26,11 @@ const adminCenterRoutes: RouteObject[] = [
|
||||
),
|
||||
children: adminCenterItems.map(item => ({
|
||||
path: item.endpoint,
|
||||
element: item.element,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
{item.element}
|
||||
</Suspense>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { RouteObject } from 'react-router-dom';
|
||||
import { Suspense } from 'react';
|
||||
import ReportingLayout from '@/layouts/ReportingLayout';
|
||||
import { ReportingMenuItems, reportingsItems } from '@/lib/reporting/reporting-constants';
|
||||
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
|
||||
|
||||
// function to flatten nested menu items
|
||||
const flattenItems = (items: ReportingMenuItems[]): ReportingMenuItems[] => {
|
||||
@@ -20,7 +22,11 @@ const reportingRoutes: RouteObject[] = [
|
||||
element: <ReportingLayout />,
|
||||
children: flattenedItems.map(item => ({
|
||||
path: item.endpoint,
|
||||
element: item.element,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
{item.element}
|
||||
</Suspense>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { RouteObject } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Suspense } from 'react';
|
||||
import SettingsLayout from '@/layouts/SettingsLayout';
|
||||
import { settingsItems } from '@/lib/settings/settings-constants';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
|
||||
|
||||
const SettingsGuard = ({
|
||||
children,
|
||||
@@ -26,7 +28,11 @@ const settingsRoutes: RouteObject[] = [
|
||||
element: <SettingsLayout />,
|
||||
children: settingsItems.map(item => ({
|
||||
path: item.endpoint,
|
||||
element: <SettingsGuard adminRequired={!!item.adminOnly}>{item.element}</SettingsGuard>,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<SettingsGuard adminRequired={!!item.adminOnly}>{item.element}</SettingsGuard>
|
||||
</Suspense>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Auth & User
|
||||
import authReducer from '@features/auth/authSlice';
|
||||
@@ -76,14 +77,14 @@ import teamMembersReducer from '@features/team-members/team-members.slice';
|
||||
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
||||
|
||||
// Task Management System
|
||||
import taskManagementReducer from '@features/task-management/task-management.slice';
|
||||
import groupingReducer from '@features/task-management/grouping.slice';
|
||||
import selectionReducer from '@features/task-management/selection.slice';
|
||||
import taskManagementReducer from '@/features/task-management/task-management.slice';
|
||||
import groupingReducer from '@/features/task-management/grouping.slice';
|
||||
import selectionReducer from '@/features/task-management/selection.slice';
|
||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||
|
||||
import projectViewReducer from '@features/project/project-view-slice';
|
||||
import taskManagementFields from '@features/task-management/taskListFields.slice';
|
||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||
|
||||
export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
@@ -172,9 +173,13 @@ export const store = configureStore({
|
||||
taskManagement: taskManagementReducer,
|
||||
grouping: groupingReducer,
|
||||
taskManagementSelection: selectionReducer,
|
||||
taskManagementFields,
|
||||
taskManagementFields: taskManagementFieldsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { getContrastColor } from '@/utils/colorUtils';
|
||||
|
||||
interface TaskGroupHeaderProps {
|
||||
group: {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
color?: string; // Color for the group indicator
|
||||
};
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
|
||||
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
|
||||
const headerTextColor = getContrastColor(headerBackgroundColor);
|
||||
|
||||
// Make the group header droppable
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: group.id,
|
||||
data: {
|
||||
type: 'group',
|
||||
group,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex items-center px-4 py-2 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700 ${
|
||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
||||
color: headerTextColor,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 20 // Higher than sticky columns (zIndex: 1) and column headers (zIndex: 2)
|
||||
}}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Chevron button */}
|
||||
<button
|
||||
className="p-1 rounded-md hover:bg-opacity-20 transition-colors"
|
||||
style={{ backgroundColor: headerBackgroundColor, color: headerTextColor, borderColor: headerTextColor, border: '1px solid' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Group indicator and name */}
|
||||
<div className="ml-2 flex items-center gap-3 flex-1">
|
||||
{/* Color indicator (removed as full header is colored) */}
|
||||
|
||||
{/* Group name and count */}
|
||||
<div className="flex items-center justify-between flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{group.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: getContrastColor(headerTextColor) === '#000000' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.2)', color: headerTextColor }}
|
||||
>
|
||||
{group.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroupHeader;
|
||||
517
worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx
Normal file
517
worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { GroupedVirtuoso } from 'react-virtuoso';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
KeyboardSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
selectAllTasksArray,
|
||||
selectGroups,
|
||||
selectGrouping,
|
||||
selectLoading,
|
||||
selectError,
|
||||
selectSelectedPriorities,
|
||||
selectSearch,
|
||||
fetchTasksV3,
|
||||
reorderTasksInGroup,
|
||||
moveTaskBetweenGroups,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
selectCurrentGrouping,
|
||||
selectCollapsedGroups,
|
||||
toggleGroupCollapsed,
|
||||
} from '@/features/task-management/grouping.slice';
|
||||
import {
|
||||
selectSelectedTaskIds,
|
||||
selectLastSelectedTaskId,
|
||||
selectIsTaskSelected,
|
||||
selectTask,
|
||||
deselectTask,
|
||||
toggleTaskSelection,
|
||||
selectRange,
|
||||
clearSelection,
|
||||
} from '@/features/task-management/selection.slice';
|
||||
import TaskRow from './TaskRow';
|
||||
import TaskGroupHeader from './TaskGroupHeader';
|
||||
import { Task, TaskGroup } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
import { TaskListField } from '@/types/task-list-field.types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||
|
||||
// Base column configuration
|
||||
const BASE_COLUMNS = [
|
||||
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
|
||||
{ id: 'taskKey', label: 'Key', width: '100px', key: COLUMN_KEYS.KEY },
|
||||
{ id: 'title', label: 'Title', width: '300px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
|
||||
{ id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
||||
{ id: 'priority', label: 'Priority', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||
{ id: 'dueDate', label: 'Due Date', width: '120px', key: COLUMN_KEYS.DUE_DATE },
|
||||
{ id: 'progress', label: 'Progress', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
||||
{ id: 'labels', label: 'Labels', width: '150px', key: COLUMN_KEYS.LABELS },
|
||||
{ id: 'phase', label: 'Phase', width: '120px', key: COLUMN_KEYS.PHASE },
|
||||
{ id: 'timeTracking', label: 'Time Tracking', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
||||
{ id: 'estimation', label: 'Estimation', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
||||
{ id: 'startDate', label: 'Start Date', width: '120px', key: COLUMN_KEYS.START_DATE },
|
||||
{ id: 'dueTime', label: 'Due Time', width: '120px', key: COLUMN_KEYS.DUE_TIME },
|
||||
{ id: 'completedDate', label: 'Completed Date', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE },
|
||||
{ id: 'createdDate', label: 'Created Date', width: '120px', key: COLUMN_KEYS.CREATED_DATE },
|
||||
{ id: 'lastUpdated', label: 'Last Updated', width: '120px', key: COLUMN_KEYS.LAST_UPDATED },
|
||||
{ id: 'reporter', label: 'Reporter', width: '120px', key: COLUMN_KEYS.REPORTER },
|
||||
];
|
||||
|
||||
type ColumnStyle = {
|
||||
width: string;
|
||||
position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed';
|
||||
left?: number;
|
||||
backgroundColor?: string;
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
interface TaskListV2Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectId: urlProjectId } = useParams();
|
||||
|
||||
// Drag and drop state
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// Configure sensors for drag and drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Using Redux state for collapsedGroups instead of local state
|
||||
const collapsedGroups = useAppSelector(selectCollapsedGroups);
|
||||
|
||||
// Selectors
|
||||
const allTasks = useAppSelector(selectAllTasksArray); // Renamed to allTasks for clarity
|
||||
const groups = useAppSelector(selectGroups);
|
||||
const grouping = useAppSelector(selectGrouping);
|
||||
const loading = useAppSelector(selectLoading);
|
||||
const error = useAppSelector(selectError);
|
||||
const selectedPriorities = useAppSelector(selectSelectedPriorities);
|
||||
const searchQuery = useAppSelector(selectSearch);
|
||||
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
||||
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
||||
const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId);
|
||||
|
||||
const fields = useAppSelector(state => state.taskManagementFields) || [];
|
||||
|
||||
// Filter visible columns based on fields
|
||||
const visibleColumns = useMemo(() => {
|
||||
return BASE_COLUMNS.filter(column => {
|
||||
// Always show drag handle and title (sticky columns)
|
||||
if (column.isSticky) return true;
|
||||
// Check if field is visible for all other columns (including task key)
|
||||
const field = fields.find(f => f.key === column.key);
|
||||
return field?.visible ?? false;
|
||||
});
|
||||
}, [fields]);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
if (urlProjectId) {
|
||||
dispatch(fetchTasksV3(urlProjectId));
|
||||
}
|
||||
}, [dispatch, urlProjectId]);
|
||||
|
||||
// Handlers
|
||||
const handleTaskSelect = useCallback((taskId: string, event: React.MouseEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
dispatch(toggleTaskSelection(taskId));
|
||||
} else if (event.shiftKey && lastSelectedTaskId) {
|
||||
const taskIds = allTasks.map(t => t.id); // Use allTasks here
|
||||
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
||||
const endIdx = taskIds.indexOf(taskId);
|
||||
const rangeIds = taskIds.slice(
|
||||
Math.min(startIdx, endIdx),
|
||||
Math.max(startIdx, endIdx) + 1
|
||||
);
|
||||
dispatch(selectRange(rangeIds));
|
||||
} else {
|
||||
dispatch(clearSelection());
|
||||
dispatch(selectTask(taskId));
|
||||
}
|
||||
}, [dispatch, lastSelectedTaskId, allTasks]);
|
||||
|
||||
const handleGroupCollapse = useCallback((groupId: string) => {
|
||||
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
|
||||
}, [dispatch]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
// Find the active task and the item being dragged over
|
||||
const activeTask = allTasks.find(task => task.id === activeId);
|
||||
if (!activeTask) return;
|
||||
|
||||
// Check if we're dragging over a task or a group
|
||||
const overTask = allTasks.find(task => task.id === overId);
|
||||
const overGroup = groups.find(group => group.id === overId);
|
||||
|
||||
// Find the groups
|
||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||
let targetGroup = overGroup;
|
||||
|
||||
if (overTask) {
|
||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||
}
|
||||
|
||||
if (!activeGroup || !targetGroup) return;
|
||||
|
||||
// If dragging to a different group, we need to handle cross-group movement
|
||||
if (activeGroup.id !== targetGroup.id) {
|
||||
console.log('Cross-group drag detected:', {
|
||||
activeTask: activeTask.id,
|
||||
fromGroup: activeGroup.id,
|
||||
toGroup: targetGroup.id,
|
||||
});
|
||||
}
|
||||
}, [allTasks, groups]);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
// Find the active task
|
||||
const activeTask = allTasks.find(task => task.id === activeId);
|
||||
if (!activeTask) {
|
||||
console.error('Active task not found:', activeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the groups
|
||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||
if (!activeGroup) {
|
||||
console.error('Could not find active group for task:', activeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're dropping on a task or a group
|
||||
const overTask = allTasks.find(task => task.id === overId);
|
||||
const overGroup = groups.find(group => group.id === overId);
|
||||
|
||||
let targetGroup = overGroup;
|
||||
let insertIndex = 0;
|
||||
|
||||
if (overTask) {
|
||||
// Dropping on a task
|
||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||
if (targetGroup) {
|
||||
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
||||
}
|
||||
} else if (overGroup) {
|
||||
// Dropping on a group (at the end)
|
||||
targetGroup = overGroup;
|
||||
insertIndex = targetGroup.taskIds.length;
|
||||
}
|
||||
|
||||
if (!targetGroup) {
|
||||
console.error('Could not find target group');
|
||||
return;
|
||||
}
|
||||
|
||||
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
||||
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||
|
||||
console.log('Drag operation:', {
|
||||
activeId,
|
||||
overId,
|
||||
activeTask: activeTask.name || activeTask.title,
|
||||
activeGroup: activeGroup.id,
|
||||
targetGroup: targetGroup.id,
|
||||
activeIndex,
|
||||
insertIndex,
|
||||
isCrossGroup,
|
||||
});
|
||||
|
||||
if (isCrossGroup) {
|
||||
// Moving task between groups
|
||||
console.log('Moving task between groups:', {
|
||||
task: activeTask.name || activeTask.title,
|
||||
from: activeGroup.title,
|
||||
to: targetGroup.title,
|
||||
newPosition: insertIndex,
|
||||
});
|
||||
|
||||
// Move task to the target group
|
||||
dispatch(moveTaskBetweenGroups({
|
||||
taskId: activeId as string,
|
||||
sourceGroupId: activeGroup.id,
|
||||
targetGroupId: targetGroup.id,
|
||||
}));
|
||||
|
||||
// If we need to insert at a specific position (not at the end)
|
||||
if (insertIndex < targetGroup.taskIds.length) {
|
||||
const newTaskIds = [...targetGroup.taskIds];
|
||||
// Remove the task if it was already added at the end
|
||||
const taskIndex = newTaskIds.indexOf(activeId as string);
|
||||
if (taskIndex > -1) {
|
||||
newTaskIds.splice(taskIndex, 1);
|
||||
}
|
||||
// Insert at the correct position
|
||||
newTaskIds.splice(insertIndex, 0, activeId as string);
|
||||
|
||||
dispatch(reorderTasksInGroup({
|
||||
taskIds: newTaskIds,
|
||||
groupId: targetGroup.id,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Reordering within the same group
|
||||
console.log('Reordering task within same group:', {
|
||||
task: activeTask.name || activeTask.title,
|
||||
group: activeGroup.title,
|
||||
from: activeIndex,
|
||||
to: insertIndex,
|
||||
});
|
||||
|
||||
if (activeIndex !== insertIndex) {
|
||||
const newTaskIds = [...activeGroup.taskIds];
|
||||
// Remove task from old position
|
||||
newTaskIds.splice(activeIndex, 1);
|
||||
// Insert at new position
|
||||
newTaskIds.splice(insertIndex, 0, activeId as string);
|
||||
|
||||
dispatch(reorderTasksInGroup({
|
||||
taskIds: newTaskIds,
|
||||
groupId: activeGroup.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
}, [allTasks, groups]);
|
||||
|
||||
// Memoized values for GroupedVirtuoso
|
||||
const virtuosoGroups = useMemo(() => {
|
||||
let currentTaskIndex = 0;
|
||||
return groups.map(group => {
|
||||
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
|
||||
|
||||
// Order tasks according to group.taskIds array to maintain proper order
|
||||
const visibleTasksInGroup = isCurrentGroupCollapsed
|
||||
? []
|
||||
: group.taskIds
|
||||
.map(taskId => allTasks.find(task => task.id === taskId))
|
||||
.filter((task): task is Task => task !== undefined); // Type guard to filter out undefined tasks
|
||||
|
||||
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
|
||||
...task,
|
||||
originalIndex: allTasks.indexOf(task),
|
||||
}));
|
||||
|
||||
const groupData = {
|
||||
...group,
|
||||
tasks: tasksForVirtuoso,
|
||||
startIndex: currentTaskIndex,
|
||||
count: tasksForVirtuoso.length,
|
||||
};
|
||||
currentTaskIndex += tasksForVirtuoso.length;
|
||||
return groupData;
|
||||
});
|
||||
}, [groups, allTasks, collapsedGroups]);
|
||||
|
||||
const virtuosoGroupCounts = useMemo(() => {
|
||||
return virtuosoGroups.map(group => group.count);
|
||||
}, [virtuosoGroups]);
|
||||
|
||||
const virtuosoItems = useMemo(() => {
|
||||
return virtuosoGroups.flatMap(group => group.tasks);
|
||||
}, [virtuosoGroups]);
|
||||
|
||||
// Memoize column headers to prevent unnecessary re-renders
|
||||
const columnHeaders = useMemo(() => (
|
||||
<div className="flex items-center min-w-max px-4 py-2">
|
||||
{visibleColumns.map((column) => {
|
||||
const columnStyle: ColumnStyle = {
|
||||
width: column.width,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
className="text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
style={columnStyle}
|
||||
>
|
||||
{column.id === 'dragHandle' ? (
|
||||
<HolderOutlined className="text-gray-400" />
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
), [visibleColumns]);
|
||||
|
||||
// Render functions
|
||||
const renderGroup = useCallback((groupIndex: number) => {
|
||||
const group = virtuosoGroups[groupIndex];
|
||||
const isGroupEmpty = group.count === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TaskGroupHeader
|
||||
group={{
|
||||
id: group.id,
|
||||
name: group.title,
|
||||
count: group.count,
|
||||
color: group.color,
|
||||
}}
|
||||
isCollapsed={collapsedGroups.has(group.id)}
|
||||
onToggle={() => handleGroupCollapse(group.id)}
|
||||
/>
|
||||
{/* Empty group drop zone */}
|
||||
{isGroupEmpty && !collapsedGroups.has(group.id) && (
|
||||
<div className="px-4 py-8 text-center text-gray-400 dark:text-gray-500 border-2 border-dashed border-transparent hover:border-blue-300 transition-colors">
|
||||
<div className="text-sm">Drop tasks here</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [virtuosoGroups, collapsedGroups, handleGroupCollapse]);
|
||||
|
||||
const renderTask = useCallback((taskIndex: number) => {
|
||||
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
|
||||
if (!task) return null; // Should not happen if logic is correct
|
||||
return (
|
||||
<TaskRow
|
||||
task={task}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
);
|
||||
}, [virtuosoItems, visibleColumns]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
|
||||
{/* Task Filters */}
|
||||
<div className="flex-none px-4 py-3">
|
||||
<ImprovedTaskFilters position="list" />
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
{columnHeaders}
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SortableContext
|
||||
items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<GroupedVirtuoso
|
||||
style={{ height: 'calc(100vh - 200px)' }}
|
||||
groupCounts={virtuosoGroupCounts}
|
||||
groupContent={renderGroup}
|
||||
itemContent={renderTask}
|
||||
components={{
|
||||
// Removed custom Group component as TaskGroupHeader now handles stickiness
|
||||
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style || {}}
|
||||
className="virtuoso-list-container" // Add a class for potential debugging/styling
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
}}
|
||||
/>
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeId ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-xl rounded-md border-2 border-blue-400 opacity-95">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<HolderOutlined className="text-blue-500" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{allTasks.find(task => task.id === activeId)?.name ||
|
||||
allTasks.find(task => task.id === activeId)?.title ||
|
||||
'Task'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{allTasks.find(task => task.id === activeId)?.task_key}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListV2;
|
||||
405
worklenz-frontend/src/components/task-list-v2/TaskRow.tsx
Normal file
405
worklenz-frontend/src/components/task-list-v2/TaskRow.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { CheckCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
import Avatar from '@/components/Avatar';
|
||||
import AssigneeSelector from '@/components/AssigneeSelector';
|
||||
import { format } from 'date-fns';
|
||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
import { ClockIcon } from '@heroicons/react/24/outline';
|
||||
import AvatarGroup from '../AvatarGroup';
|
||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: Task;
|
||||
visibleColumns: Array<{
|
||||
id: string;
|
||||
width: string;
|
||||
isSticky?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Utility function to get task display name with fallbacks
|
||||
const getTaskDisplayName = (task: Task): string => {
|
||||
// Check each field and only use if it has actual content after trimming
|
||||
if (task.title && task.title.trim()) return task.title.trim();
|
||||
if (task.name && task.name.trim()) return task.name.trim();
|
||||
if (task.task_key && task.task_key.trim()) return task.task_key.trim();
|
||||
return DEFAULT_TASK_NAME;
|
||||
};
|
||||
|
||||
// Memoized date formatter to avoid repeated date parsing
|
||||
const formatDate = (dateString: string): string => {
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM d');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Memoized date formatter to avoid repeated date parsing
|
||||
|
||||
const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
|
||||
// Drag and drop functionality
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
data: {
|
||||
type: 'task',
|
||||
task,
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize style object to prevent unnecessary re-renders
|
||||
const style = useMemo(() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}), [transform, transition, isDragging]);
|
||||
|
||||
// Get dark mode from Redux state
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
|
||||
// Memoize task display name
|
||||
const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]);
|
||||
|
||||
// Memoize converted task for AssigneeSelector to prevent recreation
|
||||
const convertedTask = useMemo(() => ({
|
||||
id: task.id,
|
||||
name: taskDisplayName,
|
||||
task_key: task.task_key || taskDisplayName,
|
||||
assignees:
|
||||
task.assignee_names?.map((assignee: InlineMember, index: number) => ({
|
||||
team_member_id: assignee.team_member_id || `assignee-${index}`,
|
||||
id: assignee.team_member_id || `assignee-${index}`,
|
||||
project_member_id: assignee.team_member_id || `assignee-${index}`,
|
||||
name: assignee.name || '',
|
||||
})) || [],
|
||||
parent_task_id: task.parent_task_id,
|
||||
status_id: undefined,
|
||||
project_id: undefined,
|
||||
manual_progress: undefined,
|
||||
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
|
||||
|
||||
// Memoize formatted dates
|
||||
const formattedDueDate = useMemo(() =>
|
||||
task.dueDate ? formatDate(task.dueDate) : null,
|
||||
[task.dueDate]
|
||||
);
|
||||
|
||||
const formattedStartDate = useMemo(() =>
|
||||
task.startDate ? formatDate(task.startDate) : null,
|
||||
[task.startDate]
|
||||
);
|
||||
|
||||
const formattedCompletedDate = useMemo(() =>
|
||||
task.completedAt ? formatDate(task.completedAt) : null,
|
||||
[task.completedAt]
|
||||
);
|
||||
|
||||
const formattedCreatedDate = useMemo(() =>
|
||||
task.created_at ? formatDate(task.created_at) : null,
|
||||
[task.created_at]
|
||||
);
|
||||
|
||||
const formattedUpdatedDate = useMemo(() =>
|
||||
task.updatedAt ? formatDate(task.updatedAt) : null,
|
||||
[task.updatedAt]
|
||||
);
|
||||
|
||||
// Memoize status style
|
||||
const statusStyle = useMemo(() => ({
|
||||
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
|
||||
color: task.statusColor || 'rgb(31, 41, 55)',
|
||||
}), [task.statusColor]);
|
||||
|
||||
// Memoize priority style
|
||||
const priorityStyle = useMemo(() => ({
|
||||
backgroundColor: task.priorityColor ? `${task.priorityColor}20` : 'rgb(229, 231, 235)',
|
||||
color: task.priorityColor || 'rgb(31, 41, 55)',
|
||||
}), [task.priorityColor]);
|
||||
|
||||
// Memoize labels display
|
||||
const labelsDisplay = useMemo(() => {
|
||||
if (!task.labels || task.labels.length === 0) return null;
|
||||
|
||||
const visibleLabels = task.labels.slice(0, 2);
|
||||
const remainingCount = task.labels.length - 2;
|
||||
|
||||
return {
|
||||
visibleLabels,
|
||||
remainingCount: remainingCount > 0 ? remainingCount : null,
|
||||
};
|
||||
}, [task.labels]);
|
||||
|
||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
const baseStyle = { width };
|
||||
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return (
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing flex items-center justify-center"
|
||||
style={baseStyle}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<HolderOutlined className="text-gray-400 hover:text-gray-600" />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'taskKey':
|
||||
return (
|
||||
<div className="flex items-center" style={baseStyle}>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{task.task_key || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center" style={baseStyle}>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{taskDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={statusStyle}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'assignees':
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={baseStyle}>
|
||||
<AvatarGroup
|
||||
members={task.assignee_names || []}
|
||||
maxCount={3}
|
||||
isDarkMode={isDarkMode}
|
||||
size={24}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
task={convertedTask}
|
||||
groupId={null}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={priorityStyle}
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'dueDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{formattedDueDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formattedDueDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.progress !== undefined &&
|
||||
task.progress >= 0 &&
|
||||
(task.progress === 100 ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<CheckCircleOutlined
|
||||
className="text-green-500"
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
color: '#52c41a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TaskProgress
|
||||
progress={task.progress}
|
||||
numberOfSubTasks={task.sub_tasks?.length || 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'labels':
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={baseStyle}>
|
||||
{labelsDisplay?.visibleLabels.map((label, index) => (
|
||||
<span
|
||||
key={`${label.id}-${index}`}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: label.color ? `${label.color}20` : 'rgb(229, 231, 235)',
|
||||
color: label.color || 'rgb(31, 41, 55)',
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{labelsDisplay?.remainingCount && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
+{labelsDisplay.remainingCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'phase':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
{task.phase}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'timeTracking':
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={baseStyle}>
|
||||
<ClockIcon className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{task.timeTracking?.logged || 0}h
|
||||
</span>
|
||||
{task.timeTracking?.estimated && (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
/{task.timeTracking.estimated}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'estimation':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.timeTracking?.estimated && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{task.timeTracking.estimated}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'startDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{formattedStartDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formattedStartDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'completedDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{formattedCompletedDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formattedCompletedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'createdDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{formattedCreatedDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formattedCreatedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'lastUpdated':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{formattedUpdatedDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formattedUpdatedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'reporter':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.reporter && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{task.reporter}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
attributes,
|
||||
listeners,
|
||||
task.task_key,
|
||||
task.status,
|
||||
task.priority,
|
||||
task.phase,
|
||||
task.reporter,
|
||||
task.assignee_names,
|
||||
task.timeTracking,
|
||||
task.progress,
|
||||
task.sub_tasks,
|
||||
taskDisplayName,
|
||||
statusStyle,
|
||||
priorityStyle,
|
||||
formattedDueDate,
|
||||
formattedStartDate,
|
||||
formattedCompletedDate,
|
||||
formattedCreatedDate,
|
||||
formattedUpdatedDate,
|
||||
labelsDisplay,
|
||||
isDarkMode,
|
||||
convertedTask,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||
}`}
|
||||
>
|
||||
{visibleColumns.map((column, index) =>
|
||||
renderColumn(column.id, column.width, column.isSticky, index)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TaskRow.displayName = 'TaskRow';
|
||||
|
||||
export default TaskRow;
|
||||
@@ -1,40 +0,0 @@
|
||||
/* MINIMAL DRAG AND DROP CSS - SHOW ONLY TASK NAME */
|
||||
|
||||
/* Basic drag handle styling */
|
||||
.drag-handle-optimized {
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle-optimized:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle-optimized:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Simple drag overlay - just show task name */
|
||||
[data-dnd-overlay] {
|
||||
background: white;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Dark mode support for drag overlay */
|
||||
.dark [data-dnd-overlay],
|
||||
[data-theme="dark"] [data-dnd-overlay] {
|
||||
background: #1f1f1f;
|
||||
border-color: #404040;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Hide drag handle during drag */
|
||||
[data-dnd-dragging="true"] .drag-handle-optimized {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
DownOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types';
|
||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||
import { taskManagementSelectors, selectAllTasks } from '@/features/task-management/task-management.slice';
|
||||
import { RootState } from '@/app/store';
|
||||
import TaskRow from './task-row';
|
||||
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
||||
import { TaskListField } from '@/features/task-management/taskListFields.slice';
|
||||
import { Checkbox } from '@/components';
|
||||
import { selectIsGroupCollapsed, toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
|
||||
import { selectIsTaskSelected } from '@/features/task-management/selection.slice';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -58,6 +61,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
|
||||
onSelectTask,
|
||||
onToggleSubtasks,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false);
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -69,7 +73,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
|
||||
});
|
||||
|
||||
// Get all tasks from the store
|
||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||
const allTasks = useSelector(selectAllTasks);
|
||||
|
||||
// Get theme from Redux store
|
||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||
@@ -328,19 +332,29 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
|
||||
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="task-group-tasks">
|
||||
{groupTasks.map((task, index) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
index={index}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
fixedColumns={visibleFixedColumns}
|
||||
scrollableColumns={visibleScrollableColumns}
|
||||
/>
|
||||
<Draggable key={task.id} draggableId={task.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`task-row-wrapper ${snapshot.isDragging ? 'dragging' : ''}`}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
index={index}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
fixedColumns={visibleFixedColumns}
|
||||
scrollableColumns={visibleScrollableColumns}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
@@ -17,33 +17,50 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { Card, Spin, Empty, Alert } from 'antd';
|
||||
import { RootState } from '@/app/store';
|
||||
import {
|
||||
taskManagementSelectors,
|
||||
selectAllTasks,
|
||||
selectGroups,
|
||||
selectGrouping,
|
||||
selectLoading,
|
||||
selectError,
|
||||
selectSelectedPriorities,
|
||||
selectSearch,
|
||||
reorderTasks,
|
||||
moveTaskToGroup,
|
||||
moveTaskBetweenGroups,
|
||||
optimisticTaskMove,
|
||||
reorderTasksInGroup,
|
||||
setLoading,
|
||||
fetchTasks,
|
||||
setError,
|
||||
setSelectedPriorities,
|
||||
setSearch,
|
||||
resetTaskManagement,
|
||||
toggleTaskExpansion,
|
||||
addSubtaskToParent,
|
||||
fetchTasksV3,
|
||||
selectTaskGroupsV3,
|
||||
selectCurrentGroupingV3,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
selectTaskGroups,
|
||||
selectCurrentGrouping,
|
||||
setCurrentGrouping,
|
||||
selectCollapsedGroups,
|
||||
selectIsGroupCollapsed,
|
||||
toggleGroupCollapsed,
|
||||
expandAllGroups,
|
||||
collapseAllGroups,
|
||||
} from '@/features/task-management/grouping.slice';
|
||||
import {
|
||||
selectSelectedTaskIds,
|
||||
selectLastSelectedTaskId,
|
||||
selectIsTaskSelected,
|
||||
selectTask,
|
||||
deselectTask,
|
||||
toggleTaskSelection,
|
||||
selectRange,
|
||||
clearSelection,
|
||||
} from '@/features/task-management/selection.slice';
|
||||
import {
|
||||
selectTaskIds,
|
||||
selectTasks,
|
||||
deselectAll as deselectAllBulk,
|
||||
} from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { Task, TaskGroup } from '@/types/task-management.types';
|
||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
@@ -155,16 +172,19 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
const { socket, connected } = useSocket();
|
||||
|
||||
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
||||
const tasks = useSelector(taskManagementSelectors.selectAll);
|
||||
const tasks = useSelector(selectAllTasks);
|
||||
const groups = useSelector(selectGroups);
|
||||
const grouping = useSelector(selectGrouping);
|
||||
const loading = useSelector(selectLoading);
|
||||
const error = useSelector(selectError);
|
||||
const selectedPriorities = useSelector(selectSelectedPriorities);
|
||||
const searchQuery = useSelector(selectSearch);
|
||||
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
||||
const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual);
|
||||
// Use bulk action slice for selected tasks instead of selection slice
|
||||
const selectedTaskIds = useSelector(
|
||||
(state: RootState) => state.bulkActionReducer.selectedTaskIdsList
|
||||
);
|
||||
const currentGrouping = useSelector(selectCurrentGrouping);
|
||||
const collapsedGroups = useSelector(selectCollapsedGroups);
|
||||
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
||||
const lastSelectedTaskId = useSelector(selectLastSelectedTaskId);
|
||||
const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks);
|
||||
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
|
||||
const error = useSelector((state: RootState) => state.taskManagement.error);
|
||||
|
||||
// Bulk action selectors
|
||||
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TaskListFiltersProps {
|
||||
selectedPriorities: string[];
|
||||
onPriorityChange: (priorities: string[]) => void;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
}
|
||||
|
||||
const TaskListFilters: React.FC<TaskListFiltersProps> = ({
|
||||
selectedPriorities,
|
||||
onPriorityChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}) => {
|
||||
const priorities = ['High', 'Medium', 'Low'];
|
||||
|
||||
return (
|
||||
<div className="task-list-filters">
|
||||
<div className="filter-group">
|
||||
<label>Priority:</label>
|
||||
<div className="priority-filters">
|
||||
{priorities.map(priority => (
|
||||
<label key={priority} className="priority-filter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPriorities.includes(priority)}
|
||||
onChange={e => {
|
||||
const newPriorities = e.target.checked
|
||||
? [...selectedPriorities, priority]
|
||||
: selectedPriorities.filter(p => p !== priority);
|
||||
onPriorityChange(newPriorities);
|
||||
}}
|
||||
/>
|
||||
<span>{priority}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label>Search:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
placeholder="Search tasks..."
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListFilters;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Task, TaskGroup } from '@/types/task-management.types';
|
||||
import TaskRow from './task-row';
|
||||
|
||||
interface TaskListGroupProps {
|
||||
group: TaskGroup;
|
||||
tasks: Task[];
|
||||
isCollapsed: boolean;
|
||||
onCollapse: () => void;
|
||||
onTaskSelect: (taskId: string, event: React.MouseEvent) => void;
|
||||
selectedTaskIds: string[];
|
||||
projectId: string;
|
||||
currentGrouping: 'status' | 'priority' | 'phase';
|
||||
}
|
||||
|
||||
const TaskListGroup: React.FC<TaskListGroupProps> = ({
|
||||
group,
|
||||
tasks,
|
||||
isCollapsed,
|
||||
onCollapse,
|
||||
onTaskSelect,
|
||||
selectedTaskIds,
|
||||
projectId,
|
||||
currentGrouping,
|
||||
}) => {
|
||||
const groupStyle = {
|
||||
backgroundColor: group.color ? `${group.color}10` : undefined,
|
||||
borderColor: group.color,
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
backgroundColor: group.color ? `${group.color}20` : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="task-list-group" style={groupStyle}>
|
||||
<div className="group-header" style={headerStyle} onClick={onCollapse}>
|
||||
<div className="group-title">
|
||||
<span className={`collapse-icon ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
{isCollapsed ? '►' : '▼'}
|
||||
</span>
|
||||
<h3>{group.title}</h3>
|
||||
<span className="task-count">({tasks.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="task-list">
|
||||
{tasks.map((task, index) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: task.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`task-row-wrapper ${isDragging ? 'dragging' : ''}`}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
onSelect={(taskId, selected) => onTaskSelect(taskId, {} as React.MouseEvent)}
|
||||
index={index}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListGroup;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TaskListHeaderProps {
|
||||
onExpandAll: () => void;
|
||||
onCollapseAll: () => void;
|
||||
}
|
||||
|
||||
const TaskListHeader: React.FC<TaskListHeaderProps> = ({
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
}) => {
|
||||
return (
|
||||
<div className="task-list-header">
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={onExpandAll}
|
||||
>
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm ml-2"
|
||||
onClick={onCollapseAll}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListHeader;
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
taskManagementSelectors,
|
||||
toggleTaskExpansion,
|
||||
fetchSubTasks,
|
||||
selectAllTasks,
|
||||
selectTaskIds,
|
||||
selectGroups,
|
||||
selectGrouping,
|
||||
selectLoading,
|
||||
selectError,
|
||||
selectSelectedPriorities,
|
||||
selectSearch,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit';
|
||||
import { GroupingState, TaskGroup } from '@/types/task-management.types';
|
||||
import { TaskGroup } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
import { taskManagementSelectors } from './task-management.slice';
|
||||
import { selectAllTasksArray } from './task-management.slice';
|
||||
|
||||
const initialState: GroupingState = {
|
||||
currentGrouping: 'status',
|
||||
type GroupingType = 'status' | 'priority' | 'phase';
|
||||
|
||||
interface LocalGroupingState {
|
||||
currentGrouping: GroupingType | null;
|
||||
customPhases: string[];
|
||||
groupOrder: {
|
||||
status: string[];
|
||||
priority: string[];
|
||||
phase: string[];
|
||||
};
|
||||
groupStates: Record<string, { collapsed: boolean }>;
|
||||
collapsedGroups: string[];
|
||||
}
|
||||
|
||||
const initialState: LocalGroupingState = {
|
||||
currentGrouping: null,
|
||||
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
|
||||
groupOrder: {
|
||||
status: ['todo', 'doing', 'done'],
|
||||
@@ -12,13 +26,14 @@ const initialState: GroupingState = {
|
||||
phase: ['Planning', 'Development', 'Testing', 'Deployment'],
|
||||
},
|
||||
groupStates: {},
|
||||
collapsedGroups: [],
|
||||
};
|
||||
|
||||
const groupingSlice = createSlice({
|
||||
name: 'grouping',
|
||||
initialState,
|
||||
reducers: {
|
||||
setCurrentGrouping: (state, action: PayloadAction<'status' | 'priority' | 'phase'>) => {
|
||||
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
|
||||
state.currentGrouping = action.payload;
|
||||
},
|
||||
|
||||
@@ -41,17 +56,19 @@ const groupingSlice = createSlice({
|
||||
state.groupOrder.phase = action.payload;
|
||||
},
|
||||
|
||||
updateGroupOrder: (state, action: PayloadAction<{ groupType: string; order: string[] }>) => {
|
||||
updateGroupOrder: (state, action: PayloadAction<{ groupType: keyof LocalGroupingState['groupOrder']; order: string[] }>) => {
|
||||
const { groupType, order } = action.payload;
|
||||
state.groupOrder[groupType] = order;
|
||||
},
|
||||
|
||||
toggleGroupCollapsed: (state, action: PayloadAction<string>) => {
|
||||
const groupId = action.payload;
|
||||
if (!state.groupStates[groupId]) {
|
||||
state.groupStates[groupId] = { collapsed: false };
|
||||
const isCollapsed = state.collapsedGroups.includes(groupId);
|
||||
if (isCollapsed) {
|
||||
state.collapsedGroups = state.collapsedGroups.filter(id => id !== groupId);
|
||||
} else {
|
||||
state.collapsedGroups.push(groupId);
|
||||
}
|
||||
state.groupStates[groupId].collapsed = !state.groupStates[groupId].collapsed;
|
||||
},
|
||||
|
||||
setGroupCollapsed: (state, action: PayloadAction<{ groupId: string; collapsed: boolean }>) => {
|
||||
@@ -62,16 +79,12 @@ const groupingSlice = createSlice({
|
||||
state.groupStates[groupId].collapsed = collapsed;
|
||||
},
|
||||
|
||||
collapseAllGroups: state => {
|
||||
Object.keys(state.groupStates).forEach(groupId => {
|
||||
state.groupStates[groupId].collapsed = true;
|
||||
});
|
||||
collapseAllGroups: (state, action: PayloadAction<string[]>) => {
|
||||
state.collapsedGroups = action.payload;
|
||||
},
|
||||
|
||||
expandAllGroups: state => {
|
||||
Object.keys(state.groupStates).forEach(groupId => {
|
||||
state.groupStates[groupId].collapsed = false;
|
||||
});
|
||||
state.collapsedGroups = [];
|
||||
},
|
||||
|
||||
resetGrouping: () => initialState,
|
||||
@@ -96,56 +109,59 @@ export const selectCurrentGrouping = (state: RootState) => state.grouping.curren
|
||||
export const selectCustomPhases = (state: RootState) => state.grouping.customPhases;
|
||||
export const selectGroupOrder = (state: RootState) => state.grouping.groupOrder;
|
||||
export const selectGroupStates = (state: RootState) => state.grouping.groupStates;
|
||||
export const selectCollapsedGroups = (state: RootState) => new Set(state.grouping.collapsedGroups);
|
||||
export const selectIsGroupCollapsed = (state: RootState, groupId: string) =>
|
||||
state.grouping.collapsedGroups.includes(groupId);
|
||||
|
||||
// Complex selectors using createSelector for memoization
|
||||
export const selectCurrentGroupOrder = createSelector(
|
||||
[selectCurrentGrouping, selectGroupOrder],
|
||||
(currentGrouping, groupOrder) => groupOrder[currentGrouping] || []
|
||||
(currentGrouping, groupOrder) => {
|
||||
if (!currentGrouping) return [];
|
||||
return groupOrder[currentGrouping] || [];
|
||||
}
|
||||
);
|
||||
|
||||
export const selectTaskGroups = createSelector(
|
||||
[
|
||||
taskManagementSelectors.selectAll,
|
||||
selectCurrentGrouping,
|
||||
selectCurrentGroupOrder,
|
||||
selectGroupStates,
|
||||
],
|
||||
[selectAllTasksArray, selectCurrentGrouping, selectCurrentGroupOrder, selectGroupStates],
|
||||
(tasks, currentGrouping, groupOrder, groupStates) => {
|
||||
const groups: TaskGroup[] = [];
|
||||
|
||||
if (!currentGrouping) return groups;
|
||||
|
||||
// Get unique values for the current grouping
|
||||
const groupValues =
|
||||
groupOrder.length > 0
|
||||
? groupOrder
|
||||
: [
|
||||
...new Set(
|
||||
tasks.map(task => {
|
||||
if (currentGrouping === 'status') return task.status;
|
||||
if (currentGrouping === 'priority') return task.priority;
|
||||
return task.phase;
|
||||
})
|
||||
),
|
||||
];
|
||||
: Array.from(new Set(
|
||||
tasks.map(task => {
|
||||
if (currentGrouping === 'status') return task.status;
|
||||
if (currentGrouping === 'priority') return task.priority;
|
||||
return task.phase;
|
||||
})
|
||||
));
|
||||
|
||||
groupValues.forEach(value => {
|
||||
if (!value) return; // Skip undefined values
|
||||
|
||||
const tasksInGroup = tasks
|
||||
.filter(task => {
|
||||
if (currentGrouping === 'status') return task.status === value;
|
||||
if (currentGrouping === 'priority') return task.priority === value;
|
||||
return task.phase === value;
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
const groupId = `${currentGrouping}-${value}`;
|
||||
|
||||
groups.push({
|
||||
id: groupId,
|
||||
title: value.charAt(0).toUpperCase() + value.slice(1),
|
||||
groupType: currentGrouping,
|
||||
groupValue: value,
|
||||
collapsed: groupStates[groupId]?.collapsed || false,
|
||||
taskIds: tasksInGroup.map(task => task.id),
|
||||
type: currentGrouping,
|
||||
color: getGroupColor(currentGrouping, value),
|
||||
collapsed: groupStates[groupId]?.collapsed || false,
|
||||
groupValue: value,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,15 +170,17 @@ export const selectTaskGroups = createSelector(
|
||||
);
|
||||
|
||||
export const selectTasksByCurrentGrouping = createSelector(
|
||||
[taskManagementSelectors.selectAll, selectCurrentGrouping],
|
||||
[selectAllTasksArray, selectCurrentGrouping],
|
||||
(tasks, currentGrouping) => {
|
||||
const grouped: Record<string, typeof tasks> = {};
|
||||
|
||||
if (!currentGrouping) return grouped;
|
||||
|
||||
tasks.forEach(task => {
|
||||
let key: string;
|
||||
if (currentGrouping === 'status') key = task.status;
|
||||
else if (currentGrouping === 'priority') key = task.priority;
|
||||
else key = task.phase;
|
||||
else key = task.phase || 'Development';
|
||||
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(task);
|
||||
@@ -170,7 +188,7 @@ export const selectTasksByCurrentGrouping = createSelector(
|
||||
|
||||
// Sort tasks within each group by order
|
||||
Object.keys(grouped).forEach(key => {
|
||||
grouped[key].sort((a, b) => a.order - b.order);
|
||||
grouped[key].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
});
|
||||
|
||||
return grouped;
|
||||
@@ -178,7 +196,7 @@ export const selectTasksByCurrentGrouping = createSelector(
|
||||
);
|
||||
|
||||
// Helper function to get group colors
|
||||
const getGroupColor = (groupType: string, value: string): string => {
|
||||
const getGroupColor = (groupType: GroupingType, value: string): string => {
|
||||
const colorMaps = {
|
||||
status: {
|
||||
todo: '#f0f0f0',
|
||||
@@ -199,7 +217,8 @@ const getGroupColor = (groupType: string, value: string): string => {
|
||||
},
|
||||
};
|
||||
|
||||
return colorMaps[groupType as keyof typeof colorMaps]?.[value as keyof any] || '#d9d9d9';
|
||||
const colorMap = colorMaps[groupType];
|
||||
return (colorMap as any)?.[value] || '#d9d9d9';
|
||||
};
|
||||
|
||||
export default groupingSlice.reducer;
|
||||
|
||||
@@ -1,121 +1,71 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { SelectionState } from '@/types/task-management.types';
|
||||
import { TaskSelection } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
|
||||
const initialState: SelectionState = {
|
||||
const initialState: TaskSelection = {
|
||||
selectedTaskIds: [],
|
||||
lastSelectedId: null,
|
||||
lastSelectedTaskId: null,
|
||||
};
|
||||
|
||||
const selectionSlice = createSlice({
|
||||
name: 'selection',
|
||||
name: 'taskManagementSelection',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleTaskSelection: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
const index = state.selectedTaskIds.indexOf(taskId);
|
||||
|
||||
if (index === -1) {
|
||||
state.selectedTaskIds.push(taskId);
|
||||
} else {
|
||||
state.selectedTaskIds.splice(index, 1);
|
||||
}
|
||||
|
||||
state.lastSelectedId = taskId;
|
||||
},
|
||||
|
||||
selectTask: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
if (!state.selectedTaskIds.includes(taskId)) {
|
||||
state.selectedTaskIds.push(taskId);
|
||||
}
|
||||
state.lastSelectedId = taskId;
|
||||
state.lastSelectedTaskId = taskId;
|
||||
},
|
||||
|
||||
deselectTask: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
state.selectedTaskIds = state.selectedTaskIds.filter(id => id !== taskId);
|
||||
if (state.lastSelectedId === taskId) {
|
||||
state.lastSelectedId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null;
|
||||
if (state.lastSelectedTaskId === taskId) {
|
||||
state.lastSelectedTaskId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null;
|
||||
}
|
||||
},
|
||||
|
||||
selectMultipleTasks: (state, action: PayloadAction<string[]>) => {
|
||||
toggleTaskSelection: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
const index = state.selectedTaskIds.indexOf(taskId);
|
||||
if (index === -1) {
|
||||
state.selectedTaskIds.push(taskId);
|
||||
state.lastSelectedTaskId = taskId;
|
||||
} else {
|
||||
state.selectedTaskIds.splice(index, 1);
|
||||
state.lastSelectedTaskId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null;
|
||||
}
|
||||
},
|
||||
selectRange: (state, action: PayloadAction<string[]>) => {
|
||||
const taskIds = action.payload;
|
||||
// Add new task IDs that aren't already selected
|
||||
taskIds.forEach(id => {
|
||||
if (!state.selectedTaskIds.includes(id)) {
|
||||
state.selectedTaskIds.push(id);
|
||||
}
|
||||
});
|
||||
state.lastSelectedId = taskIds[taskIds.length - 1] || state.lastSelectedId;
|
||||
const uniqueIds = Array.from(new Set([...state.selectedTaskIds, ...taskIds]));
|
||||
state.selectedTaskIds = uniqueIds;
|
||||
state.lastSelectedTaskId = taskIds[taskIds.length - 1];
|
||||
},
|
||||
|
||||
selectRangeTasks: (
|
||||
state,
|
||||
action: PayloadAction<{ startId: string; endId: string; allTaskIds: string[] }>
|
||||
) => {
|
||||
const { startId, endId, allTaskIds } = action.payload;
|
||||
const startIndex = allTaskIds.indexOf(startId);
|
||||
const endIndex = allTaskIds.indexOf(endId);
|
||||
|
||||
if (startIndex !== -1 && endIndex !== -1) {
|
||||
const [start, end] =
|
||||
startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex];
|
||||
const rangeIds = allTaskIds.slice(start, end + 1);
|
||||
|
||||
// Add range IDs that aren't already selected
|
||||
rangeIds.forEach(id => {
|
||||
if (!state.selectedTaskIds.includes(id)) {
|
||||
state.selectedTaskIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
state.lastSelectedId = endId;
|
||||
}
|
||||
},
|
||||
|
||||
selectAllTasks: (state, action: PayloadAction<string[]>) => {
|
||||
state.selectedTaskIds = action.payload;
|
||||
state.lastSelectedId = action.payload[action.payload.length - 1] || null;
|
||||
},
|
||||
|
||||
clearSelection: state => {
|
||||
state.selectedTaskIds = [];
|
||||
state.lastSelectedId = null;
|
||||
state.lastSelectedTaskId = null;
|
||||
},
|
||||
|
||||
setSelection: (state, action: PayloadAction<string[]>) => {
|
||||
state.selectedTaskIds = action.payload;
|
||||
state.lastSelectedId = action.payload[action.payload.length - 1] || null;
|
||||
resetSelection: state => {
|
||||
state.selectedTaskIds = [];
|
||||
state.lastSelectedTaskId = null;
|
||||
},
|
||||
|
||||
resetSelection: () => initialState,
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleTaskSelection,
|
||||
selectTask,
|
||||
deselectTask,
|
||||
selectMultipleTasks,
|
||||
selectRangeTasks,
|
||||
selectAllTasks,
|
||||
toggleTaskSelection,
|
||||
selectRange,
|
||||
clearSelection,
|
||||
setSelection,
|
||||
resetSelection,
|
||||
} = selectionSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectSelectedTaskIds = (state: RootState) =>
|
||||
state.taskManagementSelection.selectedTaskIds;
|
||||
export const selectLastSelectedId = (state: RootState) =>
|
||||
state.taskManagementSelection.lastSelectedId;
|
||||
export const selectHasSelection = (state: RootState) =>
|
||||
state.taskManagementSelection.selectedTaskIds.length > 0;
|
||||
export const selectSelectionCount = (state: RootState) =>
|
||||
state.taskManagementSelection.selectedTaskIds.length;
|
||||
export const selectIsTaskSelected = (taskId: string) => (state: RootState) =>
|
||||
export const selectSelectedTaskIds = (state: RootState) => state.taskManagementSelection.selectedTaskIds;
|
||||
export const selectLastSelectedTaskId = (state: RootState) => state.taskManagementSelection.lastSelectedTaskId;
|
||||
export const selectIsTaskSelected = (state: RootState, taskId: string) =>
|
||||
state.taskManagementSelection.selectedTaskIds.includes(taskId);
|
||||
|
||||
export default selectionSlice.reducer;
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
createEntityAdapter,
|
||||
PayloadAction,
|
||||
createAsyncThunk,
|
||||
EntityState,
|
||||
EntityId,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { Task, TaskManagementState } from '@/types/task-management.types';
|
||||
import { Task, TaskManagementState, TaskGroup, TaskGrouping } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
import {
|
||||
tasksApiService,
|
||||
@@ -12,6 +14,25 @@ import {
|
||||
ITaskListV3Response,
|
||||
} from '@/api/tasks/tasks.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||
|
||||
// Helper function to safely convert time values
|
||||
const convertTimeValue = (value: any): number => {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// Handle time objects like {hours: 2, minutes: 30}
|
||||
if ('hours' in value || 'minutes' in value) {
|
||||
const hours = Number(value.hours || 0);
|
||||
const minutes = Number(value.minutes || 0);
|
||||
return hours + minutes / 60;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export enum IGroupBy {
|
||||
STATUS = 'status',
|
||||
@@ -21,17 +42,16 @@ export enum IGroupBy {
|
||||
}
|
||||
|
||||
// Entity adapter for normalized state
|
||||
const tasksAdapter = createEntityAdapter<Task>({
|
||||
sortComparer: (a, b) => a.order - b.order,
|
||||
});
|
||||
const tasksAdapter = createEntityAdapter<Task>();
|
||||
|
||||
// Get the initial state from the adapter
|
||||
const initialState: TaskManagementState = {
|
||||
entities: {},
|
||||
ids: [],
|
||||
entities: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
groups: [],
|
||||
grouping: null,
|
||||
grouping: undefined,
|
||||
selectedPriorities: [],
|
||||
search: '',
|
||||
};
|
||||
@@ -47,7 +67,7 @@ export const fetchTasks = createAsyncThunk(
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
archived: false,
|
||||
group: currentGrouping,
|
||||
group: currentGrouping || '',
|
||||
field: '',
|
||||
order: '',
|
||||
search: '',
|
||||
@@ -118,7 +138,7 @@ export const fetchTasks = createAsyncThunk(
|
||||
group.tasks.map((task: any) => ({
|
||||
id: task.id,
|
||||
task_key: task.task_key || '',
|
||||
title: task.name || '',
|
||||
title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
|
||||
description: task.description || '',
|
||||
status: statusIdToNameMap[task.status] || 'todo',
|
||||
priority: priorityIdToNameMap[task.priority] || 'medium',
|
||||
@@ -167,24 +187,18 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
|
||||
// Get selected labels from taskReducer
|
||||
const selectedLabels = state.taskReducer.labels
|
||||
? state.taskReducer.labels
|
||||
.filter(l => l.selected)
|
||||
.map(l => l.id)
|
||||
.join(' ')
|
||||
: '';
|
||||
.filter((l: any) => l.selected && l.id)
|
||||
.map((l: any) => l.id)
|
||||
.join(' ');
|
||||
|
||||
// Get selected assignees from taskReducer
|
||||
const selectedAssignees = state.taskReducer.taskAssignees
|
||||
? state.taskReducer.taskAssignees
|
||||
.filter(m => m.selected)
|
||||
.map(m => m.id)
|
||||
.join(' ')
|
||||
: '';
|
||||
.filter((m: any) => m.selected && m.id)
|
||||
.map((m: any) => m.id)
|
||||
.join(' ');
|
||||
|
||||
// Get selected priorities from taskReducer (consistent with other slices)
|
||||
const selectedPriorities = state.taskReducer.priorities
|
||||
? state.taskReducer.priorities.join(' ')
|
||||
: '';
|
||||
// Get selected priorities from taskReducer
|
||||
const selectedPriorities = state.taskReducer.priorities.join(' ');
|
||||
|
||||
// Get search value from taskReducer
|
||||
const searchValue = state.taskReducer.search || '';
|
||||
@@ -192,7 +206,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
archived: false,
|
||||
group: currentGrouping,
|
||||
group: currentGrouping || '',
|
||||
field: '',
|
||||
order: '',
|
||||
search: searchValue,
|
||||
@@ -206,10 +220,88 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
|
||||
// Minimal processing - tasks are already processed by backend
|
||||
// Log raw response for debugging
|
||||
console.log('Raw API response:', response.body);
|
||||
console.log('Sample task from backend:', response.body.allTasks?.[0]);
|
||||
console.log('Task key from backend:', response.body.allTasks?.[0]?.task_key);
|
||||
|
||||
// Ensure tasks are properly normalized
|
||||
const tasks = response.body.allTasks.map((task: any) => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
task_key: task.task_key || task.key || '',
|
||||
title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
|
||||
description: task.description || '',
|
||||
status: task.status || 'todo',
|
||||
priority: task.priority || 'medium',
|
||||
phase: task.phase || 'Development',
|
||||
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
||||
assignees: task.assignees?.map((a: { team_member_id: string }) => a.team_member_id) || [],
|
||||
assignee_names: task.assignee_names || task.names || [],
|
||||
labels: task.labels?.map((l: { id: string; label_id: string; name: string; color_code: string; end: boolean; names: string[] }) => ({
|
||||
id: l.id || l.label_id,
|
||||
name: l.name,
|
||||
color: l.color_code || '#1890ff',
|
||||
end: l.end,
|
||||
names: l.names,
|
||||
})) || [],
|
||||
due_date: task.end_date || '',
|
||||
timeTracking: {
|
||||
estimated: convertTimeValue(task.total_time),
|
||||
logged: convertTimeValue(task.time_spent),
|
||||
},
|
||||
created_at: task.created_at || now,
|
||||
updated_at: task.updated_at || now,
|
||||
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
||||
sub_tasks: task.sub_tasks || [],
|
||||
sub_tasks_count: task.sub_tasks_count || 0,
|
||||
show_sub_tasks: task.show_sub_tasks || false,
|
||||
parent_task_id: task.parent_task_id || '',
|
||||
weight: task.weight || 0,
|
||||
color: task.color || '',
|
||||
statusColor: task.status_color || '',
|
||||
priorityColor: task.priority_color || '',
|
||||
comments_count: task.comments_count || 0,
|
||||
attachments_count: task.attachments_count || 0,
|
||||
has_dependencies: !!task.has_dependencies,
|
||||
schedule_id: task.schedule_id || null,
|
||||
} as Task;
|
||||
});
|
||||
|
||||
// Map groups to match TaskGroup interface
|
||||
const mappedGroups = response.body.groups.map((group: any) => ({
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
taskIds: group.taskIds || [],
|
||||
type: group.groupType as 'status' | 'priority' | 'phase' | 'members',
|
||||
color: group.color,
|
||||
}));
|
||||
|
||||
// Log normalized data for debugging
|
||||
console.log('Normalized data:', {
|
||||
tasks,
|
||||
groups: mappedGroups,
|
||||
grouping: response.body.grouping,
|
||||
totalTasks: response.body.totalTasks,
|
||||
});
|
||||
|
||||
// Verify task IDs match group taskIds
|
||||
const taskIds = new Set(tasks.map(t => t.id));
|
||||
const groupTaskIds = new Set(mappedGroups.flatMap(g => g.taskIds));
|
||||
console.log('Task ID verification:', {
|
||||
taskIds: Array.from(taskIds),
|
||||
groupTaskIds: Array.from(groupTaskIds),
|
||||
allTaskIdsInGroups: Array.from(groupTaskIds).every(id => taskIds.has(id)),
|
||||
allGroupTaskIdsInTasks: Array.from(taskIds).every(id => groupTaskIds.has(id)),
|
||||
});
|
||||
|
||||
return {
|
||||
tasks: response.body.allTasks,
|
||||
groups: response.body.groups,
|
||||
tasks: tasks,
|
||||
groups: mappedGroups,
|
||||
grouping: response.body.grouping,
|
||||
totalTasks: response.body.totalTasks,
|
||||
};
|
||||
@@ -237,7 +329,7 @@ export const fetchSubTasks = createAsyncThunk(
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
archived: false,
|
||||
group: currentGrouping,
|
||||
group: currentGrouping || '',
|
||||
field: '',
|
||||
order: '',
|
||||
search: '',
|
||||
@@ -343,327 +435,182 @@ export const moveTaskToGroupWithAPI = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
// Add action to update task with subtasks
|
||||
export const updateTaskWithSubtasks = createAsyncThunk(
|
||||
'taskManagement/updateTaskWithSubtasks',
|
||||
async ({ taskId, subtasks }: { taskId: string; subtasks: any[] }, { getState }) => {
|
||||
return { taskId, subtasks };
|
||||
}
|
||||
);
|
||||
|
||||
// Create the slice
|
||||
const taskManagementSlice = createSlice({
|
||||
name: 'taskManagement',
|
||||
initialState: tasksAdapter.getInitialState(initialState),
|
||||
initialState,
|
||||
reducers: {
|
||||
// Basic CRUD operations
|
||||
setTasks: (state, action: PayloadAction<Task[]>) => {
|
||||
tasksAdapter.setAll(state, action.payload);
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
const tasks = action.payload;
|
||||
state.ids = tasks.map(task => task.id);
|
||||
state.entities = tasks.reduce((acc, task) => {
|
||||
acc[task.id] = task;
|
||||
return acc;
|
||||
}, {} as Record<string, Task>);
|
||||
},
|
||||
|
||||
addTask: (state, action: PayloadAction<Task>) => {
|
||||
tasksAdapter.addOne(state, action.payload);
|
||||
const task = action.payload;
|
||||
state.ids.push(task.id);
|
||||
state.entities[task.id] = task;
|
||||
},
|
||||
|
||||
addTaskToGroup: (state, action: PayloadAction<{ task: Task; groupId?: string }>) => {
|
||||
addTaskToGroup: (state, action: PayloadAction<{ task: Task; groupId: string }>) => {
|
||||
const { task, groupId } = action.payload;
|
||||
|
||||
// Add to entity adapter
|
||||
tasksAdapter.addOne(state, task);
|
||||
|
||||
// Add to groups array for V3 API compatibility
|
||||
if (state.groups && state.groups.length > 0) {
|
||||
// Find the target group using the provided UUID
|
||||
const targetGroup = state.groups.find(group => {
|
||||
// If a specific groupId (UUID) is provided, use it directly
|
||||
if (groupId && group.id === groupId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (targetGroup) {
|
||||
// Add task ID to the end of the group's taskIds array (newest last)
|
||||
targetGroup.taskIds.push(task.id);
|
||||
|
||||
// Also add to the tasks array if it exists (for backward compatibility)
|
||||
if ((targetGroup as any).tasks) {
|
||||
(targetGroup as any).tasks.push(task);
|
||||
}
|
||||
}
|
||||
state.ids.push(task.id);
|
||||
state.entities[task.id] = task;
|
||||
const group = state.groups.find(g => g.id === groupId);
|
||||
if (group) {
|
||||
group.taskIds.push(task.id);
|
||||
}
|
||||
},
|
||||
|
||||
updateTask: (state, action: PayloadAction<{ id: string; changes: Partial<Task> }>) => {
|
||||
tasksAdapter.updateOne(state, {
|
||||
id: action.payload.id,
|
||||
changes: {
|
||||
...action.payload.changes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
updateTask: (state, action: PayloadAction<Task>) => {
|
||||
const task = action.payload;
|
||||
state.entities[task.id] = task;
|
||||
},
|
||||
deleteTask: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
delete state.entities[taskId];
|
||||
state.ids = state.ids.filter(id => id !== taskId);
|
||||
state.groups = state.groups.map(group => ({
|
||||
...group,
|
||||
taskIds: group.taskIds.filter(id => id !== taskId),
|
||||
}));
|
||||
},
|
||||
bulkUpdateTasks: (state, action: PayloadAction<Task[]>) => {
|
||||
action.payload.forEach(task => {
|
||||
state.entities[task.id] = task;
|
||||
});
|
||||
},
|
||||
|
||||
deleteTask: (state, action: PayloadAction<string>) => {
|
||||
tasksAdapter.removeOne(state, action.payload);
|
||||
},
|
||||
|
||||
// Bulk operations
|
||||
bulkUpdateTasks: (state, action: PayloadAction<{ ids: string[]; changes: Partial<Task> }>) => {
|
||||
const { ids, changes } = action.payload;
|
||||
const updates = ids.map(id => ({
|
||||
id,
|
||||
changes: {
|
||||
...changes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
tasksAdapter.updateMany(state, updates);
|
||||
},
|
||||
|
||||
bulkDeleteTasks: (state, action: PayloadAction<string[]>) => {
|
||||
tasksAdapter.removeMany(state, action.payload);
|
||||
},
|
||||
|
||||
// Optimized drag and drop operations
|
||||
reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => {
|
||||
const { taskIds, newOrder } = action.payload;
|
||||
|
||||
// Batch update for better performance
|
||||
const updates = taskIds.map((id, index) => ({
|
||||
id,
|
||||
changes: {
|
||||
order: newOrder[index],
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
const taskIds = action.payload;
|
||||
taskIds.forEach(taskId => {
|
||||
delete state.entities[taskId];
|
||||
});
|
||||
state.ids = state.ids.filter(id => !taskIds.includes(id));
|
||||
state.groups = state.groups.map(group => ({
|
||||
...group,
|
||||
taskIds: group.taskIds.filter(id => !taskIds.includes(id)),
|
||||
}));
|
||||
|
||||
tasksAdapter.updateMany(state, updates);
|
||||
},
|
||||
|
||||
moveTaskToGroup: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
taskId: string;
|
||||
groupType: 'status' | 'priority' | 'phase';
|
||||
groupValue: string;
|
||||
}>
|
||||
) => {
|
||||
const { taskId, groupType, groupValue } = action.payload;
|
||||
const changes: Partial<Task> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update the appropriate field based on group type
|
||||
if (groupType === 'status') {
|
||||
changes.status = groupValue as Task['status'];
|
||||
} else if (groupType === 'priority') {
|
||||
changes.priority = groupValue as Task['priority'];
|
||||
} else if (groupType === 'phase') {
|
||||
changes.phase = groupValue;
|
||||
reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; groupId: string }>) => {
|
||||
const { taskIds, groupId } = action.payload;
|
||||
const group = state.groups.find(g => g.id === groupId);
|
||||
if (group) {
|
||||
group.taskIds = taskIds;
|
||||
}
|
||||
|
||||
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||
},
|
||||
|
||||
// New action to move task between groups with proper group management
|
||||
moveTaskToGroup: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
|
||||
const { taskId, groupId } = action.payload;
|
||||
state.groups = state.groups.map(group => ({
|
||||
...group,
|
||||
taskIds:
|
||||
group.id === groupId
|
||||
? [...group.taskIds, taskId]
|
||||
: group.taskIds.filter(id => id !== taskId),
|
||||
}));
|
||||
},
|
||||
moveTaskBetweenGroups: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
taskId: string;
|
||||
fromGroupId: string;
|
||||
toGroupId: string;
|
||||
taskUpdate: Partial<Task>;
|
||||
sourceGroupId: string;
|
||||
targetGroupId: string;
|
||||
}>
|
||||
) => {
|
||||
const { taskId, fromGroupId, toGroupId, taskUpdate } = action.payload;
|
||||
|
||||
// Update the task entity with new values
|
||||
tasksAdapter.updateOne(state, {
|
||||
id: taskId,
|
||||
changes: {
|
||||
...taskUpdate,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update groups if they exist
|
||||
if (state.groups && state.groups.length > 0) {
|
||||
// Remove task from old group
|
||||
const fromGroup = state.groups.find(group => group.id === fromGroupId);
|
||||
if (fromGroup) {
|
||||
fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId);
|
||||
}
|
||||
|
||||
// Add task to new group
|
||||
const toGroup = state.groups.find(group => group.id === toGroupId);
|
||||
if (toGroup) {
|
||||
// Add to the end of the group (newest last)
|
||||
toGroup.taskIds.push(taskId);
|
||||
}
|
||||
}
|
||||
const { taskId, sourceGroupId, targetGroupId } = action.payload;
|
||||
state.groups = state.groups.map(group => ({
|
||||
...group,
|
||||
taskIds:
|
||||
group.id === targetGroupId
|
||||
? [...group.taskIds, taskId]
|
||||
: group.id === sourceGroupId
|
||||
? group.taskIds.filter(id => id !== taskId)
|
||||
: group.taskIds,
|
||||
}));
|
||||
},
|
||||
|
||||
// Optimistic update for drag operations - reduces perceived lag
|
||||
optimisticTaskMove: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>
|
||||
) => {
|
||||
const { taskId, newGroupId, newIndex } = action.payload;
|
||||
const task = state.entities[taskId];
|
||||
|
||||
if (task) {
|
||||
// Parse group ID to determine new values
|
||||
const [groupType, ...groupValueParts] = newGroupId.split('-');
|
||||
const groupValue = groupValueParts.join('-');
|
||||
|
||||
const changes: Partial<Task> = {
|
||||
order: newIndex,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update group-specific field
|
||||
if (groupType === 'status') {
|
||||
changes.status = groupValue as Task['status'];
|
||||
} else if (groupType === 'priority') {
|
||||
changes.priority = groupValue as Task['priority'];
|
||||
} else if (groupType === 'phase') {
|
||||
changes.phase = groupValue;
|
||||
}
|
||||
|
||||
// Update the task entity
|
||||
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||
|
||||
// Update groups if they exist
|
||||
if (state.groups && state.groups.length > 0) {
|
||||
// Find the target group
|
||||
const targetGroup = state.groups.find(group => group.id === newGroupId);
|
||||
if (targetGroup) {
|
||||
// Remove task from all groups first
|
||||
state.groups.forEach(group => {
|
||||
group.taskIds = group.taskIds.filter(id => id !== taskId);
|
||||
});
|
||||
|
||||
// Add task to target group at the specified index
|
||||
if (newIndex >= targetGroup.taskIds.length) {
|
||||
targetGroup.taskIds.push(taskId);
|
||||
} else {
|
||||
targetGroup.taskIds.splice(newIndex, 0, taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Proper reorder action that handles both task entities and group arrays
|
||||
reorderTasksInGroup: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
taskId: string;
|
||||
fromGroupId: string;
|
||||
toGroupId: string;
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
groupType: 'status' | 'priority' | 'phase';
|
||||
groupValue: string;
|
||||
sourceGroupId: string;
|
||||
targetGroupId: string;
|
||||
}>
|
||||
) => {
|
||||
const { taskId, fromGroupId, toGroupId, fromIndex, toIndex, groupType, groupValue } =
|
||||
action.payload;
|
||||
|
||||
// Update the task entity
|
||||
const changes: Partial<Task> = {
|
||||
order: toIndex,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update group-specific field
|
||||
if (groupType === 'status') {
|
||||
changes.status = groupValue as Task['status'];
|
||||
} else if (groupType === 'priority') {
|
||||
changes.priority = groupValue as Task['priority'];
|
||||
} else if (groupType === 'phase') {
|
||||
changes.phase = groupValue;
|
||||
}
|
||||
|
||||
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||
|
||||
// Update groups if they exist
|
||||
if (state.groups && state.groups.length > 0) {
|
||||
// Remove task from source group
|
||||
const fromGroup = state.groups.find(group => group.id === fromGroupId);
|
||||
if (fromGroup) {
|
||||
fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId);
|
||||
}
|
||||
|
||||
// Add task to target group
|
||||
const toGroup = state.groups.find(group => group.id === toGroupId);
|
||||
if (toGroup) {
|
||||
if (toIndex >= toGroup.taskIds.length) {
|
||||
toGroup.taskIds.push(taskId);
|
||||
} else {
|
||||
toGroup.taskIds.splice(toIndex, 0, taskId);
|
||||
}
|
||||
}
|
||||
const { taskId, sourceGroupId, targetGroupId } = action.payload;
|
||||
state.groups = state.groups.map(group => ({
|
||||
...group,
|
||||
taskIds:
|
||||
group.id === targetGroupId
|
||||
? [...group.taskIds, taskId]
|
||||
: group.id === sourceGroupId
|
||||
? group.taskIds.filter(id => id !== taskId)
|
||||
: group.taskIds,
|
||||
}));
|
||||
},
|
||||
reorderTasksInGroup: (
|
||||
state,
|
||||
action: PayloadAction<{ taskIds: string[]; groupId: string }>
|
||||
) => {
|
||||
const { taskIds, groupId } = action.payload;
|
||||
const group = state.groups.find(g => g.id === groupId);
|
||||
if (group) {
|
||||
group.taskIds = taskIds;
|
||||
}
|
||||
},
|
||||
|
||||
// Loading states
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
|
||||
// Filter actions
|
||||
setSelectedPriorities: (state, action: PayloadAction<string[]>) => {
|
||||
state.selectedPriorities = action.payload;
|
||||
},
|
||||
|
||||
// Search action
|
||||
setSearch: (state, action: PayloadAction<string>) => {
|
||||
state.search = action.payload;
|
||||
},
|
||||
|
||||
// Reset action
|
||||
resetTaskManagement: state => {
|
||||
return tasksAdapter.getInitialState(initialState);
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
state.groups = [];
|
||||
state.grouping = undefined;
|
||||
state.selectedPriorities = [];
|
||||
state.search = '';
|
||||
state.ids = [];
|
||||
state.entities = {};
|
||||
},
|
||||
toggleTaskExpansion: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
const task = state.entities[taskId];
|
||||
const task = state.entities[action.payload];
|
||||
if (task) {
|
||||
task.show_sub_tasks = !task.show_sub_tasks;
|
||||
}
|
||||
},
|
||||
addSubtaskToParent: (state, action: PayloadAction<{ subtask: Task; parentTaskId: string }>) => {
|
||||
const { subtask, parentTaskId } = action.payload;
|
||||
const parentTask = state.entities[parentTaskId];
|
||||
if (parentTask) {
|
||||
if (!parentTask.sub_tasks) {
|
||||
parentTask.sub_tasks = [];
|
||||
addSubtaskToParent: (
|
||||
state,
|
||||
action: PayloadAction<{ parentId: string; subtask: Task }>
|
||||
) => {
|
||||
const { parentId, subtask } = action.payload;
|
||||
const parent = state.entities[parentId];
|
||||
if (parent) {
|
||||
state.ids.push(subtask.id);
|
||||
state.entities[subtask.id] = subtask;
|
||||
if (!parent.sub_tasks) {
|
||||
parent.sub_tasks = [];
|
||||
}
|
||||
parentTask.sub_tasks.push(subtask);
|
||||
parentTask.sub_tasks_count = (parentTask.sub_tasks_count || 0) + 1;
|
||||
// Ensure the parent task is expanded to show the new subtask
|
||||
parentTask.show_sub_tasks = true;
|
||||
// Add the subtask to the main entities as well
|
||||
tasksAdapter.addOne(state, subtask);
|
||||
parent.sub_tasks.push(subtask);
|
||||
parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchTasks.pending, state => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchTasks.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
tasksAdapter.setAll(state, action.payload);
|
||||
})
|
||||
.addCase(fetchTasks.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = (action.payload as string) || 'Failed to fetch tasks';
|
||||
})
|
||||
.addCase(fetchTasksV3.pending, state => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
@@ -671,39 +618,68 @@ const taskManagementSlice = createSlice({
|
||||
.addCase(fetchTasksV3.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
// Tasks are already processed by backend, minimal setup needed
|
||||
tasksAdapter.setAll(state, action.payload.tasks);
|
||||
state.groups = action.payload.groups;
|
||||
state.grouping = action.payload.grouping;
|
||||
|
||||
// Ensure we have tasks before updating state
|
||||
if (action.payload.tasks && action.payload.tasks.length > 0) {
|
||||
// Update tasks
|
||||
const tasks = action.payload.tasks;
|
||||
state.ids = tasks.map(task => task.id);
|
||||
state.entities = tasks.reduce((acc, task) => {
|
||||
acc[task.id] = task;
|
||||
return acc;
|
||||
}, {} as Record<string, Task>);
|
||||
|
||||
// Update groups
|
||||
state.groups = action.payload.groups;
|
||||
state.grouping = action.payload.grouping;
|
||||
|
||||
// Verify task IDs match group taskIds
|
||||
const taskIds = new Set(Object.keys(state.entities));
|
||||
const groupTaskIds = new Set(state.groups.flatMap(g => g.taskIds));
|
||||
|
||||
// Ensure all tasks have IDs and all group taskIds exist
|
||||
const validTaskIds = new Set(Object.keys(state.entities));
|
||||
state.groups = state.groups.map((group: TaskGroup) => ({
|
||||
...group,
|
||||
taskIds: group.taskIds.filter((id: string) => validTaskIds.has(id)),
|
||||
}));
|
||||
} else {
|
||||
// Set empty state but don't show error
|
||||
state.ids = [];
|
||||
state.entities = {} as Record<string, Task>;
|
||||
state.groups = [];
|
||||
}
|
||||
})
|
||||
.addCase(fetchTasksV3.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = (action.payload as string) || 'Failed to fetch tasks';
|
||||
// Provide a more descriptive error message
|
||||
state.error = action.error.message || action.payload || 'An error occurred while fetching tasks. Please try again.';
|
||||
// Clear task data on error to prevent stale state
|
||||
state.ids = [];
|
||||
state.entities = {} as Record<string, Task>;
|
||||
state.groups = [];
|
||||
})
|
||||
.addCase(fetchSubTasks.pending, (state, action) => {
|
||||
// Don't set global loading state for subtasks
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||
const { parentTaskId, subtasks } = action.payload;
|
||||
const parentTask = state.entities[parentTaskId];
|
||||
if (parentTask) {
|
||||
parentTask.sub_tasks = subtasks;
|
||||
parentTask.sub_tasks_count = subtasks.length;
|
||||
parentTask.show_sub_tasks = true;
|
||||
// Add subtasks to the main entities as well
|
||||
tasksAdapter.addMany(state, subtasks);
|
||||
}
|
||||
})
|
||||
.addCase(refreshTaskProgress.pending, state => {
|
||||
// Don't set loading to true for refresh to avoid UI blocking
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(refreshTaskProgress.fulfilled, state => {
|
||||
state.error = null;
|
||||
// Progress refresh completed successfully
|
||||
})
|
||||
.addCase(refreshTaskProgress.rejected, (state, action) => {
|
||||
state.error = (action.payload as string) || 'Failed to refresh task progress';
|
||||
.addCase(fetchSubTasks.rejected, (state, action) => {
|
||||
// Set error but don't clear task data
|
||||
state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Export the slice reducer and actions
|
||||
export const {
|
||||
setTasks,
|
||||
addTask,
|
||||
@@ -726,25 +702,30 @@ export const {
|
||||
addSubtaskToParent,
|
||||
} = taskManagementSlice.actions;
|
||||
|
||||
export default taskManagementSlice.reducer;
|
||||
// Export the selectors
|
||||
export const selectAllTasks = (state: RootState) => state.taskManagement.entities;
|
||||
export const selectAllTasksArray = (state: RootState) => Object.values(state.taskManagement.entities);
|
||||
export const selectTaskById = (state: RootState, taskId: string) => state.taskManagement.entities[taskId];
|
||||
export const selectTaskIds = (state: RootState) => state.taskManagement.ids;
|
||||
export const selectGroups = (state: RootState) => state.taskManagement.groups;
|
||||
export const selectGrouping = (state: RootState) => state.taskManagement.grouping;
|
||||
export const selectLoading = (state: RootState) => state.taskManagement.loading;
|
||||
export const selectError = (state: RootState) => state.taskManagement.error;
|
||||
export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities;
|
||||
export const selectSearch = (state: RootState) => state.taskManagement.search;
|
||||
|
||||
// Selectors
|
||||
export const taskManagementSelectors = tasksAdapter.getSelectors<RootState>(
|
||||
state => state.taskManagement
|
||||
);
|
||||
|
||||
// Enhanced selectors for better performance
|
||||
// Memoized selectors
|
||||
export const selectTasksByStatus = (state: RootState, status: string) =>
|
||||
taskManagementSelectors.selectAll(state).filter(task => task.status === status);
|
||||
Object.values(state.taskManagement.entities).filter(task => task.status === status);
|
||||
|
||||
export const selectTasksByPriority = (state: RootState, priority: string) =>
|
||||
taskManagementSelectors.selectAll(state).filter(task => task.priority === priority);
|
||||
Object.values(state.taskManagement.entities).filter(task => task.priority === priority);
|
||||
|
||||
export const selectTasksByPhase = (state: RootState, phase: string) =>
|
||||
taskManagementSelectors.selectAll(state).filter(task => task.phase === phase);
|
||||
Object.values(state.taskManagement.entities).filter(task => task.phase === phase);
|
||||
|
||||
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
|
||||
export const selectTasksError = (state: RootState) => state.taskManagement.error;
|
||||
// Export the reducer as default
|
||||
export default taskManagementSlice.reducer;
|
||||
|
||||
// V3 API selectors - no processing needed, data is pre-processed by backend
|
||||
export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InlineSuspenseFallback } from '@/components/suspense-fallback/suspense-
|
||||
// Import core components synchronously to avoid suspense in main tabs
|
||||
import ProjectViewEnhancedTasks from '@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks';
|
||||
import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board';
|
||||
import TaskListV2 from '@/components/task-list-v2/TaskListV2';
|
||||
|
||||
// Lazy load less critical components
|
||||
const ProjectViewInsights = React.lazy(
|
||||
@@ -35,7 +36,7 @@ export const tabItems: TabItems[] = [
|
||||
key: 'tasks-list',
|
||||
label: 'Task List',
|
||||
isPinned: true,
|
||||
element: React.createElement(ProjectViewEnhancedTasks),
|
||||
element: React.createElement(TaskListV2),
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import OverviewReports from '@/pages/reporting/overview-reports/overview-reports';
|
||||
import ProjectsReports from '@/pages/reporting/projects-reports/projects-reports';
|
||||
import MembersReports from '@/pages/reporting/members-reports/members-reports';
|
||||
import OverviewTimeReports from '@/pages/reporting/timeReports/overview-time-reports';
|
||||
import ProjectsTimeReports from '@/pages/reporting/timeReports/projects-time-reports';
|
||||
import MembersTimeReports from '@/pages/reporting/timeReports/members-time-reports';
|
||||
import EstimatedVsActualTimeReports from '@/pages/reporting/timeReports/estimated-vs-actual-time-reports';
|
||||
import React, { ReactNode, lazy } from 'react';
|
||||
const OverviewReports = lazy(() => import('@/pages/reporting/overview-reports/overview-reports'));
|
||||
const ProjectsReports = lazy(() => import('@/pages/reporting/projects-reports/projects-reports'));
|
||||
const MembersReports = lazy(() => import('@/pages/reporting/members-reports/members-reports'));
|
||||
const OverviewTimeReports = lazy(() => import('@/pages/reporting/timeReports/overview-time-reports'));
|
||||
const ProjectsTimeReports = lazy(() => import('@/pages/reporting/timeReports/projects-time-reports'));
|
||||
const MembersTimeReports = lazy(() => import('@/pages/reporting/timeReports/members-time-reports'));
|
||||
const EstimatedVsActualTimeReports = lazy(() => import('@/pages/reporting/timeReports/estimated-vs-actual-time-reports'));
|
||||
|
||||
// Type definition for a menu item
|
||||
export type ReportingMenuItems = {
|
||||
|
||||
@@ -13,20 +13,20 @@ import {
|
||||
UserSwitchOutlined,
|
||||
BulbOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import React, { ReactNode } from 'react';
|
||||
import ProfileSettings from '../../pages/settings/profile/profile-settings';
|
||||
import NotificationsSettings from '../../pages/settings/notifications/notifications-settings';
|
||||
import ClientsSettings from '../../pages/settings/clients/clients-settings';
|
||||
import JobTitlesSettings from '@/pages/settings/job-titles/job-titles-settings';
|
||||
import LabelsSettings from '../../pages/settings/labels/labels-settings';
|
||||
import CategoriesSettings from '../../pages/settings/categories/categories-settings';
|
||||
import ProjectTemplatesSettings from '@/pages/settings/project-templates/project-templates-settings';
|
||||
import TaskTemplatesSettings from '@/pages/settings/task-templates/task-templates-settings';
|
||||
import TeamMembersSettings from '@/pages/settings/team-members/team-members-settings';
|
||||
import TeamsSettings from '../../pages/settings/teams/teams-settings';
|
||||
import ChangePassword from '@/pages/settings/change-password/change-password';
|
||||
import LanguageAndRegionSettings from '@/pages/settings/language-and-region/language-and-region-settings';
|
||||
import AppearanceSettings from '@/pages/settings/appearance/appearance-settings';
|
||||
import React, { ReactNode, lazy } from 'react';
|
||||
const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-settings'));
|
||||
const NotificationsSettings = lazy(() => import('../../pages/settings/notifications/notifications-settings'));
|
||||
const ClientsSettings = lazy(() => import('../../pages/settings/clients/clients-settings'));
|
||||
const JobTitlesSettings = lazy(() => import('@/pages/settings/job-titles/job-titles-settings'));
|
||||
const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
|
||||
const CategoriesSettings = lazy(() => import('../../pages/settings/categories/categories-settings'));
|
||||
const ProjectTemplatesSettings = lazy(() => import('@/pages/settings/project-templates/project-templates-settings'));
|
||||
const TaskTemplatesSettings = lazy(() => import('@/pages/settings/task-templates/task-templates-settings'));
|
||||
const TeamMembersSettings = lazy(() => import('@/pages/settings/team-members/team-members-settings'));
|
||||
const TeamsSettings = lazy(() => import('../../pages/settings/teams/teams-settings'));
|
||||
const ChangePassword = lazy(() => import('@/pages/settings/change-password/change-password'));
|
||||
const LanguageAndRegionSettings = lazy(() => import('@/pages/settings/language-and-region/language-and-region-settings'));
|
||||
const AppearanceSettings = lazy(() => import('@/pages/settings/appearance/appearance-settings'));
|
||||
|
||||
// type of menu item in settings sidebar
|
||||
type SettingMenuItems = {
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import React, { ReactNode } from 'react';
|
||||
import Overview from './overview/overview';
|
||||
import Users from './users/users';
|
||||
import Teams from './teams/teams';
|
||||
import Billing from './billing/billing';
|
||||
import Projects from './projects/projects';
|
||||
import React, { ReactNode, lazy } from 'react';
|
||||
const Overview = lazy(() => import('./overview/overview'));
|
||||
const Users = lazy(() => import('./users/users'));
|
||||
const Teams = lazy(() => import('./teams/teams'));
|
||||
const Billing = lazy(() => import('./billing/billing'));
|
||||
const Projects = lazy(() => import('./projects/projects'));
|
||||
|
||||
// type of a menu item in admin center sidebar
|
||||
type AdminCenterMenuItems = {
|
||||
|
||||
@@ -119,7 +119,7 @@ const ProjectView = React.memo(() => {
|
||||
return () => {
|
||||
resetAllProjectData();
|
||||
};
|
||||
}, []); // Empty dependency array - only runs on mount/unmount
|
||||
}, [resetAllProjectData]);
|
||||
|
||||
// Effect for handling route changes (when navigating away from project view)
|
||||
useEffect(() => {
|
||||
@@ -358,10 +358,10 @@ const ProjectView = React.memo(() => {
|
||||
minHeight: '36px',
|
||||
}}
|
||||
tabBarGutter={0}
|
||||
destroyInactiveTabPane={true} // Destroy inactive tabs to save memory
|
||||
destroyInactiveTabPane={true}
|
||||
animated={{
|
||||
inkBar: true,
|
||||
tabPane: false, // Disable content animation for better performance
|
||||
tabPane: false,
|
||||
}}
|
||||
size="small"
|
||||
type="card"
|
||||
|
||||
@@ -20,7 +20,7 @@ import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/tas
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { fetchSubTasks } from '@/features/tasks/tasks.slice';
|
||||
import { fetchSubTasks } from '@/features/task-management/task-management.slice';
|
||||
|
||||
type TaskListTaskCellProps = {
|
||||
task: IProjectTask;
|
||||
|
||||
@@ -266,6 +266,7 @@ const TaskListTableWrapper = ({
|
||||
tableId={tableId}
|
||||
activeId={activeId}
|
||||
groupBy={groupBy}
|
||||
isOver={isOver}
|
||||
/>
|
||||
</Collapsible>
|
||||
</Flex>
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { DragOverEvent } from '@dnd-kit/core';
|
||||
import { List, Card, Avatar, Dropdown, Empty, Divider, Button } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -90,6 +90,7 @@ interface TaskListTableProps {
|
||||
tableId: string;
|
||||
activeId?: string | null;
|
||||
groupBy?: string;
|
||||
isOver?: boolean; // Add this line
|
||||
}
|
||||
|
||||
interface DraggableRowProps {
|
||||
@@ -1291,6 +1292,7 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
|
||||
// Add drag state
|
||||
const [dragActiveId, setDragActiveId] = useState<string | null>(null);
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState<number | null>(null);
|
||||
|
||||
// Configure sensors for drag and drop
|
||||
const sensors = useSensors(
|
||||
@@ -1640,6 +1642,7 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setDragActiveId(null);
|
||||
setPlaceholderIndex(null); // Reset placeholder index
|
||||
|
||||
if (!over || !active || active.id === over.id) {
|
||||
return;
|
||||
@@ -1794,6 +1797,7 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver} // Add this line
|
||||
autoScroll={false} // Disable auto-scroll animations
|
||||
>
|
||||
<SortableContext
|
||||
@@ -1858,12 +1862,22 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
{displayTasks && displayTasks.length > 0 ? (
|
||||
displayTasks
|
||||
.filter(task => task?.id) // Filter out tasks without valid IDs
|
||||
.map(task => {
|
||||
.map((task, index) => {
|
||||
const updatedTask = findTaskInGroups(task.id || '') || task;
|
||||
const isDraggingCurrent = dragActiveId === updatedTask.id;
|
||||
|
||||
return (
|
||||
<React.Fragment key={updatedTask.id}>
|
||||
{renderTaskRow(updatedTask)}
|
||||
{placeholderIndex === index && (
|
||||
<tr className="placeholder-row">
|
||||
<td colSpan={visibleColumns.length + 2}>
|
||||
<div className="h-10 border-2 border-dashed border-blue-400 rounded-md flex items-center justify-center text-blue-500">
|
||||
Drop task here
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isDraggingCurrent && renderTaskRow(updatedTask)}
|
||||
{updatedTask.show_sub_tasks && (
|
||||
<>
|
||||
{updatedTask?.sub_tasks?.map(subtask =>
|
||||
@@ -1910,6 +1924,15 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{placeholderIndex === displayTasks.length && (
|
||||
<tr className="placeholder-row">
|
||||
<td colSpan={visibleColumns.length + 2}>
|
||||
<div className="h-10 border-2 border-dashed border-blue-400 rounded-md flex items-center justify-center text-blue-500">
|
||||
Drop task here
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
28
worklenz-frontend/src/types/task-list-field.types.ts
Normal file
28
worklenz-frontend/src/types/task-list-field.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface TaskListField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox';
|
||||
isVisible: boolean;
|
||||
order: number;
|
||||
width?: number;
|
||||
options?: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TaskListFieldGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
fields: TaskListField[];
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface TaskListFieldState {
|
||||
fields: TaskListField[];
|
||||
groups: TaskListFieldGroup[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -1,43 +1,57 @@
|
||||
import { InlineMember } from './teamMembers/inlineMember.types';
|
||||
import { EntityState } from '@reduxjs/toolkit';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
task_key: string;
|
||||
title: string;
|
||||
title?: string; // Make title optional since it can be empty from database
|
||||
name?: string; // Alternative name field
|
||||
task_key?: string; // Task key field
|
||||
description?: string;
|
||||
status: 'todo' | 'doing' | 'done';
|
||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||
phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
|
||||
progress: number; // 0-100
|
||||
assignees: string[];
|
||||
assignee_names?: InlineMember[];
|
||||
labels: Label[];
|
||||
startDate?: string; // Start date for the task
|
||||
dueDate?: string; // Due date for the task
|
||||
completedAt?: string; // When the task was completed
|
||||
reporter?: string; // Who reported/created the task
|
||||
timeTracking: {
|
||||
estimated?: number;
|
||||
logged: number;
|
||||
};
|
||||
customFields: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
order: number;
|
||||
// Subtask-related properties
|
||||
status: string;
|
||||
priority: string;
|
||||
phase?: string;
|
||||
assignee?: string;
|
||||
assignee_names?: InlineMember[]; // Array of assigned members
|
||||
names?: InlineMember[]; // Alternative names field
|
||||
due_date?: string;
|
||||
dueDate?: string; // Alternative due date field
|
||||
startDate?: string; // Start date field
|
||||
completedAt?: string; // Completion date
|
||||
updatedAt?: string; // Update timestamp
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sub_tasks?: Task[];
|
||||
sub_tasks_count?: number;
|
||||
show_sub_tasks?: boolean;
|
||||
sub_tasks?: Task[];
|
||||
parent_task_id?: string;
|
||||
progress?: number;
|
||||
weight?: number;
|
||||
color?: string;
|
||||
statusColor?: string;
|
||||
priorityColor?: string;
|
||||
labels?: { id: string; name: string; color: string }[];
|
||||
comments_count?: number;
|
||||
attachments_count?: number;
|
||||
has_dependencies?: boolean;
|
||||
schedule_id?: string | null;
|
||||
order?: number;
|
||||
reporter?: string; // Reporter field
|
||||
timeTracking?: { // Time tracking information
|
||||
logged?: number;
|
||||
estimated?: number;
|
||||
};
|
||||
// Add any other task properties as needed
|
||||
}
|
||||
|
||||
export interface TaskGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
groupType: 'status' | 'priority' | 'phase';
|
||||
groupValue: string; // The actual value for the group (e.g., 'todo', 'high', 'development')
|
||||
collapsed: boolean;
|
||||
taskIds: string[];
|
||||
color?: string; // For visual distinction
|
||||
type?: 'status' | 'priority' | 'phase' | 'members';
|
||||
color?: string;
|
||||
collapsed?: boolean;
|
||||
groupValue?: string;
|
||||
// Add any other group properties as needed
|
||||
}
|
||||
|
||||
export interface GroupingConfig {
|
||||
@@ -73,14 +87,14 @@ export interface Label {
|
||||
|
||||
// Redux State Interfaces
|
||||
export interface TaskManagementState {
|
||||
entities: Record<string, Task>;
|
||||
ids: string[];
|
||||
entities: Record<string, Task>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
groups: TaskGroup[]; // Pre-processed groups from V3 API
|
||||
grouping: string | null; // Current grouping from V3 API
|
||||
selectedPriorities: string[]; // Selected priority filters
|
||||
search: string; // Search query for filtering tasks
|
||||
groups: TaskGroup[];
|
||||
grouping: string | undefined;
|
||||
selectedPriorities: string[];
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface TaskGroupsState {
|
||||
@@ -89,15 +103,20 @@ export interface TaskGroupsState {
|
||||
}
|
||||
|
||||
export interface GroupingState {
|
||||
currentGrouping: 'status' | 'priority' | 'phase';
|
||||
customPhases: string[];
|
||||
groupOrder: Record<string, string[]>;
|
||||
groupStates: Record<string, { collapsed: boolean }>; // Persist group states
|
||||
currentGrouping: TaskGrouping | null;
|
||||
collapsedGroups: Set<string>;
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
export interface TaskGrouping {
|
||||
id: string;
|
||||
name: string;
|
||||
field: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskSelection {
|
||||
selectedTaskIds: string[];
|
||||
lastSelectedId: string | null;
|
||||
lastSelectedTaskId: string | null;
|
||||
}
|
||||
|
||||
export interface ColumnsState {
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
export const tagBackground = (color: string): string => {
|
||||
return `${color}1A`; // 1A is 10% opacity in hex
|
||||
};
|
||||
|
||||
export const getContrastColor = (hexcolor: string): string => {
|
||||
// If a color is not a valid hex, default to a sensible contrast
|
||||
if (!/^#([A-Fa-f0-9]{3}){1,2}$/.test(hexcolor)) {
|
||||
return '#000000'; // Default to black for invalid colors
|
||||
}
|
||||
|
||||
const r = parseInt(hexcolor.slice(1, 3), 16);
|
||||
const g = parseInt(hexcolor.slice(3, 5), 16);
|
||||
const b = parseInt(hexcolor.slice(5, 7), 16);
|
||||
|
||||
// Perceptual luminance calculation (from WCAG 2.0)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
// Use a threshold to decide between black and white text
|
||||
return luminance > 0.5 ? '#000000' : '#FFFFFF';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user