diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index b208dc5a..5e5154f3 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -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" diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 969f7461..b83e80fd 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -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", diff --git a/worklenz-frontend/src/app/routes/account-setup-routes.tsx b/worklenz-frontend/src/app/routes/account-setup-routes.tsx index b7f2fcaf..6df71a4d 100644 --- a/worklenz-frontend/src/app/routes/account-setup-routes.tsx +++ b/worklenz-frontend/src/app/routes/account-setup-routes.tsx @@ -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: , + element: ( + }> + + + ), }; export default accountSetupRoute; diff --git a/worklenz-frontend/src/app/routes/admin-center-routes.tsx b/worklenz-frontend/src/app/routes/admin-center-routes.tsx index 2e670a7f..67ad5b26 100644 --- a/worklenz-frontend/src/app/routes/admin-center-routes.tsx +++ b/worklenz-frontend/src/app/routes/admin-center-routes.tsx @@ -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: ( + }> + {item.element} + + ), })), }, ]; diff --git a/worklenz-frontend/src/app/routes/reporting-routes.tsx b/worklenz-frontend/src/app/routes/reporting-routes.tsx index 01f83e9b..2e62de18 100644 --- a/worklenz-frontend/src/app/routes/reporting-routes.tsx +++ b/worklenz-frontend/src/app/routes/reporting-routes.tsx @@ -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: , children: flattenedItems.map(item => ({ path: item.endpoint, - element: item.element, + element: ( + }> + {item.element} + + ), })), }, ]; diff --git a/worklenz-frontend/src/app/routes/settings-routes.tsx b/worklenz-frontend/src/app/routes/settings-routes.tsx index 9999841b..78f59f88 100644 --- a/worklenz-frontend/src/app/routes/settings-routes.tsx +++ b/worklenz-frontend/src/app/routes/settings-routes.tsx @@ -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: , children: settingsItems.map(item => ({ path: item.endpoint, - element: {item.element}, + element: ( + }> + {item.element} + + ), })), }, ]; diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 262f654b..63c738a0 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -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; + export type AppDispatch = typeof store.dispatch; + +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx new file mode 100644 index 00000000..4048996d --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -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 = ({ 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 ( +
+ {/* Chevron button */} + + + {/* Group indicator and name */} +
+ {/* Color indicator (removed as full header is colored) */} + + {/* Group name and count */} +
+ + {group.name} + + + {group.count} + +
+
+
+ ); +}; + +export default TaskGroupHeader; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx new file mode 100644 index 00000000..43b3ed3e --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -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 = ({ projectId }) => { + const dispatch = useAppDispatch(); + const { projectId: urlProjectId } = useParams(); + + // Drag and drop state + const [activeId, setActiveId] = useState(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(() => ( +
+ {visibleColumns.map((column) => { + const columnStyle: ColumnStyle = { + width: column.width, + }; + + return ( +
+ {column.id === 'dragHandle' ? ( + + ) : ( + column.label + )} +
+ ); + })} +
+ ), [visibleColumns]); + + // Render functions + const renderGroup = useCallback((groupIndex: number) => { + const group = virtuosoGroups[groupIndex]; + const isGroupEmpty = group.count === 0; + + return ( +
+ handleGroupCollapse(group.id)} + /> + {/* Empty group drop zone */} + {isGroupEmpty && !collapsedGroups.has(group.id) && ( +
+
Drop tasks here
+
+ )} +
+ ); + }, [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 ( + + ); + }, [virtuosoItems, visibleColumns]); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( + +
+ {/* Task Filters */} +
+ +
+ + {/* Column Headers */} +
+
+ {columnHeaders} +
+ + {/* Task List */} +
+ task.id).filter((id): id is string => id !== undefined)} + strategy={verticalListSortingStrategy} + > + (({ style, children }, ref) => ( +
+ {children} +
+ )), + }} + /> +
+
+
+ + {/* Drag Overlay */} + + {activeId ? ( +
+
+
+ +
+
+ {allTasks.find(task => task.id === activeId)?.name || + allTasks.find(task => task.id === activeId)?.title || + 'Task'} +
+
+ {allTasks.find(task => task.id === activeId)?.task_key} +
+
+
+
+
+ ) : null} +
+
+
+ ); +}; + +export default TaskListV2; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx new file mode 100644 index 00000000..ef84c6cc --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -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 = 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 ( +
+ +
+ ); + + case 'taskKey': + return ( +
+ + {task.task_key || 'N/A'} + +
+ ); + + case 'title': + return ( +
+ + {taskDisplayName} + +
+ ); + + case 'status': + return ( +
+ + {task.status} + +
+ ); + + case 'assignees': + return ( +
+ + +
+ ); + + case 'priority': + return ( +
+ + {task.priority} + +
+ ); + + case 'dueDate': + return ( +
+ {formattedDueDate && ( + + {formattedDueDate} + + )} +
+ ); + + case 'progress': + return ( +
+ {task.progress !== undefined && + task.progress >= 0 && + (task.progress === 100 ? ( +
+ +
+ ) : ( + + ))} +
+ ); + + case 'labels': + return ( +
+ {labelsDisplay?.visibleLabels.map((label, index) => ( + + {label.name} + + ))} + {labelsDisplay?.remainingCount && ( + + +{labelsDisplay.remainingCount} + + )} +
+ ); + + case 'phase': + return ( +
+ + {task.phase} + +
+ ); + + case 'timeTracking': + return ( +
+ + + {task.timeTracking?.logged || 0}h + + {task.timeTracking?.estimated && ( + + /{task.timeTracking.estimated}h + + )} +
+ ); + + case 'estimation': + return ( +
+ {task.timeTracking?.estimated && ( + + {task.timeTracking.estimated}h + + )} +
+ ); + + case 'startDate': + return ( +
+ {formattedStartDate && ( + + {formattedStartDate} + + )} +
+ ); + + case 'completedDate': + return ( +
+ {formattedCompletedDate && ( + + {formattedCompletedDate} + + )} +
+ ); + + case 'createdDate': + return ( +
+ {formattedCreatedDate && ( + + {formattedCreatedDate} + + )} +
+ ); + + case 'lastUpdated': + return ( +
+ {formattedUpdatedDate && ( + + {formattedUpdatedDate} + + )} +
+ ); + + case 'reporter': + return ( +
+ {task.reporter && ( + {task.reporter} + )} +
+ ); + + 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 ( +
+ {visibleColumns.map((column, index) => + renderColumn(column.id, column.width, column.isSticky, index) + )} +
+ ); +}); + +TaskRow.displayName = 'TaskRow'; + +export default TaskRow; diff --git a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css index 6f21e39c..e69de29b 100644 --- a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css +++ b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css @@ -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; -} diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index a79c0b3f..07d0b679 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -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 = 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 = 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 = React.memo(
{groupTasks.map((task, index) => ( - + + {(provided, snapshot) => ( +
+ +
+ )} +
))}
diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index ca89af5e..d4fd98cc 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -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 = ({ 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); diff --git a/worklenz-frontend/src/components/task-management/task-list-filters.tsx b/worklenz-frontend/src/components/task-management/task-list-filters.tsx new file mode 100644 index 00000000..0e5db48c --- /dev/null +++ b/worklenz-frontend/src/components/task-management/task-list-filters.tsx @@ -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 = ({ + selectedPriorities, + onPriorityChange, + searchQuery, + onSearchChange, +}) => { + const priorities = ['High', 'Medium', 'Low']; + + return ( +
+
+ +
+ {priorities.map(priority => ( + + ))} +
+
+
+ + onSearchChange(e.target.value)} + placeholder="Search tasks..." + className="search-input" + /> +
+
+ ); +}; + +export default TaskListFilters; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-list-group.tsx b/worklenz-frontend/src/components/task-management/task-list-group.tsx new file mode 100644 index 00000000..47e9dda6 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/task-list-group.tsx @@ -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 = ({ + 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 ( +
+
+
+ + {isCollapsed ? 'â–º' : 'â–¼'} + +

{group.title}

+ ({tasks.length}) +
+
+ {!isCollapsed && ( +
+ {tasks.map((task, index) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: task.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ onTaskSelect(taskId, {} as React.MouseEvent)} + index={index} + /> +
+ ); + })} +
+ )} +
+ ); +}; + +export default TaskListGroup; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-list-header.tsx b/worklenz-frontend/src/components/task-management/task-list-header.tsx new file mode 100644 index 00000000..0bbdac89 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/task-list-header.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +interface TaskListHeaderProps { + onExpandAll: () => void; + onCollapseAll: () => void; +} + +const TaskListHeader: React.FC = ({ + onExpandAll, + onCollapseAll, +}) => { + return ( +
+
+ + +
+
+ ); +}; + +export default TaskListHeader; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx index 548b3e9c..0304c3a1 100644 --- a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -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'; diff --git a/worklenz-frontend/src/features/task-management/grouping.slice.ts b/worklenz-frontend/src/features/task-management/grouping.slice.ts index 531dac24..182d0903 100644 --- a/worklenz-frontend/src/features/task-management/grouping.slice.ts +++ b/worklenz-frontend/src/features/task-management/grouping.slice.ts @@ -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; + 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) => { 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) => { 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) => { + 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 = {}; + 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; diff --git a/worklenz-frontend/src/features/task-management/selection.slice.ts b/worklenz-frontend/src/features/task-management/selection.slice.ts index 7157facc..97f69e7e 100644 --- a/worklenz-frontend/src/features/task-management/selection.slice.ts +++ b/worklenz-frontend/src/features/task-management/selection.slice.ts @@ -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) => { - 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) => { const taskId = action.payload; if (!state.selectedTaskIds.includes(taskId)) { state.selectedTaskIds.push(taskId); } - state.lastSelectedId = taskId; + state.lastSelectedTaskId = taskId; }, - deselectTask: (state, action: PayloadAction) => { 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) => { + toggleTaskSelection: (state, action: PayloadAction) => { + 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) => { 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) => { - 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) => { - 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; diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 20493847..e865903d 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -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({ - sortComparer: (a, b) => a.order - b.order, -}); +const tasksAdapter = createEntityAdapter(); +// 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) => { - 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); }, - addTask: (state, action: PayloadAction) => { - 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 }>) => { - tasksAdapter.updateOne(state, { - id: action.payload.id, - changes: { - ...action.payload.changes, - updatedAt: new Date().toISOString(), - }, + updateTask: (state, action: PayloadAction) => { + const task = action.payload; + state.entities[task.id] = task; + }, + deleteTask: (state, action: PayloadAction) => { + 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) => { + action.payload.forEach(task => { + state.entities[task.id] = task; }); }, - - deleteTask: (state, action: PayloadAction) => { - tasksAdapter.removeOne(state, action.payload); - }, - - // Bulk operations - bulkUpdateTasks: (state, action: PayloadAction<{ ids: string[]; changes: Partial }>) => { - 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) => { - 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 = { - 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; + 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 = { - 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 = { - 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) => { state.loading = action.payload; }, - setError: (state, action: PayloadAction) => { state.error = action.payload; - state.loading = false; }, - - // Filter actions setSelectedPriorities: (state, action: PayloadAction) => { state.selectedPriorities = action.payload; }, - - // Search action setSearch: (state, action: PayloadAction) => { 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) => { - 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); + + // 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; + 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; + 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( - 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; diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index 3cd6fc89..bf6348a3 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -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, diff --git a/worklenz-frontend/src/lib/reporting/reporting-constants.ts b/worklenz-frontend/src/lib/reporting/reporting-constants.ts index 25d23cbd..dfb975f9 100644 --- a/worklenz-frontend/src/lib/reporting/reporting-constants.ts +++ b/worklenz-frontend/src/lib/reporting/reporting-constants.ts @@ -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 = { diff --git a/worklenz-frontend/src/lib/settings/settings-constants.ts b/worklenz-frontend/src/lib/settings/settings-constants.ts index 7f9beb2a..8823bd7e 100644 --- a/worklenz-frontend/src/lib/settings/settings-constants.ts +++ b/worklenz-frontend/src/lib/settings/settings-constants.ts @@ -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 = { diff --git a/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts b/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts index 77161db2..1cee4eb8 100644 --- a/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts +++ b/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts @@ -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 = { diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 42dfc71e..cf42bfdb 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -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" diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx index 553843bf..293efc05 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx @@ -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; diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx index 784b4b76..bee5c22a 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx @@ -266,6 +266,7 @@ const TaskListTableWrapper = ({ tableId={tableId} activeId={activeId} groupBy={groupBy} + isOver={isOver} /> diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx index a9cec576..5f250577 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx @@ -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 = ({ taskList, tableId, active // Add drag state const [dragActiveId, setDragActiveId] = useState(null); + const [placeholderIndex, setPlaceholderIndex] = useState(null); // Configure sensors for drag and drop const sensors = useSensors( @@ -1640,6 +1642,7 @@ const TaskListTable: React.FC = ({ 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 = ({ taskList, tableId, active sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd} + onDragOver={handleDragOver} // Add this line autoScroll={false} // Disable auto-scroll animations > = ({ 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 ( - {renderTaskRow(updatedTask)} + {placeholderIndex === index && ( + + +
+ Drop task here +
+ + + )} + {!isDraggingCurrent && renderTaskRow(updatedTask)} {updatedTask.show_sub_tasks && ( <> {updatedTask?.sub_tasks?.map(subtask => @@ -1910,6 +1924,15 @@ const TaskListTable: React.FC = ({ taskList, tableId, active )} + {placeholderIndex === displayTasks.length && ( + + +
+ Drop task here +
+ + + )} diff --git a/worklenz-frontend/src/types/task-list-field.types.ts b/worklenz-frontend/src/types/task-list-field.types.ts new file mode 100644 index 00000000..35c5b24c --- /dev/null +++ b/worklenz-frontend/src/types/task-list-field.types.ts @@ -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; +} \ No newline at end of file diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 064855a8..708ebf20 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -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; - 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; ids: string[]; + entities: Record; 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; - groupStates: Record; // Persist group states + currentGrouping: TaskGrouping | null; + collapsedGroups: Set; } -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 { diff --git a/worklenz-frontend/src/utils/colorUtils.ts b/worklenz-frontend/src/utils/colorUtils.ts index b905e9dd..1d7e9c0c 100644 --- a/worklenz-frontend/src/utils/colorUtils.ts +++ b/worklenz-frontend/src/utils/colorUtils.ts @@ -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'; +};