From 1e4c673c1cc611d8fbdb76e2eedf006f9ae0ad56 Mon Sep 17 00:00:00 2001
From: Aye <117931758+ayetza@users.noreply.github.com>
Date: Tue, 14 Apr 2026 17:38:46 -0600
Subject: [PATCH 01/28] frontend sprint 2
---
.../src/main/frontend/package-lock.json | 514 +++++++++++-
.../backend/src/main/frontend/package.json | 1 +
.../backend/src/main/frontend/src/App.js | 442 +++++-----
.../src/main/frontend/src/Dashboard.js | 269 ++++++
.../backend/src/main/frontend/src/NewItem.js | 60 +-
.../backend/src/main/frontend/src/index.css | 794 +++++++++++++++---
6 files changed, 1690 insertions(+), 390 deletions(-)
create mode 100644 MtdrSpring/backend/src/main/frontend/src/Dashboard.js
diff --git a/MtdrSpring/backend/src/main/frontend/package-lock.json b/MtdrSpring/backend/src/main/frontend/package-lock.json
index 382891ec0..8ff1f4283 100644
--- a/MtdrSpring/backend/src/main/frontend/package-lock.json
+++ b/MtdrSpring/backend/src/main/frontend/package-lock.json
@@ -18,6 +18,7 @@
"react-dom": "^17.0.2",
"react-moment": "^1.1.2",
"react-scripts": "5.0.0",
+ "recharts": "^2.1.16",
"typescript": "^4.6.4"
}
},
@@ -4087,6 +4088,51 @@
"@types/node": "*"
}
},
+ "node_modules/@types/d3-color": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
+ "integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
+ "integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "^2"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.4.tgz",
+ "integrity": "sha512-jjZVLBjEX4q6xneKMmv62UocaFJFOTQSb/1aTzs3m3ICTOFoVaqGBHpNLm/4dVi0/FTltfBKgmOK1ECj3/gGjA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz",
+ "integrity": "sha512-YOpKj0kIEusRf7ofeJcSZQsvKbnTwpe1DUF+P2qsotqG53kEsjm7EzzliqQxMkAWdkZcHrg5rRhB4JiDOQPX+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "^2"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.7.tgz",
+ "integrity": "sha512-HedHlfGHdwzKqX9+PiQVXZrdmGlwo7naoefJP7kCNk4Y7qcpQt1tUaoRa6qn0kbTdlaIHGO7111qLtb/6J8uuw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "^2"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz",
+ "integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==",
+ "license": "MIT"
+ },
"node_modules/@types/eslint": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.40.2.tgz",
@@ -5803,6 +5849,12 @@
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz",
"integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="
},
+ "node_modules/classnames": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
+ "license": "MIT"
+ },
"node_modules/clean-css": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz",
@@ -6319,6 +6371,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/css-unit-converter": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
+ "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==",
+ "license": "MIT"
+ },
"node_modules/css-vendor": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz",
@@ -6496,9 +6554,86 @@
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="
},
"node_modules/csstype": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
- "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "internmap": "^1.0.0"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
+ "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-format": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz",
+ "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-interpolate": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
+ "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-color": "1 - 2"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz",
+ "integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-scale": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz",
+ "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^2.3.0",
+ "d3-format": "1 - 2",
+ "d3-interpolate": "1.2.0 - 2",
+ "d3-time": "^2.1.1",
+ "d3-time-format": "2 - 3"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz",
+ "integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-path": "1 - 2"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
+ "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "2"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
+ "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-time": "1 - 2"
+ }
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -6539,6 +6674,12 @@
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA=="
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@@ -7906,6 +8047,15 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -9112,6 +9262,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
+ "license": "ISC"
+ },
"node_modules/ipaddr.js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
@@ -14396,6 +14552,12 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
},
+ "node_modules/react-lifecycles-compat": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
+ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
+ "license": "MIT"
+ },
"node_modules/react-moment": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/react-moment/-/react-moment-1.1.3.tgz",
@@ -14414,6 +14576,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-resize-detector": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
+ "integrity": "sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-scripts": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.0.tgz",
@@ -14486,6 +14661,46 @@
}
}
},
+ "node_modules/react-smooth": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.5.tgz",
+ "integrity": "sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.0",
+ "react-transition-group": "2.9.0"
+ },
+ "peerDependencies": {
+ "prop-types": "^15.6.0",
+ "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/react-smooth/node_modules/dom-helpers": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
+ "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.1.2"
+ }
+ },
+ "node_modules/react-smooth/node_modules/react-transition-group": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
+ "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "dom-helpers": "^3.4.0",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2",
+ "react-lifecycles-compat": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react": ">=15.0.0",
+ "react-dom": ">=15.0.0"
+ }
+ },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -14533,6 +14748,51 @@
"node": ">=8.10.0"
}
},
+ "node_modules/recharts": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.1.16.tgz",
+ "integrity": "sha512-aYn1plTjYzRCo3UGxtWsduslwYd+Cuww3h/YAAEoRdGe0LRnBgYgaXSlVrNFkWOOSXrBavpmnli9h7pvRuk5wg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "^2.0.0",
+ "@types/d3-scale": "^3.0.0",
+ "@types/d3-shape": "^2.0.0",
+ "classnames": "^2.2.5",
+ "d3-interpolate": "^2.0.0",
+ "d3-scale": "^3.0.0",
+ "d3-shape": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.19",
+ "react-is": "^16.10.2",
+ "react-resize-detector": "^7.1.2",
+ "react-smooth": "^2.0.1",
+ "recharts-scale": "^0.4.4",
+ "reduce-css-calc": "^2.1.8"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "prop-types": "^15.6.0",
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/recharts/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/recursive-readdir": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
@@ -14544,6 +14804,22 @@
"node": ">=6.0.0"
}
},
+ "node_modules/reduce-css-calc": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
+ "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
+ "license": "MIT",
+ "dependencies": {
+ "css-unit-converter": "^1.1.1",
+ "postcss-value-parser": "^3.3.0"
+ }
+ },
+ "node_modules/reduce-css-calc/node_modules/postcss-value-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+ "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==",
+ "license": "MIT"
+ },
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -20029,6 +20305,45 @@
"@types/node": "*"
}
},
+ "@types/d3-color": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
+ "integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg=="
+ },
+ "@types/d3-interpolate": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
+ "integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
+ "requires": {
+ "@types/d3-color": "^2"
+ }
+ },
+ "@types/d3-path": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.4.tgz",
+ "integrity": "sha512-jjZVLBjEX4q6xneKMmv62UocaFJFOTQSb/1aTzs3m3ICTOFoVaqGBHpNLm/4dVi0/FTltfBKgmOK1ECj3/gGjA=="
+ },
+ "@types/d3-scale": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz",
+ "integrity": "sha512-YOpKj0kIEusRf7ofeJcSZQsvKbnTwpe1DUF+P2qsotqG53kEsjm7EzzliqQxMkAWdkZcHrg5rRhB4JiDOQPX+A==",
+ "requires": {
+ "@types/d3-time": "^2"
+ }
+ },
+ "@types/d3-shape": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.7.tgz",
+ "integrity": "sha512-HedHlfGHdwzKqX9+PiQVXZrdmGlwo7naoefJP7kCNk4Y7qcpQt1tUaoRa6qn0kbTdlaIHGO7111qLtb/6J8uuw==",
+ "requires": {
+ "@types/d3-path": "^2"
+ }
+ },
+ "@types/d3-time": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz",
+ "integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw=="
+ },
"@types/eslint": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.40.2.tgz",
@@ -21335,6 +21650,11 @@
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz",
"integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="
},
+ "classnames": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
+ },
"clean-css": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz",
@@ -21702,6 +22022,11 @@
}
}
},
+ "css-unit-converter": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
+ "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA=="
+ },
"css-vendor": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz",
@@ -21828,9 +22153,76 @@
}
},
"csstype": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
- "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+ },
+ "d3-array": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+ "requires": {
+ "internmap": "^1.0.0"
+ }
+ },
+ "d3-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
+ "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ=="
+ },
+ "d3-format": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz",
+ "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA=="
+ },
+ "d3-interpolate": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
+ "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
+ "requires": {
+ "d3-color": "1 - 2"
+ }
+ },
+ "d3-path": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz",
+ "integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA=="
+ },
+ "d3-scale": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz",
+ "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==",
+ "requires": {
+ "d3-array": "^2.3.0",
+ "d3-format": "1 - 2",
+ "d3-interpolate": "1.2.0 - 2",
+ "d3-time": "^2.1.1",
+ "d3-time-format": "2 - 3"
+ }
+ },
+ "d3-shape": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz",
+ "integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==",
+ "requires": {
+ "d3-path": "1 - 2"
+ }
+ },
+ "d3-time": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
+ "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
+ "requires": {
+ "d3-array": "2"
+ }
+ },
+ "d3-time-format": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
+ "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
+ "requires": {
+ "d3-time": "1 - 2"
+ }
},
"damerau-levenshtein": {
"version": "1.0.8",
@@ -21860,6 +22252,11 @@
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA=="
},
+ "decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+ },
"dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@@ -22872,6 +23269,11 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
+ "fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="
+ },
"fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -23736,6 +24138,11 @@
"side-channel": "^1.0.4"
}
},
+ "internmap": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
+ },
"ipaddr.js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
@@ -27396,6 +27803,11 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
},
+ "react-lifecycles-compat": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
+ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
+ },
"react-moment": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/react-moment/-/react-moment-1.1.3.tgz",
@@ -27407,6 +27819,14 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A=="
},
+ "react-resize-detector": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
+ "integrity": "sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw==",
+ "requires": {
+ "lodash": "^4.17.21"
+ }
+ },
"react-scripts": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.0.tgz",
@@ -27462,6 +27882,36 @@
"workbox-webpack-plugin": "^6.4.1"
}
},
+ "react-smooth": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.5.tgz",
+ "integrity": "sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==",
+ "requires": {
+ "fast-equals": "^5.0.0",
+ "react-transition-group": "2.9.0"
+ },
+ "dependencies": {
+ "dom-helpers": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
+ "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
+ "requires": {
+ "@babel/runtime": "^7.1.2"
+ }
+ },
+ "react-transition-group": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
+ "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
+ "requires": {
+ "dom-helpers": "^3.4.0",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2",
+ "react-lifecycles-compat": "^3.0.4"
+ }
+ }
+ }
+ },
"react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -27499,6 +27949,42 @@
"picomatch": "^2.2.1"
}
},
+ "recharts": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.1.16.tgz",
+ "integrity": "sha512-aYn1plTjYzRCo3UGxtWsduslwYd+Cuww3h/YAAEoRdGe0LRnBgYgaXSlVrNFkWOOSXrBavpmnli9h7pvRuk5wg==",
+ "requires": {
+ "@types/d3-interpolate": "^2.0.0",
+ "@types/d3-scale": "^3.0.0",
+ "@types/d3-shape": "^2.0.0",
+ "classnames": "^2.2.5",
+ "d3-interpolate": "^2.0.0",
+ "d3-scale": "^3.0.0",
+ "d3-shape": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.19",
+ "react-is": "^16.10.2",
+ "react-resize-detector": "^7.1.2",
+ "react-smooth": "^2.0.1",
+ "recharts-scale": "^0.4.4",
+ "reduce-css-calc": "^2.1.8"
+ },
+ "dependencies": {
+ "react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ }
+ }
+ },
+ "recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "requires": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
"recursive-readdir": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
@@ -27507,6 +27993,22 @@
"minimatch": "^3.0.5"
}
},
+ "reduce-css-calc": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
+ "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
+ "requires": {
+ "css-unit-converter": "^1.1.1",
+ "postcss-value-parser": "^3.3.0"
+ },
+ "dependencies": {
+ "postcss-value-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+ "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ=="
+ }
+ }
+ },
"regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
diff --git a/MtdrSpring/backend/src/main/frontend/package.json b/MtdrSpring/backend/src/main/frontend/package.json
index f92323cbe..1ec5bfd76 100644
--- a/MtdrSpring/backend/src/main/frontend/package.json
+++ b/MtdrSpring/backend/src/main/frontend/package.json
@@ -13,6 +13,7 @@
"react-dom": "^17.0.2",
"react-moment": "^1.1.2",
"react-scripts": "5.0.0",
+ "recharts": "^2.1.16",
"typescript": "^4.6.4"
},
"scripts": {
diff --git a/MtdrSpring/backend/src/main/frontend/src/App.js b/MtdrSpring/backend/src/main/frontend/src/App.js
index 21462dd91..6bcc9f457 100644
--- a/MtdrSpring/backend/src/main/frontend/src/App.js
+++ b/MtdrSpring/backend/src/main/frontend/src/App.js
@@ -1,240 +1,250 @@
- /*
-## MyToDoReact version 1.0.
-##
-## Copyright (c) 2022 Oracle, Inc.
-## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
-*/
-/*
- * This is the application main React component. We're using "function"
- * components in this application. No "class" components should be used for
- * consistency.
- * @author jean.de.lavarene@oracle.com
- */
import React, { useState, useEffect } from 'react';
import NewItem from './NewItem';
+import Dashboard from './Dashboard';
import API_LIST from './API';
import DeleteIcon from '@mui/icons-material/Delete';
-import { Button, TableBody, CircularProgress } from '@mui/material';
+import TaskAltIcon from '@mui/icons-material/TaskAlt';
+import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
+import BarChartIcon from '@mui/icons-material/BarChart';
+import { CircularProgress } from '@mui/material';
import Moment from 'react-moment';
-/* In this application we're using Function Components with the State Hooks
- * to manage the states. See the doc: https://reactjs.org/docs/hooks-state.html
- * This App component represents the entire app. It renders a NewItem component
- * and two tables: one that lists the todo items that are to be done and another
- * one with the items that are already done.
- */
+const CARD_COLORS = ['#7C3AED', '#F59E0B', '#14B8A6', '#EC4899', '#3B82F6', '#EF4444'];
+
function App() {
- // isLoading is true while waiting for the backend to return the list
- // of items. We use this state to display a spinning circle:
- const [isLoading, setLoading] = useState(false);
- // Similar to isLoading, isInserting is true while waiting for the backend
- // to insert a new item:
- const [isInserting, setInserting] = useState(false);
- // The list of todo items is stored in this state. It includes the "done"
- // "not-done" items:
- const [items, setItems] = useState([]);
- // In case of an error during the API call:
- const [error, setError] = useState();
-
- function deleteItem(deleteId) {
- // console.log("deleteItem("+deleteId+")")
- fetch(API_LIST+"/"+deleteId, {
- method: 'DELETE',
+ const [activeTab, setActiveTab] = useState('tasks');
+ const [isLoading] = useState(false);
+ const [isInserting, setInserting] = useState(false);
+ const [items, setItems] = useState([]);
+ const [, setError] = useState();
+
+ function deleteItem(deleteId) {
+ fetch(API_LIST + "/" + deleteId, { method: 'DELETE' })
+ .then(response => {
+ if (response.ok) return response;
+ throw new Error('Something went wrong ...');
})
+ .then(
+ () => { setItems(prev => prev.filter(item => item.id !== deleteId)); },
+ (err) => { setError(err); }
+ );
+ }
+
+ function toggleDone(event, id, description, done) {
+ event.preventDefault();
+ modifyItem(id, description, done).then(
+ () => { reloadOneItem(id); },
+ (err) => { setError(err); }
+ );
+ }
+
+ function reloadOneItem(id) {
+ fetch(API_LIST + "/" + id)
.then(response => {
- // console.log("response=");
- // console.log(response);
- if (response.ok) {
- // console.log("deleteItem FETCH call is ok");
- return response;
- } else {
- throw new Error('Something went wrong ...');
- }
+ if (response.ok) return response.json();
+ throw new Error('Something went wrong ...');
})
.then(
(result) => {
- const remainingItems = items.filter(item => item.id !== deleteId);
- setItems(remainingItems);
+ setItems(prev => prev.map(x =>
+ x.id === id ? { ...x, description: result.description, done: result.done } : x
+ ));
},
- (error) => {
- setError(error);
- }
+ (err) => { setError(err); }
);
- }
- function toggleDone(event, id, description, done) {
- event.preventDefault();
- modifyItem(id, description, done).then(
- (result) => { reloadOneIteam(id); },
- (error) => { setError(error); }
- );
- }
- function reloadOneIteam(id){
- fetch(API_LIST+"/"+id)
- .then(response => {
- if (response.ok) {
- return response.json();
- } else {
- throw new Error('Something went wrong ...');
- }
- })
- .then(
- (result) => {
- const items2 = items.map(
- x => (x.id === id ? {
- ...x,
- 'description':result.description,
- 'done': result.done
- } : x));
- setItems(items2);
- },
- (error) => {
- setError(error);
- });
- }
- function modifyItem(id, description, done) {
- // console.log("deleteItem("+deleteId+")")
- var data = {"description": description, "done": done};
- return fetch(API_LIST+"/"+id, {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(data)
- })
+ }
+
+ function modifyItem(id, description, done) {
+ return fetch(API_LIST + "/" + id, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ description, done }),
+ }).then(response => {
+ if (response.ok) return response;
+ throw new Error('Something went wrong ...');
+ });
+ }
+
+ useEffect(() => {
+ setItems([
+ { id: 1, description: 'Design new dashboard layout', createdAt: '2026-04-14T09:00:00', done: false },
+ { id: 2, description: 'Fix login bug on mobile', createdAt: '2026-04-14T10:30:00', done: false },
+ { id: 3, description: 'Write unit tests for API', createdAt: '2026-04-13T15:00:00', done: false },
+ { id: 4, description: 'Deploy to staging environment', createdAt: '2026-04-13T11:00:00', done: true },
+ { id: 5, description: 'Review pull request #42', createdAt: '2026-04-12T08:00:00', done: true },
+ ]);
+ }, []);
+
+ function addItem(text) {
+ setInserting(true);
+ fetch(API_LIST, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ description: text }),
+ })
.then(response => {
- // console.log("response=");
- // console.log(response);
- if (response.ok) {
- // console.log("deleteItem FETCH call is ok");
- return response;
- } else {
- throw new Error('Something went wrong ...');
- }
- });
- }
- /*
- To simulate slow network, call sleep before making API calls.
- const sleep = (milliseconds) => {
- return new Promise(resolve => setTimeout(resolve, milliseconds))
- }
- */
- useEffect(() => {
- setLoading(true);
- // sleep(5000).then(() => {
- fetch(API_LIST)
- .then(response => {
- if (response.ok) {
- return response.json();
- } else {
- throw new Error('Something went wrong ...');
- }
- })
- .then(
- (result) => {
- setLoading(false);
- setItems(result);
- },
- (error) => {
- setLoading(false);
- setError(error);
- });
-
- //})
- },
- // https://en.reactjs.org/docs/faq-ajax.html
- [] // empty deps array [] means
- // this useEffect will run once
- // similar to componentDidMount()
- );
- function addItem(text){
- console.log("addItem("+text+")")
- setInserting(true);
- var data = {};
- console.log(data);
- data.description = text;
- fetch(API_LIST, {
- method: 'POST',
- // We convert the React state to JSON and send it as the POST body
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(data),
- }).then((response) => {
- // This API doens't return a JSON document
- console.log(response);
- console.log();
- console.log(response.headers.location);
- // return response.json();
- if (response.ok) {
- return response;
- } else {
- throw new Error('Something went wrong ...');
- }
- }).then(
+ if (response.ok) return response;
+ throw new Error('Something went wrong ...');
+ })
+ .then(
(result) => {
- var id = result.headers.get('location');
- var newItem = {"id": id, "description": text}
- setItems([newItem, ...items]);
+ const id = result.headers.get('location');
+ setItems(prev => [{ id, description: text }, ...prev]);
setInserting(false);
},
- (error) => {
- setInserting(false);
- setError(error);
- }
+ (err) => { setInserting(false); setError(err); }
);
- }
- return (
-
-
MY TODO LIST
-
- { error &&
- Error: {error.message}
- }
- { isLoading &&
-
- }
- { !isLoading &&
-
-
-
- {items.map(item => (
- !item.done && (
-
- {item.description}
- { /*{JSON.stringify(item, null, 2) } */ }
- {item.createdAt}
- toggleDone(event, item.id, item.description, !item.done)} size="small">
- Done
-
-
- )))}
-
-
-
- Done items
-
-
-
- {items.map(item => (
- item.done && (
-
-
- {item.description}
- {item.createdAt}
- toggleDone(event, item.id, item.description, !item.done)} size="small">
- Undo
-
- } variant="contained" className="DeleteButton" onClick={() => deleteItem(item.id)} size="small">
- Delete
-
-
- )))}
-
-
+ }
+
+ const todoItems = items.filter(item => !item.done);
+ const doneItems = items.filter(item => item.done);
+ const donePercent = items.length > 0 ? Math.round((doneItems.length / items.length) * 100) : 0;
+
+ return (
+
+
+
+ {/* Left panel — header + stats */}
+
+
+
+
+
+ My Tasks
+ Stay organized, stay focused
+
+
+
+ {todoItems.length} pending
+
+
+
+ {doneItems.length} completed
+
+
+
+
+ {items.length > 0 && (
+
+
+ {doneItems.length} of {items.length} tasks completed
+ {donePercent}%
+
+
+
+ )}
+
+
+ {/* Right panel — tasks */}
+
+
+ setActiveTab('tasks')}
+ >
+
+ Tasks
+
+ setActiveTab('analytics')}
+ >
+
+ Analytics
+
+
+
+ {activeTab === 'analytics' ? (
+
+ ) : (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+
To Do
+
+ {todoItems.length}
+
+
+ {todoItems.length === 0 ? (
+ All caught up — nothing left to do!
+ ) : (
+ todoItems.map((item, i) => (
+
+
toggleDone(e, item.id, item.description, true)}
+ title="Mark as done"
+ />
+
+ {item.description}
+ {item.createdAt && (
+
+ {item.createdAt}
+
+ )}
+
+
+ ))
+ )}
+
+
+ {doneItems.length > 0 && (
+
+
+
Completed
+ {doneItems.length}
+
+ {doneItems.map((item) => (
+
+
toggleDone(e, item.id, item.description, false)}
+ title="Mark as to do"
+ />
+
+ {item.description}
+ {item.createdAt && (
+
+ {item.createdAt}
+
+ )}
+
+
+ deleteItem(item.id)}
+ title="Delete task"
+ >
+
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ )}
- }
- );
+
+ );
}
+
export default App;
diff --git a/MtdrSpring/backend/src/main/frontend/src/Dashboard.js b/MtdrSpring/backend/src/main/frontend/src/Dashboard.js
new file mode 100644
index 000000000..4cf63bc5b
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/Dashboard.js
@@ -0,0 +1,269 @@
+import React from 'react';
+import {
+ BarChart, Bar, XAxis, YAxis, CartesianGrid,
+ Tooltip, Legend, ResponsiveContainer
+} from 'recharts';
+
+// ── Mock data (swap for real API when backend is ready) ──────────────────────
+const SPRINT_DATA = [
+ { dev: 'Ana G.', s1: 5, s2: 6, s3: 4, h1: 14, h2: 16, h3: 12 },
+ { dev: 'Carlos L.', s1: 3, s2: 5, s3: 6, h1: 11, h2: 14, h3: 13 },
+ { dev: 'Maria R.', s1: 7, s2: 4, s3: 5, h1: 18, h2: 15, h3: 18 },
+ { dev: 'Jorge M.', s1: 4, s2: 7, s3: 3, h1: 10, h2: 16, h3: 9 },
+ { dev: 'Sofia C.', s1: 6, s2: 5, s3: 8, h1: 15, h2: 14, h3: 17 },
+];
+
+// ── Derived KPIs ─────────────────────────────────────────────────────────────
+const totalTasks = SPRINT_DATA.reduce((acc, d) => acc + d.s1 + d.s2 + d.s3, 0);
+const totalHours = SPRINT_DATA.reduce((acc, d) => acc + d.h1 + d.h2 + d.h3, 0);
+const avgTasksDev = (totalTasks / SPRINT_DATA.length).toFixed(1);
+const avgHoursDev = (totalHours / SPRINT_DATA.length).toFixed(1);
+
+const KPI_CARDS = [
+ { label: '# Tasks', value: totalTasks, color: '#7C3AED', bg: '#EDE9FE' },
+ { label: 'Real Hours', value: `${totalHours}h`, color: '#F59E0B', bg: '#FEF3C7' },
+ { label: 'Tasks / Dev', value: avgTasksDev, color: '#14B8A6', bg: '#CCFBF1' },
+ { label: 'Hours / Dev', value: `${avgHoursDev}h`,color: '#EC4899', bg: '#FCE7F3' },
+];
+
+// ── Auto-generated insights from data ────────────────────────────────────────
+function generateInsights() {
+ const withTotals = SPRINT_DATA.map(d => ({
+ ...d,
+ totalTasks: d.s1 + d.s2 + d.s3,
+ totalHours: d.h1 + d.h2 + d.h3,
+ efficiency: (d.s1 + d.s2 + d.s3) / (d.h1 + d.h2 + d.h3),
+ trend: d.s3 - d.s1,
+ }));
+
+ const topTasks = [...withTotals].sort((a, b) => b.totalTasks - a.totalTasks)[0];
+ const topEff = [...withTotals].sort((a, b) => b.efficiency - a.efficiency)[0];
+ const lowEff = [...withTotals].sort((a, b) => a.efficiency - b.efficiency)[0];
+ const mostImproved = [...withTotals].sort((a, b) => b.trend - a.trend)[0];
+ const declining = [...withTotals].sort((a, b) => a.trend - b.trend)[0];
+ const mostHours = [...withTotals].sort((a, b) => b.totalHours - a.totalHours)[0];
+ const leastHours = [...withTotals].sort((a, b) => a.totalHours - b.totalHours)[0];
+
+ const taskVariance = Math.max(...withTotals.map(d => d.totalTasks)) -
+ Math.min(...withTotals.map(d => d.totalTasks));
+
+ const insights = [
+ {
+ type: 'success',
+ tag: 'Top Performer',
+ title: `${topTasks.dev} leads in productivity`,
+ body: `Completed ${topTasks.totalTasks} tasks in total — the highest count on the team.`,
+ },
+ {
+ type: 'info',
+ tag: 'Efficiency',
+ title: `${topEff.dev} is the most efficient`,
+ body: `Achieves ${topEff.efficiency.toFixed(2)} tasks/hour — the best output-to-time ratio on the team.`,
+ },
+ {
+ type: 'warning',
+ tag: 'Watch',
+ title: `${lowEff.dev} has the lowest efficiency`,
+ body: `Only ${lowEff.efficiency.toFixed(2)} tasks/hour. May be facing technical blockers or handling higher-complexity work.`,
+ },
+ mostImproved.trend > 0 ? {
+ type: 'success',
+ tag: 'Positive Trend',
+ title: `${mostImproved.dev} is improving sprint over sprint`,
+ body: `Increased by ${mostImproved.trend} tasks from Sprint 1 to Sprint 3 — a strong learning curve.`,
+ } : null,
+ declining.trend < 0 ? {
+ type: 'danger',
+ tag: 'Declining Trend',
+ title: `${declining.dev} shows a drop in output`,
+ body: `Down ${Math.abs(declining.trend)} tasks from Sprint 1 to Sprint 3. Needs follow-up.`,
+ } : null,
+ taskVariance >= 4 ? {
+ type: 'warning',
+ tag: 'Imbalance',
+ title: `High variance across developers`,
+ body: `There is a ${taskVariance}-task gap between the highest and lowest contributor. Workload may not be evenly distributed.`,
+ } : null,
+ {
+ type: 'info',
+ tag: 'Workload',
+ title: `${mostHours.dev} is logging the most hours`,
+ body: `${mostHours.totalHours}h total vs ${leastHours.totalHours}h for ${leastHours.dev} — a ${mostHours.totalHours - leastHours.totalHours}h gap that may signal uneven task assignment.`,
+ },
+ ].filter(Boolean);
+
+ const actions = [
+ {
+ priority: 'High',
+ color: '#EF4444',
+ bg: '#FEF2F2',
+ text: `Set up pair programming sessions between ${topEff.dev} and ${lowEff.dev} to share best practices and unblock bottlenecks.`,
+ },
+ {
+ priority: 'High',
+ color: '#EF4444',
+ bg: '#FEF2F2',
+ text: declining.trend < 0
+ ? `Schedule a 1-on-1 with ${declining.dev} to identify what caused the drop from Sprint 1 to Sprint 3 before the next sprint begins.`
+ : `Review task distribution — ensure no developer is assigned more than 130% of the team average.`,
+ },
+ {
+ priority: 'Medium',
+ color: '#F59E0B',
+ bg: '#FEF3C7',
+ text: `Rebalance workload between ${mostHours.dev} and ${leastHours.dev} in the next sprint — the ${mostHours.totalHours - leastHours.totalHours}h difference is a burnout risk.`,
+ },
+ {
+ priority: 'Medium',
+ color: '#F59E0B',
+ bg: '#FEF3C7',
+ text: `Use ${topTasks.dev}'s estimates as a baseline reference when assigning story points to the team.`,
+ },
+ {
+ priority: 'Low',
+ color: '#14B8A6',
+ bg: '#CCFBF1',
+ text: `Publicly acknowledge ${mostImproved.dev}'s progress in the retrospective — reinforces a culture of continuous improvement.`,
+ },
+ ];
+
+ return { insights, actions };
+}
+
+// ── Tooltips ─────────────────────────────────────────────────────────────────
+const CustomTooltip = ({ active, payload, label }) => {
+ if (!active || !payload?.length) return null;
+ return (
+
+
{label}
+ {payload.map(p => (
+
{p.name}: {p.value} tasks
+ ))}
+
+ );
+};
+
+const HoursTooltip = ({ active, payload, label }) => {
+ if (!active || !payload?.length) return null;
+ return (
+
+
{label}
+ {payload.map(p => (
+
{p.name}: {p.value}h
+ ))}
+
+ );
+};
+
+const INSIGHT_STYLES = {
+ success: { border: '#22C55E', bg: '#F0FDF4', tag: '#16A34A' },
+ info: { border: '#3B82F6', bg: '#EFF6FF', tag: '#1D4ED8' },
+ warning: { border: '#F59E0B', bg: '#FFFBEB', tag: '#B45309' },
+ danger: { border: '#EF4444', bg: '#FEF2F2', tag: '#B91C1C' },
+};
+
+// ── Component ─────────────────────────────────────────────────────────────────
+function Dashboard() {
+ const { insights, actions } = generateInsights();
+
+ return (
+
+
+ {/* KPI Cards */}
+
+ {KPI_CARDS.map(card => (
+
+ {card.value}
+ {card.label}
+
+ ))}
+
+
+ {/* Chart 1 — Tasks by developer/sprint */}
+
+
+
Completed Tasks by Developer
+
Comparative analysis per sprint
+
+
+
+
+
+
+
+ } cursor={{ fill: 'rgba(124,58,237,0.04)' }} />
+
+
+
+
+
+
+
+
+
+ {/* Chart 2 — Real hours by developer/sprint */}
+
+
+
Real Hours by Developer
+
Comparative analysis per sprint
+
+
+
+
+
+
+
+ } cursor={{ fill: 'rgba(124,58,237,0.04)' }} />
+
+
+
+
+
+
+
+
+
+ {/* Insights */}
+
+
+
Insights
+
Patterns automatically detected from the data
+
+
+ {insights.map((ins, i) => {
+ const s = INSIGHT_STYLES[ins.type];
+ return (
+
+
{ins.tag}
+
{ins.title}
+
{ins.body}
+
+ );
+ })}
+
+
+
+ {/* Improvement Actions */}
+
+
+
Improvement Actions
+
Concrete recommendations for the next sprint
+
+
+ {actions.map((action, i) => (
+
+
{action.priority}
+
{action.text}
+
+ ))}
+
+
+
+
+ );
+}
+
+export default Dashboard;
diff --git a/MtdrSpring/backend/src/main/frontend/src/NewItem.js b/MtdrSpring/backend/src/main/frontend/src/NewItem.js
index c52158419..565e86411 100644
--- a/MtdrSpring/backend/src/main/frontend/src/NewItem.js
+++ b/MtdrSpring/backend/src/main/frontend/src/NewItem.js
@@ -1,64 +1,36 @@
-/*
-## MyToDoReact version 1.0.
-##
-## Copyright (c) 2022 Oracle, Inc.
-## Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
-*/
-/*
- * Component that supports creating a new todo item.
- * @author jean.de.lavarene@oracle.com
- */
-
import React, { useState } from "react";
-import Button from '@mui/material/Button';
-
function NewItem(props) {
const [item, setItem] = useState('');
+
function handleSubmit(e) {
- // console.log("NewItem.handleSubmit("+e+")");
- if (!item.trim()) {
- return;
- }
- // addItem makes the REST API call:
+ e.preventDefault();
+ if (!item.trim()) return;
props.addItem(item);
setItem("");
- e.preventDefault();
- }
- function handleChange(e) {
- setItem(e.target.value);
}
+
return (
-
My Tasks
+
Is this orange?
+
Is this orange?
Stay organized, stay focused
diff --git a/MtdrSpring/backend/src/main/frontend/src/index.css b/MtdrSpring/backend/src/main/frontend/src/index.css
index ae5ada450..fef409449 100644
--- a/MtdrSpring/backend/src/main/frontend/src/index.css
+++ b/MtdrSpring/backend/src/main/frontend/src/index.css
@@ -1,680 +1,7 @@
-@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
-/* ── Design tokens ─────────────────────────────────────────────────────────── */
-:root {
- --purple: #7C3AED;
- --purple-dark: #5B21B6;
- --purple-mid: #6D28D9;
- --purple-light: #EDE9FE;
- --purple-soft: #F5F3FF;
- --yellow: #FCD34D;
- --teal: #14B8A6;
- --green: #22C55E;
- --danger: #EF4444;
- --danger-light: #FEF2F2;
- --text-primary: #1F1D2E;
- --text-secondary: #9CA3AF;
- --text-light: #C4B5FD;
- --surface: #FFFFFF;
- --bg: #F5F3FF;
- --border: #EDE9FE;
- --shadow-card: 0 2px 12px rgba(124,58,237,0.08);
- --shadow-lg: 0 8px 32px rgba(124,58,237,0.18);
- --radius-xl: 24px;
- --radius-lg: 16px;
- --radius-md: 12px;
- --radius-sm: 8px;
- --radius-pill: 99px;
-
- /* Fluid spacing scale */
- --space-xs: clamp(6px, 1vw, 8px);
- --space-sm: clamp(10px, 2vw, 14px);
- --space-md: clamp(14px, 3vw, 20px);
- --space-lg: clamp(20px, 4vw, 32px);
- --space-xl: clamp(28px, 5vw, 48px);
-
- /* Fluid type scale */
- --text-xs: clamp(10px, 1.5vw, 11px);
- --text-sm: clamp(11px, 1.8vw, 13px);
- --text-base: clamp(13px, 2vw, 15px);
- --text-lg: clamp(16px, 2.5vw, 20px);
- --text-xl: clamp(20px, 3.5vw, 28px);
- --text-2xl: clamp(22px, 4vw, 32px);
-}
-
-/* ── Reset ─────────────────────────────────────────────────────────────────── */
-*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
-html { -webkit-text-size-adjust: 100%; }
-
-body {
- background: var(--bg);
- font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- color: var(--text-primary);
- min-height: 100vh;
- overflow-x: hidden;
-}
-
-/* ── Layout shell ───────────────────────────────────────────────────────────── */
-.app-wrapper { min-height: 100vh; }
-
-/* Default: mobile — full-width stacked */
-.layout {
- display: flex;
- flex-direction: column;
- min-height: 100vh;
-}
-
-.left-panel { flex-shrink: 0; }
-
-.right-panel {
- flex: 1;
- min-width: 0;
- padding: clamp(14px, 4vw, 20px) clamp(14px, 4vw, 16px) 60px;
-}
-
-/* Remove right-panel padding on tablet+ since layout handles it */
-@media (min-width: 640px) {
- .right-panel { padding: 0; }
-}
-
-/* ─ Tablet portrait (≥ 640px) ─ */
-@media (min-width: 640px) {
- .layout {
- max-width: 600px;
- margin: 0 auto;
- padding: var(--space-lg) var(--space-md) 60px;
- }
- .app-header {
- border-radius: var(--radius-xl) !important;
- padding: var(--space-lg) var(--space-lg) var(--space-md) !important;
- }
- .progress-wrap { margin: var(--space-sm) 0 0 !important; border-radius: var(--radius-lg); }
- .app-body { padding: var(--space-md) 0 0 !important; }
-}
-
-/* ─ Tablet landscape (≥ 768px) ─ */
-@media (min-width: 768px) {
- .layout { max-width: 720px; }
-}
-
-/* ─ Small desktop (≥ 1024px) — switch to two-column ─ */
-@media (min-width: 1024px) {
- .layout {
- flex-direction: row;
- align-items: flex-start;
- max-width: 1000px;
- padding: var(--space-xl) var(--space-lg) 80px;
- gap: var(--space-lg);
- }
- .left-panel {
- width: 290px;
- position: sticky;
- top: var(--space-xl);
- }
- .app-header {
- border-radius: var(--radius-xl) !important;
- padding: var(--space-lg) var(--space-md) var(--space-md) !important;
- }
- .progress-wrap { margin: var(--space-sm) 0 0 !important; border-radius: var(--radius-lg); }
- .app-body { padding: 0 !important; }
-}
-
-/* ─ Large desktop (≥ 1280px) ─ */
-@media (min-width: 1280px) {
- .layout {
- max-width: 1160px;
- gap: 40px;
- }
- .left-panel { width: 320px; }
-}
-
-/* ─ Extra large (≥ 1536px) ─ */
-@media (min-width: 1536px) {
- .layout { max-width: 1360px; }
- .left-panel { width: 360px; }
-}
-
-/* ── Header ─────────────────────────────────────────────────────────────────── */
-.app-header {
- background: linear-gradient(140deg, #7C3AED 0%, #5B21B6 100%);
- border-radius: 0 0 28px 28px;
- padding: clamp(40px, 8vw, 52px) clamp(20px, 5vw, 28px) clamp(24px, 5vw, 36px);
- color: white;
- position: relative;
- overflow: hidden;
-}
-
-.app-header::before {
- content: '';
- position: absolute;
- top: -40px; right: -40px;
- width: clamp(120px, 20vw, 180px);
- height: clamp(120px, 20vw, 180px);
- background: rgba(255,255,255,0.06);
- border-radius: 50%;
-}
-
-.app-header::after {
- content: '';
- position: absolute;
- bottom: -60px; right: 40px;
- width: clamp(80px, 14vw, 120px);
- height: clamp(80px, 14vw, 120px);
- background: rgba(255,255,255,0.04);
- border-radius: 50%;
-}
-
-.header-logo {
- width: clamp(44px, 7vw, 52px);
- height: clamp(44px, 7vw, 52px);
- background: rgba(255,255,255,0.18);
- border-radius: var(--radius-md);
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: var(--space-md);
- backdrop-filter: blur(8px);
- flex-shrink: 0;
-}
-
-.header-logo svg { font-size: clamp(22px, 4vw, 28px) !important; color: var(--yellow); }
-
-.app-header h1 {
- font-size: var(--text-2xl);
- font-weight: 700;
- letter-spacing: -0.5px;
- margin-bottom: 4px;
- line-height: 1.2;
-}
-
-.app-header .subtitle {
- font-size: var(--text-sm);
- font-weight: 400;
- color: var(--text-light);
- margin-bottom: var(--space-md);
-}
-
-/* ── Stats row ──────────────────────────────────────────────────────────────── */
-.stats-row { display: flex; gap: 10px; flex-wrap: wrap; }
-
-.stat-chip {
- background: rgba(255,255,255,0.14);
- border-radius: var(--radius-pill);
- padding: 5px 12px;
- font-size: var(--text-sm);
- font-weight: 600;
- color: white;
- display: flex;
- align-items: center;
- gap: 6px;
- backdrop-filter: blur(8px);
-}
-
-.stat-chip .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
-.dot-pending { background: var(--yellow); }
-.dot-done { background: var(--green); }
-
-/* ── Progress ───────────────────────────────────────────────────────────────── */
-.progress-wrap {
- margin: clamp(14px, 3vw, 20px) clamp(14px, 4vw, 16px) 0;
- background: var(--surface);
- border-radius: var(--radius-lg);
- padding: clamp(12px, 2.5vw, 16px) clamp(14px, 3vw, 20px);
- box-shadow: var(--shadow-card);
-}
-
-@media (min-width: 640px) {
- .progress-wrap { margin: clamp(14px, 3vw, 20px) 0 0; }
-}
-
-.progress-label {
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: var(--text-sm);
- font-weight: 600;
- color: var(--text-secondary);
- margin-bottom: 10px;
-}
-
-.progress-label .pct { color: var(--purple); }
-
-.progress-track {
- height: clamp(5px, 1vw, 8px);
- background: var(--border);
- border-radius: var(--radius-pill);
- overflow: hidden;
-}
-
-.progress-fill {
- height: 100%;
- background: linear-gradient(90deg, var(--purple), #A78BFA);
- border-radius: var(--radius-pill);
- transition: width 0.5s cubic-bezier(0.4,0,0.2,1);
-}
-
-/* ── App body ───────────────────────────────────────────────────────────────── */
-.app-body {
- padding: clamp(14px, 3vw, 20px) 0 0;
-}
-
-/* On tablet+ the right-panel has no padding, so app-body needs its own horizontal */
-@media (min-width: 640px) {
- .app-body {
- padding: clamp(16px, 3vw, 20px) 0 0;
- }
-}
-
-/* ── New item form ──────────────────────────────────────────────────────────── */
-.new-item-wrap {
- background: var(--surface);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-card);
- border: 1.5px solid var(--border);
- margin-bottom: clamp(16px, 3vw, 24px);
- display: flex;
- align-items: center;
- overflow: hidden;
- transition: border-color 0.2s, box-shadow 0.2s;
-}
-
-.new-item-wrap:focus-within {
- border-color: var(--purple);
- box-shadow: 0 0 0 4px rgba(124,58,237,0.1);
-}
-
-.new-item-prefix {
- padding: 0 4px 0 clamp(12px, 3vw, 18px);
- color: var(--purple);
- font-size: clamp(18px, 3vw, 22px);
- font-weight: 300;
- display: flex;
- align-items: center;
- flex-shrink: 0;
- user-select: none;
-}
-
-.new-item-input {
- flex: 1;
- min-width: 0;
- border: none;
- outline: none;
- background: transparent;
- font-family: inherit;
- font-size: var(--text-base);
- font-weight: 500;
- color: var(--text-primary);
- padding: clamp(12px, 2.5vw, 15px) 8px;
-}
-
-.new-item-input::placeholder { color: var(--text-secondary); font-weight: 400; }
-
-.add-btn {
- flex-shrink: 0;
- margin: 6px;
- padding: clamp(7px, 1.5vw, 9px) clamp(14px, 3vw, 20px);
- background: linear-gradient(135deg, var(--purple), var(--purple-mid));
- color: white;
- border: none;
- border-radius: var(--radius-md);
- font-family: inherit;
- font-size: var(--text-sm);
- font-weight: 600;
- cursor: pointer;
- transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
- white-space: nowrap;
- box-shadow: 0 4px 12px rgba(124,58,237,0.3);
- /* min touch target */
- min-height: 36px;
-}
-
-.add-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 6px 16px rgba(124,58,237,0.4); }
-.add-btn:active:not(:disabled) { transform: translateY(0); }
-.add-btn:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
-
-/* ── Task sections ──────────────────────────────────────────────────────────── */
-.tasks-section { margin-bottom: clamp(18px, 4vw, 28px); }
-
-.section-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: clamp(8px, 2vw, 12px);
-}
-
-.section-header h2 {
- font-size: var(--text-xs);
- font-weight: 700;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.8px;
-}
-
-.count-badge {
- background: var(--purple-light);
- color: var(--purple);
- font-size: var(--text-xs);
- font-weight: 700;
- padding: 2px 9px;
- border-radius: var(--radius-pill);
-}
-
-/* ── Task card ──────────────────────────────────────────────────────────────── */
-.task-card {
- background: var(--surface);
- border-radius: var(--radius-md);
- padding: clamp(10px, 2vw, 14px) clamp(12px, 2.5vw, 16px);
- margin-bottom: clamp(6px, 1.5vw, 10px);
- box-shadow: var(--shadow-card);
- display: flex;
- align-items: center;
- gap: clamp(10px, 2vw, 14px);
- border: 1.5px solid transparent;
- transition: box-shadow 0.15s, border-color 0.15s, transform 0.1s;
- position: relative;
- overflow: hidden;
-}
-
-.task-card::before {
- content: '';
- position: absolute;
- left: 0; top: 0; bottom: 0;
- width: 4px;
- background: var(--card-accent, var(--purple));
- border-radius: 4px 0 0 4px;
-}
-
-.task-card:hover { box-shadow: var(--shadow-lg); border-color: var(--border); transform: translateY(-1px); }
-.task-card:hover .task-actions { opacity: 1; }
-
-.task-card.is-done { background: #FAFAFA; box-shadow: none; border-color: var(--border); }
-.task-card.is-done::before { background: var(--green); }
-.task-card.is-done:hover { transform: none; box-shadow: var(--shadow-card); }
-
-/* On touch devices always show actions (no hover) */
-@media (hover: none) {
- .task-actions { opacity: 1; }
- .task-card:hover { transform: none; box-shadow: var(--shadow-card); }
-}
-
-/* ── Checkbox ───────────────────────────────────────────────────────────────── */
-.task-checkbox {
- flex-shrink: 0;
- width: clamp(20px, 3.5vw, 24px);
- height: clamp(20px, 3.5vw, 24px);
- border-radius: 50%;
- border: 2px solid #D1D5DB;
- background: transparent;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s;
- padding: 0;
- outline: none;
- /* min touch target */
- position: relative;
-}
-
-.task-checkbox::after {
- content: '';
- position: absolute;
- inset: -8px;
-}
-
-.task-checkbox:hover { border-color: var(--purple); background: var(--purple-light); }
-
-.task-checkbox.checked { background: var(--green); border-color: var(--green); }
-.task-checkbox.checked::after {
- content: '✓';
- position: static;
- color: white;
- font-size: clamp(10px, 1.8vw, 13px);
- font-weight: 700;
- line-height: 1;
-}
-
-/* ── Task body ──────────────────────────────────────────────────────────────── */
-.task-body { flex: 1; min-width: 0; }
-
-.task-description {
- font-size: var(--text-base);
- font-weight: 500;
- color: var(--text-primary);
- display: block;
- line-height: 1.4;
- word-break: break-word;
-}
-
-.task-card.is-done .task-description {
- color: #9CA3AF;
- text-decoration: line-through;
- font-weight: 400;
-}
-
-.task-date {
- font-size: var(--text-xs);
- color: var(--text-secondary);
- display: block;
- margin-top: 3px;
- font-weight: 400;
-}
-
-/* ── Task actions ───────────────────────────────────────────────────────────── */
-.task-actions {
- display: flex;
- align-items: center;
- gap: 4px;
- opacity: 0;
- transition: opacity 0.15s;
- flex-shrink: 0;
-}
-
-.action-btn {
- width: clamp(30px, 5vw, 34px);
- height: clamp(30px, 5vw, 34px);
- border-radius: var(--radius-sm);
- border: none;
- background: transparent;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--text-secondary);
- transition: background 0.12s, color 0.12s;
- outline: none;
-}
-
-.action-btn:hover { background: var(--border); color: var(--text-primary); }
-.action-btn.delete:hover { background: var(--danger-light); color: var(--danger); }
-
-/* ── Empty state ────────────────────────────────────────────────────────────── */
-.empty-state {
- text-align: center;
- padding: clamp(24px, 5vw, 36px) 0 clamp(20px, 4vw, 28px);
- color: var(--text-secondary);
- font-size: var(--text-base);
- font-weight: 500;
-}
-
-/* ── Tabs ───────────────────────────────────────────────────────────────────── */
-.tabs {
- display: flex;
- gap: 5px;
- margin-bottom: clamp(14px, 3vw, 20px);
- background: var(--surface);
- border-radius: var(--radius-lg);
- padding: 5px;
- box-shadow: var(--shadow-card);
-}
-
-.tab-btn {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: clamp(8px, 1.8vw, 10px) clamp(10px, 2.5vw, 16px);
- border: none;
- border-radius: var(--radius-md);
- background: transparent;
- font-family: inherit;
- font-size: var(--text-sm);
- font-weight: 600;
- color: var(--text-secondary);
- cursor: pointer;
- transition: all 0.18s;
- min-height: 40px;
-}
-
-.tab-btn svg { flex-shrink: 0; }
-
-.tab-btn:hover { background: var(--purple-soft); color: var(--purple); }
-.tab-btn.active { background: var(--purple); color: white; box-shadow: 0 4px 12px rgba(124,58,237,0.3); }
-
-/* Hide icon labels on very small screens */
-@media (max-width: 359px) {
- .tab-btn span { display: none; }
-}
-
-/* ── Dashboard ──────────────────────────────────────────────────────────────── */
-.dashboard {
- display: flex;
- flex-direction: column;
- gap: clamp(12px, 2.5vw, 16px);
-}
-
-/* KPI grid: 2 cols on mobile → 4 cols on sm+ */
-.kpi-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: clamp(8px, 2vw, 12px);
-}
-
-@media (min-width: 480px) { .kpi-grid { grid-template-columns: repeat(4, 1fr); } }
-
-.kpi-card {
- background: var(--kpi-bg);
- border-radius: var(--radius-lg);
- padding: clamp(14px, 2.5vw, 18px) clamp(12px, 2.5vw, 16px);
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.kpi-value {
- font-size: clamp(20px, 4vw, 28px);
- font-weight: 700;
- color: var(--kpi-color);
- line-height: 1;
-}
-
-.kpi-label {
- font-size: var(--text-xs);
- font-weight: 600;
- color: var(--kpi-color);
- opacity: 0.7;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-/* ── Chart card ─────────────────────────────────────────────────────────────── */
-.chart-card {
- background: var(--surface);
- border-radius: var(--radius-lg);
- padding: clamp(14px, 3vw, 20px);
- box-shadow: var(--shadow-card);
- /* prevent chart overflow on small screens */
- overflow: hidden;
-}
-
-.chart-header { margin-bottom: clamp(10px, 2vw, 16px); }
-
-.chart-header h3 {
- font-size: var(--text-base);
- font-weight: 700;
- color: var(--text-primary);
- margin-bottom: 2px;
-}
-
-.chart-header p { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; }
-
-.chart-wrap { width: 100%; overflow: hidden; }
-
-.chart-tooltip {
- background: white;
- border-radius: 12px;
- padding: 10px 14px;
- box-shadow: 0 8px 32px rgba(124,58,237,0.15);
- font-family: 'Poppins', sans-serif;
- font-size: 12px;
- border: none;
-}
-
-.tooltip-label { font-weight: 700; color: var(--text-primary); margin-bottom: 6px; font-size: 13px; }
-
-/* ── Insights ───────────────────────────────────────────────────────────────── */
-.insights-section { margin-top: 4px; }
-
-.insights-header { margin-bottom: clamp(8px, 2vw, 12px); }
-
-.insights-header h3 { font-size: var(--text-base); font-weight: 700; color: var(--text-primary); margin-bottom: 2px; }
-.insights-header p { font-size: var(--text-xs); color: var(--text-secondary); }
-
-/* 1 col mobile → 2 cols sm+ */
-.insights-grid {
- display: grid;
- grid-template-columns: 1fr;
- gap: clamp(8px, 2vw, 10px);
-}
-
-@media (min-width: 560px) { .insights-grid { grid-template-columns: repeat(2, 1fr); } }
-
-.insight-card {
- background: var(--ins-bg);
- border-left: 4px solid var(--ins-border);
- border-radius: var(--radius-md);
- padding: clamp(10px, 2vw, 14px) clamp(12px, 2.5vw, 16px);
- display: flex;
- flex-direction: column;
- gap: 5px;
-}
-
-.insight-tag { font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--ins-tag); }
-.insight-title { font-size: var(--text-sm); font-weight: 700; color: var(--text-primary); line-height: 1.3; }
-.insight-body { font-size: var(--text-xs); color: var(--text-secondary); line-height: 1.5; }
-
-/* ── Actions ────────────────────────────────────────────────────────────────── */
-.actions-list { display: flex; flex-direction: column; gap: clamp(6px, 1.5vw, 8px); }
-
-.action-item {
- background: var(--act-bg);
- border-radius: var(--radius-md);
- padding: clamp(10px, 2vw, 12px) clamp(12px, 2.5vw, 16px);
- display: flex;
- align-items: flex-start;
- gap: clamp(8px, 2vw, 12px);
-}
-
-.action-priority {
- flex-shrink: 0;
- font-size: var(--text-xs);
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- color: var(--act-color);
- background: white;
- border: 1.5px solid var(--act-color);
- border-radius: var(--radius-pill);
- padding: 2px 8px;
- margin-top: 2px;
- white-space: nowrap;
-}
-
-.action-text { font-size: var(--text-sm); color: var(--text-primary); line-height: 1.5; }
-
-/* ── Loading ────────────────────────────────────────────────────────────────── */
-.loading-wrap { display: flex; justify-content: center; padding: clamp(32px, 6vw, 48px) 0; }
+.test-tailwind {
+ @apply bg-orange-500 p-10 text-white font-bold;
+}
\ No newline at end of file
From 41d495de38a59e7c579661fad22706a7b9860428 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago
Date: Wed, 15 Apr 2026 13:51:07 -0600
Subject: [PATCH 11/28] Work item components
---
.../backend/src/main/frontend/craco.config.js | 7 +
.../src/main/frontend/package-lock.json | 16 ++
.../backend/src/main/frontend/package.json | 1 +
.../backend/src/main/frontend/src/App.js | 252 ------------------
.../backend/src/main/frontend/src/App.tsx | 45 ++++
.../work-items/components/comment-item.tsx | 0
.../work-items/components/comment-thread.tsx | 0
.../components/shared/metric-card.tsx | 28 ++
.../components/shared/person-avatar.tsx | 24 ++
.../components/shared/person-stack.tsx | 29 ++
.../components/shared/work-item-badge-row.tsx | 44 +++
.../components/work-item-activity-panel.tsx | 0
.../components/work-item-comments-panel.tsx | 0
.../components/work-item-context-card.tsx | 45 ++++
.../components/work-item-detail-header.tsx | 93 +++++++
.../components/work-item-detail-summary.tsx | 0
.../components/work-item-links-panel.tsx | 0
.../components/work-item-meta-card.tsx | 25 ++
.../components/work-item-metrics.tsx | 41 +++
.../components/work-item-progress-card.tsx | 34 +++
.../facades/work-item-detail.facade.impl.ts | 0
.../facades/work-item-detail.facade.ts | 0
.../features/work-items/lib/work-item-ui.ts | 65 +++++
.../model/work-item-detail-screen-data.ts | 0
.../model/work-item-detail-state.ts | 0
.../pages/work-item-detail-page.tsx | 0
.../pages/work-item-detail-ui-prototype.tsx | 43 +++
.../work-items/types/work-item-ui.types.ts | 31 +++
.../backend/src/main/frontend/tsconfig.json | 4 +-
29 files changed, 573 insertions(+), 254 deletions(-)
delete mode 100644 MtdrSpring/backend/src/main/frontend/src/App.js
create mode 100644 MtdrSpring/backend/src/main/frontend/src/App.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/comment-item.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/comment-thread.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/metric-card.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/person-avatar.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/person-stack.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/work-item-badge-row.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-activity-panel.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-comments-panel.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-context-card.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-detail-header.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-detail-summary.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-links-panel.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-meta-card.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-metrics.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-progress-card.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/facades/work-item-detail.facade.impl.ts
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/facades/work-item-detail.facade.ts
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/work-item-ui.ts
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/model/work-item-detail-screen-data.ts
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/model/work-item-detail-state.ts
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-detail-page.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-detail-ui-prototype.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/types/work-item-ui.types.ts
diff --git a/MtdrSpring/backend/src/main/frontend/craco.config.js b/MtdrSpring/backend/src/main/frontend/craco.config.js
index c92370e1d..34ff10e49 100644
--- a/MtdrSpring/backend/src/main/frontend/craco.config.js
+++ b/MtdrSpring/backend/src/main/frontend/craco.config.js
@@ -1,4 +1,11 @@
+const path = require('path');
+
module.exports = {
+ webpack: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
style: {
postcss: {
mode: "extends",
diff --git a/MtdrSpring/backend/src/main/frontend/package-lock.json b/MtdrSpring/backend/src/main/frontend/package-lock.json
index 5330eaa09..8a45b839d 100644
--- a/MtdrSpring/backend/src/main/frontend/package-lock.json
+++ b/MtdrSpring/backend/src/main/frontend/package-lock.json
@@ -15,6 +15,7 @@
"@mui/material": "^5.8.0",
"@mui/styles": "^5.7.0",
"@tailwindcss/vite": "^4.2.2",
+ "lucide-react": "^1.8.0",
"moment": "^2.29.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@@ -13195,6 +13196,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
+ "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -28191,6 +28201,12 @@
"yallist": "^3.0.2"
}
},
+ "lucide-react": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
+ "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
+ "requires": {}
+ },
"magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
diff --git a/MtdrSpring/backend/src/main/frontend/package.json b/MtdrSpring/backend/src/main/frontend/package.json
index 39c31ab63..9157d88f9 100644
--- a/MtdrSpring/backend/src/main/frontend/package.json
+++ b/MtdrSpring/backend/src/main/frontend/package.json
@@ -10,6 +10,7 @@
"@mui/material": "^5.8.0",
"@mui/styles": "^5.7.0",
"@tailwindcss/vite": "^4.2.2",
+ "lucide-react": "^1.8.0",
"moment": "^2.29.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
diff --git a/MtdrSpring/backend/src/main/frontend/src/App.js b/MtdrSpring/backend/src/main/frontend/src/App.js
deleted file mode 100644
index 18897dfb2..000000000
--- a/MtdrSpring/backend/src/main/frontend/src/App.js
+++ /dev/null
@@ -1,252 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import NewItem from './NewItem';
-import Dashboard from './Dashboard';
-import API_LIST from './API';
-import DeleteIcon from '@mui/icons-material/Delete';
-import TaskAltIcon from '@mui/icons-material/TaskAlt';
-import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
-import BarChartIcon from '@mui/icons-material/BarChart';
-import { CircularProgress } from '@mui/material';
-import Moment from 'react-moment';
-
-const CARD_COLORS = ['#7C3AED', '#F59E0B', '#14B8A6', '#EC4899', '#3B82F6', '#EF4444'];
-
-function App() {
- const [activeTab, setActiveTab] = useState('tasks');
- const [isLoading] = useState(false);
- const [isInserting, setInserting] = useState(false);
- const [items, setItems] = useState([]);
- const [, setError] = useState();
-
- function deleteItem(deleteId) {
- fetch(API_LIST + "/" + deleteId, { method: 'DELETE' })
- .then(response => {
- if (response.ok) return response;
- throw new Error('Something went wrong ...');
- })
- .then(
- () => { setItems(prev => prev.filter(item => item.id !== deleteId)); },
- (err) => { setError(err); }
- );
- }
-
- function toggleDone(event, id, description, done) {
- event.preventDefault();
- modifyItem(id, description, done).then(
- () => { reloadOneItem(id); },
- (err) => { setError(err); }
- );
- }
-
- function reloadOneItem(id) {
- fetch(API_LIST + "/" + id)
- .then(response => {
- if (response.ok) return response.json();
- throw new Error('Something went wrong ...');
- })
- .then(
- (result) => {
- setItems(prev => prev.map(x =>
- x.id === id ? { ...x, description: result.description, done: result.done } : x
- ));
- },
- (err) => { setError(err); }
- );
- }
-
- function modifyItem(id, description, done) {
- return fetch(API_LIST + "/" + id, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ description, done }),
- }).then(response => {
- if (response.ok) return response;
- throw new Error('Something went wrong ...');
- });
- }
-
- useEffect(() => {
- setItems([
- { id: 1, description: 'Design new dashboard layout', createdAt: '2026-04-14T09:00:00', done: false },
- { id: 2, description: 'Fix login bug on mobile', createdAt: '2026-04-14T10:30:00', done: false },
- { id: 3, description: 'Write unit tests for API', createdAt: '2026-04-13T15:00:00', done: false },
- { id: 4, description: 'Deploy to staging environment', createdAt: '2026-04-13T11:00:00', done: true },
- { id: 5, description: 'Review pull request #42', createdAt: '2026-04-12T08:00:00', done: true },
- ]);
- }, []);
-
- function addItem(text) {
- setInserting(true);
- fetch(API_LIST, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ description: text }),
- })
- .then(response => {
- if (response.ok) return response;
- throw new Error('Something went wrong ...');
- })
- .then(
- (result) => {
- const id = result.headers.get('location');
- setItems(prev => [{ id, description: text }, ...prev]);
- setInserting(false);
- },
- (err) => { setInserting(false); setError(err); }
- );
- }
-
- const todoItems = items.filter(item => !item.done);
- const doneItems = items.filter(item => item.done);
- const donePercent = items.length > 0 ? Math.round((doneItems.length / items.length) * 100) : 0;
-
- return (
-
-
-
- {/* Left panel — header + stats */}
-
-
-
-
-
- My Tasks
- Is this orange?
- Is this orange?
- Stay organized, stay focused
-
-
-
- {todoItems.length} pending
-
-
-
- {doneItems.length} completed
-
-
-
-
- {items.length > 0 && (
-
-
- {doneItems.length} of {items.length} tasks completed
- {donePercent}%
-
-
-
- )}
-
-
- {/* Right panel — tasks */}
-
-
- setActiveTab('tasks')}
- >
-
- Tasks
-
- setActiveTab('analytics')}
- >
-
- Analytics
-
-
-
- {activeTab === 'analytics' ? (
-
- ) : (
-
-
-
- {isLoading ? (
-
-
-
- ) : (
- <>
-
-
-
To Do
-
- {todoItems.length}
-
-
- {todoItems.length === 0 ? (
- All caught up — nothing left to do!
- ) : (
- todoItems.map((item, i) => (
-
-
toggleDone(e, item.id, item.description, true)}
- title="Mark as done"
- />
-
- {item.description}
- {item.createdAt && (
-
- {item.createdAt}
-
- )}
-
-
- ))
- )}
-
-
- {doneItems.length > 0 && (
-
-
-
Completed
- {doneItems.length}
-
- {doneItems.map((item) => (
-
-
toggleDone(e, item.id, item.description, false)}
- title="Mark as to do"
- />
-
- {item.description}
- {item.createdAt && (
-
- {item.createdAt}
-
- )}
-
-
- deleteItem(item.id)}
- title="Delete task"
- >
-
-
-
-
- ))}
-
- )}
- >
- )}
-
- )}
-
-
-
-
- );
-}
-
-export default App;
diff --git a/MtdrSpring/backend/src/main/frontend/src/App.tsx b/MtdrSpring/backend/src/main/frontend/src/App.tsx
new file mode 100644
index 000000000..8b0638f1d
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/App.tsx
@@ -0,0 +1,45 @@
+import { WorkItemContextCard } from '@/features/work-items/components/work-item-context-card';
+import { WorkItemDetailHeader } from '@/features/work-items/components/work-item-detail-header';
+import type { WorkItemDetail } from '@/features/work-items/types/work-item-ui.types';
+
+const mockWorkItem: WorkItemDetail = {
+ id: 'WI-102',
+ title: 'Create work item detail experience for managers and developers',
+ type: 'feature',
+ status: 'in_progress',
+ priority: 'high',
+ sprintName: 'Sprint 04 · Frontend Foundations',
+ estimatedHours: 12,
+ loggedHours: 7.5,
+ dueDate: 'Apr 22, 2026',
+ description:
+ 'Design and implement a polished work item detail screen that consolidates task context, assignees, discussion, related items, and activity history.',
+ acceptanceCriteria: [
+ 'Header shows title, type, status, priority, sprint, and main actions.',
+ 'The layout supports comments, related links, and activity in distinct reusable panels.',
+ 'The visual hierarchy is strong enough for manager visibility and quick developer execution.',
+ ],
+ tags: ['frontend', 'design-system', 'mvp'],
+ assignees: [
+ { id: 'u1', name: 'Bernardo', role: 'Manager' },
+ { id: 'u2', name: 'Ana Torres', role: 'Frontend Dev' },
+ { id: 'u3', name: 'Luis Vega', role: 'Backend Dev' },
+ ],
+ reporter: { id: 'u4', name: 'Sofia Ruiz', role: 'Product Owner' },
+ externalLink: 'https://example.com/spec/work-item-detail',
+ commentsCount: 6,
+ linkedItemsCount: 3,
+};
+
+function App() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default App;
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/comment-item.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/comment-item.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/comment-thread.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/comment-thread.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/metric-card.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/metric-card.tsx
new file mode 100644
index 000000000..edcaabc14
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/metric-card.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+interface MetricCardProps {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+ hint: string;
+}
+
+export function MetricCard({ icon, label, value, hint }: MetricCardProps) {
+ return (
+
+
+
+ {icon}
+
+
+
+
+ {label}
+
+
{value}
+
{hint}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/person-avatar.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/person-avatar.tsx
new file mode 100644
index 000000000..529018f02
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/person-avatar.tsx
@@ -0,0 +1,24 @@
+import type { Person } from '../../types/work-item-ui.types';
+import { getPersonInitials, joinClasses } from '../../lib/work-item-ui';
+
+interface PersonAvatarProps {
+ person: Person;
+ className?: string;
+}
+
+export function PersonAvatar({ person, className }: PersonAvatarProps) {
+ return (
+
+
+ {getPersonInitials(person)}
+
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/person-stack.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/person-stack.tsx
new file mode 100644
index 000000000..69736afdc
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/person-stack.tsx
@@ -0,0 +1,29 @@
+import type { Person } from '../../types/work-item-ui.types';
+import { PersonAvatar } from './person-avatar';
+
+interface PersonStackProps {
+ people: Person[];
+}
+
+export function PersonStack({ people }: PersonStackProps) {
+ return (
+
+
+ {people.map((person) => (
+
+ ))}
+
+
+
+
+ {people.length} collaborators
+
+
Cross-functional ownership
+
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/work-item-badge-row.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/work-item-badge-row.tsx
new file mode 100644
index 000000000..e497d2468
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/shared/work-item-badge-row.tsx
@@ -0,0 +1,44 @@
+import type {
+ WorkItemPriority,
+ WorkItemStatus,
+ WorkItemType,
+} from '../../types/work-item-ui.types';
+import {
+ formatStatus,
+ getPriorityClasses,
+ getStatusClasses,
+ getTypeClasses,
+ joinClasses,
+} from '../../lib/work-item-ui';
+import React from "react";
+
+interface WorkItemBadgeRowProps {
+ id: string;
+ type: WorkItemType;
+ status: WorkItemStatus;
+ priority: WorkItemPriority;
+}
+
+function Pill({children, className}: { children: React.ReactNode; className?: string; }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function WorkItemBadgeRow({id, type, status, priority,}: WorkItemBadgeRowProps) {
+ return (
+
+
{type}
+
{formatStatus(status)}
+
{priority} priority
+
{id}
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-activity-panel.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-activity-panel.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-comments-panel.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-comments-panel.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-context-card.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-context-card.tsx
new file mode 100644
index 000000000..c0bbd1228
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-context-card.tsx
@@ -0,0 +1,45 @@
+import { CheckCircle2 } from 'lucide-react';
+import type { WorkItemDetail } from '../types/work-item-ui.types';
+
+interface WorkItemContextCardProps {
+ item: WorkItemDetail;
+}
+
+export function WorkItemContextCard({ item }: WorkItemContextCardProps) {
+ return (
+
+
+
Work context
+
+
+
+
+
Description
+
+ {item.description}
+
+
+
+
+
+
+
+ Acceptance criteria
+
+
+
+ {item.acceptanceCriteria?.map((criterion) => (
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-detail-header.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-detail-header.tsx
new file mode 100644
index 000000000..d8bd121da
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-detail-header.tsx
@@ -0,0 +1,93 @@
+import { CalendarDays, ExternalLink, Flag, UserRound } from 'lucide-react';
+import type { WorkItemDetail } from '../types/work-item-ui.types';
+import { WorkItemBadgeRow } from './shared/work-item-badge-row';
+import { WorkItemMetaCard } from './work-item-meta-card';
+import { WorkItemMetrics } from './work-item-metrics';
+import { WorkItemProgressCard } from './work-item-progress-card';
+
+interface WorkItemDetailHeaderProps {
+ item: WorkItemDetail;
+ onMarkDone?: () => void;
+ onLogTime?: () => void;
+ onOpenExternal?: () => void;
+}
+
+export function WorkItemDetailHeader({ item, onMarkDone, onLogTime, onOpenExternal,}: WorkItemDetailHeaderProps) {
+ return (
+
+
+
+
+
+
+
+
+ {item.title}
+
+
+
+ {item.description}
+
+
+
+
+
+
+ {item.dueDate}
+
+
+
+
+ {item.sprintName}
+
+
+
+
+ Reporter: {item.reporter.name}
+
+
+
+
+
+
+ Mark as Done
+
+
+
+ Log Time
+
+
+
+
+ Open Spec
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-detail-summary.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-detail-summary.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-links-panel.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-links-panel.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-meta-card.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-meta-card.tsx
new file mode 100644
index 000000000..f4a36aee9
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-meta-card.tsx
@@ -0,0 +1,25 @@
+import type { WorkItemDetail } from '../types/work-item-ui.types';
+import { PersonStack } from './shared/person-stack';
+
+interface WorkItemMetaCardProps {
+ item: WorkItemDetail;
+}
+
+export function WorkItemMetaCard({ item }: WorkItemMetaCardProps) {
+ return (
+
+
+
+
+ {item.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-metrics.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-metrics.tsx
new file mode 100644
index 000000000..4a0ad2fd6
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-metrics.tsx
@@ -0,0 +1,41 @@
+import { Clock3, Link2, MessageSquare, Timer } from 'lucide-react';
+import type { WorkItemDetail } from '../types/work-item-ui.types';
+import { MetricCard } from './shared/metric-card';
+
+interface WorkItemMetricsProps {
+ item: WorkItemDetail;
+}
+
+export function WorkItemMetrics({ item }: WorkItemMetricsProps) {
+ return (
+
+ }
+ label="Estimate"
+ value={`${item.estimatedHours}h`}
+ hint="Original planning effort"
+ />
+
+ }
+ label="Logged"
+ value={`${item.loggedHours}h`}
+ hint="Actual work captured"
+ />
+
+ }
+ label="Discussion"
+ value={`${item.commentsCount}`}
+ hint="Active collaboration"
+ />
+
+ }
+ label="Linked items"
+ value={`${item.linkedItemsCount}`}
+ hint="Dependencies and blockers"
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-progress-card.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-progress-card.tsx
new file mode 100644
index 000000000..5b4d38393
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/work-item-progress-card.tsx
@@ -0,0 +1,34 @@
+import type { WorkItemDetail } from '../types/work-item-ui.types';
+
+interface WorkItemProgressCardProps {
+ item: WorkItemDetail;
+}
+
+export function WorkItemProgressCard({ item }: WorkItemProgressCardProps) {
+ const progress = Math.min(
+ 100,
+ Math.round((item.loggedHours / item.estimatedHours) * 100),
+ );
+
+ return (
+
+
+
+
Execution progress
+
+ Logged effort versus original estimate
+
+
+
+
{progress}%
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/facades/work-item-detail.facade.impl.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/facades/work-item-detail.facade.impl.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/facades/work-item-detail.facade.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/facades/work-item-detail.facade.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/work-item-ui.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/work-item-ui.ts
new file mode 100644
index 000000000..6c0e7bd64
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/work-item-ui.ts
@@ -0,0 +1,65 @@
+import type {
+ Person,
+ WorkItemPriority,
+ WorkItemStatus,
+ WorkItemType,
+} from '../types/work-item-ui.types';
+
+export function joinClasses(...values: Array): string {
+ return values.filter(Boolean).join(' ');
+}
+
+export function getPersonInitials(person: Person): string {
+ return person.name
+ .split(' ')
+ .map((part) => part[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase();
+}
+
+export function formatStatus(status: WorkItemStatus): string {
+ return status.replace('_', ' ');
+}
+
+export function getTypeClasses(type: WorkItemType): string {
+ switch (type) {
+ case 'feature':
+ return 'border-cyan-400/30 bg-cyan-500/15 text-cyan-300';
+ case 'bug':
+ return 'border-rose-400/30 bg-rose-500/15 text-rose-300';
+ case 'issue':
+ return 'border-orange-400/30 bg-orange-500/15 text-orange-300';
+ case 'task':
+ default:
+ return 'border-indigo-400/30 bg-indigo-500/15 text-indigo-300';
+ }
+}
+
+export function getStatusClasses(status: WorkItemStatus): string {
+ switch (status) {
+ case 'done':
+ return 'border-emerald-400/30 bg-emerald-500/15 text-emerald-300';
+ case 'blocked':
+ return 'border-rose-400/30 bg-rose-500/15 text-rose-300';
+ case 'in_progress':
+ return 'border-sky-400/30 bg-sky-500/15 text-sky-300';
+ case 'todo':
+ default:
+ return 'border-zinc-400/30 bg-zinc-500/15 text-zinc-300';
+ }
+}
+
+export function getPriorityClasses(priority: WorkItemPriority): string {
+ switch (priority) {
+ case 'critical':
+ return 'border-rose-400/30 bg-rose-500/15 text-rose-300';
+ case 'high':
+ return 'border-amber-400/30 bg-amber-500/15 text-amber-300';
+ case 'medium':
+ return 'border-violet-400/30 bg-violet-500/15 text-violet-300';
+ case 'low':
+ default:
+ return 'border-zinc-400/30 bg-zinc-500/15 text-zinc-300';
+ }
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/model/work-item-detail-screen-data.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/model/work-item-detail-screen-data.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/model/work-item-detail-state.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/model/work-item-detail-state.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-detail-page.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-detail-page.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-detail-ui-prototype.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-detail-ui-prototype.tsx
new file mode 100644
index 000000000..65ce3f326
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-detail-ui-prototype.tsx
@@ -0,0 +1,43 @@
+import { WorkItemContextCard } from '@/features/work-items/components/work-item-context-card';
+import { WorkItemDetailHeader } from '@/features/work-items/components/work-item-detail-header';
+import type { WorkItemDetail } from '@/features/work-items/types/work-item-ui.types';
+
+const mockWorkItem: WorkItemDetail = {
+ id: 'WI-102',
+ title: 'Create work item detail experience for managers and developers',
+ type: 'feature',
+ status: 'in_progress',
+ priority: 'high',
+ sprintName: 'Sprint 04 · Frontend Foundations',
+ estimatedHours: 12,
+ loggedHours: 7.5,
+ dueDate: 'Apr 22, 2026',
+ description:
+ 'Design and implement a polished work item detail screen that consolidates task context, assignees, discussion, related items, and activity history.',
+ acceptanceCriteria: [
+ 'Header shows title, type, status, priority, sprint, and main actions.',
+ 'The layout supports comments, related links, and activity in distinct reusable panels.',
+ 'The visual hierarchy is strong enough for manager visibility and quick developer execution.',
+ ],
+ tags: ['frontend', 'design-system', 'mvp'],
+ assignees: [
+ { id: 'u1', name: 'Bernardo', role: 'Manager' },
+ { id: 'u2', name: 'Ana Torres', role: 'Frontend Dev' },
+ { id: 'u3', name: 'Luis Vega', role: 'Backend Dev' },
+ ],
+ reporter: { id: 'u4', name: 'Sofia Ruiz', role: 'Product Owner' },
+ externalLink: 'https://example.com/spec/work-item-detail',
+ commentsCount: 6,
+ linkedItemsCount: 3,
+};
+
+export function WorkItemPrototypePage() {
+ return (
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/types/work-item-ui.types.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/types/work-item-ui.types.ts
new file mode 100644
index 000000000..48c8285dc
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/types/work-item-ui.types.ts
@@ -0,0 +1,31 @@
+export type WorkItemType = 'feature' | 'issue' | 'bug' | 'task';
+
+export type WorkItemStatus = 'todo' | 'in_progress' | 'blocked' | 'done';
+
+export type WorkItemPriority = 'low' | 'medium' | 'high' | 'critical';
+
+export interface Person {
+ id: string;
+ name: string;
+ role: string;
+}
+
+export interface WorkItemDetail {
+ id: string;
+ title: string;
+ type: WorkItemType;
+ status: WorkItemStatus;
+ priority: WorkItemPriority;
+ sprintName: string;
+ estimatedHours: number;
+ loggedHours: number;
+ dueDate: string;
+ description: string;
+ acceptanceCriteria?: string[];
+ tags: string[];
+ assignees: Person[];
+ reporter: Person;
+ externalLink?: string;
+ commentsCount: number;
+ linkedItemsCount: number;
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/tsconfig.json b/MtdrSpring/backend/src/main/frontend/tsconfig.json
index 84ad7aecf..1d61ef4e1 100644
--- a/MtdrSpring/backend/src/main/frontend/tsconfig.json
+++ b/MtdrSpring/backend/src/main/frontend/tsconfig.json
@@ -28,9 +28,9 @@
"module": "ESNext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
- "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ "baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */
"paths": {
- "@/*": ["./src/*"]
+ "@/*": ["src/*"]
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
From 4412948195b826fdabf35af3b0cdeb9eeea26fef Mon Sep 17 00:00:00 2001
From: Bernardo Santiago <63428964+bernardosantiago44@users.noreply.github.com>
Date: Wed, 15 Apr 2026 14:01:46 -0600
Subject: [PATCH 12/28] Enhance Frontend UI Developer agent documentation
Updated the Frontend UI Developer agent with detailed mission, scope, working rules, UI expectations, data integration, and done criteria.
---
.github/agents/frontend-ui-developer.md | 74 +++++++++++++++++++++++++
1 file changed, 74 insertions(+)
create mode 100644 .github/agents/frontend-ui-developer.md
diff --git a/.github/agents/frontend-ui-developer.md b/.github/agents/frontend-ui-developer.md
new file mode 100644
index 000000000..5d82107ad
--- /dev/null
+++ b/.github/agents/frontend-ui-developer.md
@@ -0,0 +1,74 @@
+---
+name: Frontend UI Developer
+description: Designs and implements frontend UI interfaces for the React + TypeScript application only. Focuses on reusable components, pages, layouts, and mock-driven frontend flows. Does not modify backend, database, infrastructure, or API contracts unless explicitly instructed by a human.
+---
+
+# Frontend UI Developer
+
+## Mission
+Build and refine the frontend user interface for the project using React + TypeScript.
+
+Prioritize:
+- clear and reusable UI components;
+- clean page composition;
+- frontend-first development with mock data/services when needed;
+- consistency with existing project styles and structure.
+
+## Read first
+Before making changes, check:
+
+1. `.github/copilot-instructions.md`
+2. `.github/context/project-overview.md`
+3. `.github/context/frontend-boundaries.md`
+4. `.github/context/ui-conventions.md`
+
+## Scope
+You may:
+- create and update React components, pages, layouts, hooks, mappers, view models, mock data, and frontend services;
+- improve visual hierarchy, spacing, responsiveness, and usability;
+- connect UI to existing frontend DTOs and mock service layers;
+- prepare the UI so real backend integration can be wired later with minimal refactoring.
+- Work in the `/MtdrSpring/backend/src/main/frontend/` React subdirectory.
+- Read the `/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/` java package for guidance about the response types of the backend.
+
+You must not:
+- modify backend code, database scripts, infrastructure, CI/CD, Telegram bot code, or API contracts on your own;
+- invent backend behavior without clearly isolating it behind mock services or typed interfaces;
+- couple UI components directly to backend implementation details;
+- introduce large dependencies unless already used in the repo or clearly justified.
+
+## Working rules
+- Stay inside the frontend area of the repository.
+- Prefer small, composable, reusable components over large page-specific ones.
+- Follow existing folder and naming conventions.
+- Use TypeScript interfaces/types for domain-facing data models and component props.
+- Use semicolons.
+- Strongly type where it improves readability and safety; avoid unnecessary noise.
+- Reuse shared UI patterns before creating new ones.
+- Keep components presentational when possible; place mapping/transformation logic outside UI components.
+- Support loading, empty, error, and populated states where relevant.
+- Keep accessibility in mind: semantic HTML, labels, keyboard navigation, and sensible contrast.
+- Keep styling consistent with the project’s Tailwind and design patterns.
+
+## UI expectations
+- Design for clarity first, then polish.
+- Prefer simple layouts with strong hierarchy and consistent spacing.
+- Build interfaces that are easy to extend later.
+- When creating new screens, think in terms of:
+ - page shell;
+ - section blocks;
+ - reusable cards/lists/forms/dialogs;
+ - typed mock data flow.
+
+## Data and integration
+- Assume the frontend may use mock services before real backend integration.
+- Keep DTOs, view models, and mappers explicit when the transformation adds clarity.
+- If backend data is missing or unclear, do not change backend assumptions; isolate the uncertainty in mock data or adapters.
+
+## Done criteria
+A task is complete when:
+- the UI works locally and is coherent with surrounding screens;
+- the code is readable and reusable;
+- the component/page handles its main visual states;
+- changes stay within frontend scope only;
+- the implementation is ready to be connected to real backend data later without major rewrites.
From 66699ddffbe372eb13153f6294482bb563e8f0c8 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago <63428964+bernardosantiago44@users.noreply.github.com>
Date: Wed, 15 Apr 2026 14:06:39 -0600
Subject: [PATCH 13/28] Add project overview documentation
Added a comprehensive project overview detailing the platform's objectives, user groups, architecture, and MVP focus.
---
.github/context/project-overview.md | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 .github/context/project-overview.md
diff --git a/.github/context/project-overview.md b/.github/context/project-overview.md
new file mode 100644
index 000000000..4fe6794a7
--- /dev/null
+++ b/.github/context/project-overview.md
@@ -0,0 +1,23 @@
+# Project Overview
+
+This repository contains a project management platform designed to improve productivity and activity visibility for remote and
+hybrid software development teams. The system’s stated objective is to increase team productivity and visibility by 20% through
+task automation, structured work tracking, and KPI reporting. The platform serves two main user groups: developers and managers.
+Developers interact primarily through Telegram to review and manage their personal work, while managers need broader visibility
+across the team, including progress, blockers, and estimated-versus-actual effort.
+
+The solution is composed of two delivery channels: a web portal and a Telegram chatbot service. The overall system follows a
+cloud-native approach and is intended to run on Oracle Cloud Infrastructure with Oracle Autonomous Database, Docker, Kubernetes,
+CI/CD pipelines, and infrastructure as code. The backend is planned around Java, Spring Boot, microservices, and REST-based
+integrations. For frontend work, this context matters only to understand the product and data flow; frontend changes should remain
+isolated from backend, infrastructure, and database implementation details.
+
+At the domain level, the core workflow revolves around users, teams, sprints, and work items. A work item may represent a feature,
+issue, or bug, and can include assignments, tags, links to other work items, comments, time entries, and activity logs. The data model
+also includes sprint baselines and KPI definitions/snapshots so the system can track productivity and reporting over time. This means
+the frontend should be designed around a project/work management experience rather than a generic dashboard shell.
+
+From a product perspective, the MVP focuses on work item management, sprint tracking, manager visibility, Telegram-based developer
+interaction, and basic KPI reporting. The frontend should therefore prioritize interfaces such as work item lists, sprint views,
+detail panels, assignments, comments, time tracking, and lightweight KPI summaries. The UI should be structured so mock services
+can be used first and later replaced by real backend integrations with minimal refactoring.
From 1700c9f21a141deda5d34925499b89a77b6e77f1 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago <63428964+bernardosantiago44@users.noreply.github.com>
Date: Wed, 15 Apr 2026 14:07:17 -0600
Subject: [PATCH 14/28] Simplify read first section in frontend-ui-developer.md
Removed redundant instructions for checking files before making changes.
---
.github/agents/frontend-ui-developer.md | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/.github/agents/frontend-ui-developer.md b/.github/agents/frontend-ui-developer.md
index 5d82107ad..e25dd9d11 100644
--- a/.github/agents/frontend-ui-developer.md
+++ b/.github/agents/frontend-ui-developer.md
@@ -17,10 +17,7 @@ Prioritize:
## Read first
Before making changes, check:
-1. `.github/copilot-instructions.md`
-2. `.github/context/project-overview.md`
-3. `.github/context/frontend-boundaries.md`
-4. `.github/context/ui-conventions.md`
+1. `.github/context/project-overview.md`
## Scope
You may:
From e57dd34991f6c340f19ee1e98f3a876b6f2bd7e7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 15 Apr 2026 20:29:24 +0000
Subject: [PATCH 15/28] Initial plan
From 0cad99714854291ab19c66a56696cefbe1bdddd2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 15 Apr 2026 20:50:02 +0000
Subject: [PATCH 16/28] feat: add work item dashboard with list/kanban views,
create/edit/detail modals, and filters
Agent-Logs-Url: https://github.com/bernardosantiago44/talos_oci_devops_project/sessions/e230285c-35dc-4860-b9c9-a10c2761acb6
Co-authored-by: bernardosantiago44 <63428964+bernardosantiago44@users.noreply.github.com>
---
.../backend/src/main/frontend/src/App.tsx | 42 +-
.../dashboard/dashboard-summary-cards.tsx | 94 +++++
.../dashboard/dashboard-toolbar.tsx | 114 ++++++
.../components/dashboard/kanban-view.tsx | 197 +++++++++
.../dashboard/work-item-detail-modal.tsx | 241 +++++++++++
.../dashboard/work-item-form-modal.tsx | 384 ++++++++++++++++++
.../dashboard/work-item-list-view.tsx | 202 +++++++++
.../features/work-items/lib/dashboard-ui.ts | 124 ++++++
.../work-items/mock/work-items.mock.ts | 215 ++++++++++
.../pages/work-item-dashboard-page.tsx | 234 +++++++++++
.../work-items/services/work-item.service.ts | 32 +-
.../src/main/frontend/tailwind.config.js | 11 +
12 files changed, 1847 insertions(+), 43 deletions(-)
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/dashboard-summary-cards.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/dashboard-toolbar.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/kanban-view.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-detail-modal.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-form-modal.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-list-view.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
create mode 100644 MtdrSpring/backend/src/main/frontend/tailwind.config.js
diff --git a/MtdrSpring/backend/src/main/frontend/src/App.tsx b/MtdrSpring/backend/src/main/frontend/src/App.tsx
index 8b0638f1d..d31527d77 100644
--- a/MtdrSpring/backend/src/main/frontend/src/App.tsx
+++ b/MtdrSpring/backend/src/main/frontend/src/App.tsx
@@ -1,45 +1,7 @@
-import { WorkItemContextCard } from '@/features/work-items/components/work-item-context-card';
-import { WorkItemDetailHeader } from '@/features/work-items/components/work-item-detail-header';
-import type { WorkItemDetail } from '@/features/work-items/types/work-item-ui.types';
-
-const mockWorkItem: WorkItemDetail = {
- id: 'WI-102',
- title: 'Create work item detail experience for managers and developers',
- type: 'feature',
- status: 'in_progress',
- priority: 'high',
- sprintName: 'Sprint 04 · Frontend Foundations',
- estimatedHours: 12,
- loggedHours: 7.5,
- dueDate: 'Apr 22, 2026',
- description:
- 'Design and implement a polished work item detail screen that consolidates task context, assignees, discussion, related items, and activity history.',
- acceptanceCriteria: [
- 'Header shows title, type, status, priority, sprint, and main actions.',
- 'The layout supports comments, related links, and activity in distinct reusable panels.',
- 'The visual hierarchy is strong enough for manager visibility and quick developer execution.',
- ],
- tags: ['frontend', 'design-system', 'mvp'],
- assignees: [
- { id: 'u1', name: 'Bernardo', role: 'Manager' },
- { id: 'u2', name: 'Ana Torres', role: 'Frontend Dev' },
- { id: 'u3', name: 'Luis Vega', role: 'Backend Dev' },
- ],
- reporter: { id: 'u4', name: 'Sofia Ruiz', role: 'Product Owner' },
- externalLink: 'https://example.com/spec/work-item-detail',
- commentsCount: 6,
- linkedItemsCount: 3,
-};
+import { WorkItemDashboardPage } from '@/features/work-items/pages/work-item-dashboard-page';
function App() {
- return (
-
-
-
-
-
-
- );
+ return ;
}
export default App;
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/dashboard-summary-cards.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/dashboard-summary-cards.tsx
new file mode 100644
index 000000000..b34c6b4be
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/dashboard-summary-cards.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { CheckSquare, Clock, AlertCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
+import type { WorkItemDetailDto } from '../../dtos/work-item-detail.dto';
+import { isOverdue } from '../../lib/dashboard-ui';
+
+interface SummaryCardsProps {
+ items: WorkItemDetailDto[];
+}
+
+interface StatCard {
+ label: string;
+ value: number;
+ icon: React.ReactNode;
+ color: string;
+ bg: string;
+ border: string;
+}
+
+export function DashboardSummaryCards({ items }: SummaryCardsProps) {
+ const total = items.length;
+ const todo = items.filter((i) => i.status === 'TODO').length;
+ const inProgress = items.filter((i) => i.status === 'IN_PROGRESS').length;
+ const blocked = items.filter((i) => i.status === 'BLOCKED').length;
+ const done = items.filter((i) => i.status === 'DONE').length;
+ const overdue = items.filter((i) => isOverdue(i.dueDate, i.status)).length;
+
+ const cards: StatCard[] = [
+ {
+ label: 'Total Tasks',
+ value: total,
+ icon: ,
+ color: 'text-zinc-300',
+ bg: 'bg-zinc-800/60',
+ border: 'border-zinc-700/50',
+ },
+ {
+ label: 'Todo',
+ value: todo,
+ icon: ,
+ color: 'text-zinc-400',
+ bg: 'bg-zinc-800/60',
+ border: 'border-zinc-700/50',
+ },
+ {
+ label: 'In Progress',
+ value: inProgress,
+ icon: ,
+ color: 'text-sky-300',
+ bg: 'bg-sky-500/10',
+ border: 'border-sky-500/20',
+ },
+ {
+ label: 'Blocked',
+ value: blocked,
+ icon: ,
+ color: 'text-rose-300',
+ bg: 'bg-rose-500/10',
+ border: 'border-rose-500/20',
+ },
+ {
+ label: 'Done',
+ value: done,
+ icon: ,
+ color: 'text-emerald-300',
+ bg: 'bg-emerald-500/10',
+ border: 'border-emerald-500/20',
+ },
+ {
+ label: 'Overdue',
+ value: overdue,
+ icon: ,
+ color: 'text-amber-300',
+ bg: 'bg-amber-500/10',
+ border: 'border-amber-500/20',
+ },
+ ];
+
+ return (
+
+ {cards.map((card) => (
+
+
+ {card.icon}
+ {card.value}
+
+
{card.label}
+
+ ))}
+
+ );
+}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/dashboard-toolbar.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/dashboard-toolbar.tsx
new file mode 100644
index 000000000..dec751696
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/dashboard-toolbar.tsx
@@ -0,0 +1,114 @@
+import React from 'react';
+import { Search, ListIcon, LayoutGrid, Plus } from 'lucide-react';
+import type { WorkItemStatus } from '../../enums/work-item-status.enum';
+import type { UserSummaryDto } from '@/shared/dtos/user-summary.dto';
+import { WORK_ITEM_STATUSES } from '../../enums/work-item-status.enum';
+import { formatStatusLabel } from '../../lib/dashboard-ui';
+
+export type ViewMode = 'list' | 'kanban';
+
+interface DashboardToolbarProps {
+ search: string;
+ onSearchChange: (v: string) => void;
+ statusFilter: WorkItemStatus | '';
+ onStatusFilterChange: (v: WorkItemStatus | '') => void;
+ assigneeFilter: string;
+ onAssigneeFilterChange: (v: string) => void;
+ viewMode: ViewMode;
+ onViewModeChange: (v: ViewMode) => void;
+ onCreateClick: () => void;
+ users: UserSummaryDto[];
+}
+
+export function DashboardToolbar({
+ search,
+ onSearchChange,
+ statusFilter,
+ onStatusFilterChange,
+ assigneeFilter,
+ onAssigneeFilterChange,
+ viewMode,
+ onViewModeChange,
+ onCreateClick,
+ users,
+}: DashboardToolbarProps) {
+ return (
+
+ {/* Search */}
+
+
+ onSearchChange(e.target.value)}
+ className="w-full rounded-xl border border-zinc-700/60 bg-zinc-800/60 py-2 pl-9 pr-4 text-sm text-zinc-200 placeholder-zinc-500 outline-none focus:border-sky-500/60 focus:ring-1 focus:ring-sky-500/30"
+ />
+
+
+ {/* Status filter */}
+
onStatusFilterChange(e.target.value as WorkItemStatus | '')}
+ className="rounded-xl border border-zinc-700/60 bg-zinc-800/60 px-3 py-2 text-sm text-zinc-300 outline-none focus:border-sky-500/60 focus:ring-1 focus:ring-sky-500/30"
+ >
+ All statuses
+ {WORK_ITEM_STATUSES.map((s) => (
+ {formatStatusLabel(s)}
+ ))}
+
+
+ {/* Assignee filter */}
+
onAssigneeFilterChange(e.target.value)}
+ className="rounded-xl border border-zinc-700/60 bg-zinc-800/60 px-3 py-2 text-sm text-zinc-300 outline-none focus:border-sky-500/60 focus:ring-1 focus:ring-sky-500/30"
+ >
+ All assignees
+ {users.map((u) => (
+ {u.name}
+ ))}
+
+
+ {/* View toggle */}
+
+ onViewModeChange('list')}
+ className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
+ viewMode === 'list'
+ ? 'bg-zinc-700 text-white'
+ : 'text-zinc-400 hover:text-zinc-200'
+ }`}
+ title="List view"
+ >
+
+ List
+
+ onViewModeChange('kanban')}
+ className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
+ viewMode === 'kanban'
+ ? 'bg-zinc-700 text-white'
+ : 'text-zinc-400 hover:text-zinc-200'
+ }`}
+ title="Kanban view"
+ >
+
+ Kanban
+
+
+
+ {/* Create button */}
+
+
+ New Task
+
+
+ );
+}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/kanban-view.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/kanban-view.tsx
new file mode 100644
index 000000000..571696b0d
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/kanban-view.tsx
@@ -0,0 +1,197 @@
+import React from 'react';
+import { CheckCircle2, Pencil, Eye } from 'lucide-react';
+import type { WorkItemDetailDto } from '../../dtos/work-item-detail.dto';
+import type { WorkItemStatus } from '../../enums/work-item-status.enum';
+import {
+ formatStatusLabel,
+ formatTypeLabel,
+ formatPriorityLabel,
+ getStatusBadgeClasses,
+ getPriorityBadgeClasses,
+ getTypeBadgeClasses,
+ getStatusDotColor,
+ calcProgress,
+ isOverdue,
+ formatDate,
+ getInitials,
+ cx,
+} from '../../lib/dashboard-ui';
+
+interface KanbanViewProps {
+ items: WorkItemDetailDto[];
+ onEdit: (item: WorkItemDetailDto) => void;
+ onComplete: (item: WorkItemDetailDto) => void;
+ onViewDetail: (item: WorkItemDetailDto) => void;
+}
+
+const COLUMNS: { status: WorkItemStatus; label: string }[] = [
+ { status: 'TODO', label: 'Todo' },
+ { status: 'IN_PROGRESS', label: 'In Progress' },
+ { status: 'BLOCKED', label: 'Blocked' },
+ { status: 'DONE', label: 'Done' },
+];
+
+function KanbanCard({
+ item,
+ onEdit,
+ onComplete,
+ onViewDetail,
+}: {
+ item: WorkItemDetailDto;
+ onEdit: (item: WorkItemDetailDto) => void;
+ onComplete: (item: WorkItemDetailDto) => void;
+ onViewDetail: (item: WorkItemDetailDto) => void;
+}) {
+ const progress = calcProgress(item.totalLoggedMinutes, item.estimatedMinutes);
+ const overdue = isOverdue(item.dueDate, item.status);
+ const isDone = item.status === 'DONE';
+
+ return (
+
+ {/* Type + Priority badges */}
+
+
+ {formatTypeLabel(item.type)}
+
+
+ {formatPriorityLabel(item.priority)}
+
+
+
+ {/* Title */}
+
onViewDetail(item)}
+ className={cx(
+ 'mt-2 block w-full text-left text-sm font-medium leading-snug transition-colors hover:text-sky-300',
+ isDone ? 'text-zinc-500 line-through' : 'text-zinc-100',
+ )}
+ >
+ {item.title}
+
+
+ {/* Due date */}
+ {item.dueDate && (
+
+ Due {formatDate(item.dueDate)}
+
+ )}
+
+ {/* Progress bar */}
+ {item.estimatedMinutes && item.estimatedMinutes > 0 && (
+
+ )}
+
+ {/* Footer: assignees + actions */}
+
+
+ {item.assignees.length === 0 && (
+
Unassigned
+ )}
+ {item.assignees.slice(0, 3).map((a, i) => (
+
+ {getInitials(a.user.name)}
+
+ ))}
+
+
+
+
onViewDetail(item)}
+ title="View detail"
+ className="rounded-md p-1 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-200"
+ >
+
+
+
onEdit(item)}
+ title="Edit"
+ className="rounded-md p-1 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-200"
+ >
+
+
+ {!isDone && (
+
onComplete(item)}
+ title="Mark done"
+ className="rounded-md p-1 text-zinc-500 hover:bg-emerald-500/20 hover:text-emerald-400"
+ >
+
+
+ )}
+
+
+
+ );
+}
+
+export function KanbanView({ items, onEdit, onComplete, onViewDetail }: KanbanViewProps) {
+ return (
+
+ {COLUMNS.map(({ status, label }) => {
+ const colItems = items.filter((i) => i.status === status);
+ return (
+
+ {/* Column header */}
+
+
+ c.startsWith('text-')) ?? 'text-zinc-300',
+ )}
+ >
+ {formatStatusLabel(status)}
+
+
+ {colItems.length}
+
+
+
+ {/* Cards */}
+
+ {colItems.length === 0 && (
+
+ )}
+ {colItems.map((item) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-detail-modal.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-detail-modal.tsx
new file mode 100644
index 000000000..7edd961b5
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-detail-modal.tsx
@@ -0,0 +1,241 @@
+import React from 'react';
+import { X, Calendar, Flag, Clock, Tag, Users, CheckCircle2 } from 'lucide-react';
+import type { WorkItemDetailDto } from '../../dtos/work-item-detail.dto';
+import {
+ formatStatusLabel,
+ formatTypeLabel,
+ formatPriorityLabel,
+ getStatusBadgeClasses,
+ getPriorityBadgeClasses,
+ getTypeBadgeClasses,
+ calcProgress,
+ isOverdue,
+ formatDate,
+ getSprintLabel,
+ getInitials,
+ cx,
+} from '../../lib/dashboard-ui';
+
+interface WorkItemDetailModalProps {
+ isOpen: boolean;
+ item: WorkItemDetailDto | null;
+ onClose: () => void;
+ onEdit: (item: WorkItemDetailDto) => void;
+ onComplete: (item: WorkItemDetailDto) => void;
+}
+
+function DetailRow({ icon, label, children }: {
+ icon: React.ReactNode;
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function WorkItemDetailModal({
+ isOpen,
+ item,
+ onClose,
+ onEdit,
+ onComplete,
+}: WorkItemDetailModalProps) {
+ if (!isOpen || !item) return null;
+
+ const progress = calcProgress(item.totalLoggedMinutes, item.estimatedMinutes);
+ const overdue = isOverdue(item.dueDate, item.status);
+ const isDone = item.status === 'DONE';
+
+ return (
+
+ {/* Overlay */}
+
+
+ {/* Panel */}
+
+ {/* Header */}
+
+
+
+
+
+ {formatTypeLabel(item.type)}
+
+
+ {formatStatusLabel(item.status)}
+
+
+ {formatPriorityLabel(item.priority)}
+
+
+
+ {item.title}
+
+
{item.id}
+
+
+
+
+
+
+
+ {/* Body */}
+
+
+ {/* Description */}
+ {item.description && (
+
+
Description
+
+ {item.description}
+
+
+ )}
+
+ {/* Meta grid */}
+
+
} label="Due Date">
+
+ {formatDate(item.dueDate)}
+ {overdue && ' · Overdue'}
+
+
+
+
} label="Sprint">
+
{getSprintLabel(item.sprintId)}
+
+
+
} label="Time">
+
+ {item.totalLoggedMinutes}
+ {item.estimatedMinutes ? `/${item.estimatedMinutes}` : ''} min
+
+
+
+
} label="Progress">
+
+
+
+
+ {/* Assignees */}
+
} label="Assignees">
+ {item.assignees.length === 0 ? (
+
Unassigned
+ ) : (
+
+ {item.assignees.map((a) => (
+
+
+ {getInitials(a.user.name)}
+
+
+
{a.user.name}
+
{a.role}
+
+
+ ))}
+
+ )}
+
+
+ {/* Tags */}
+ {item.tags.length > 0 && (
+
} label="Tags">
+
+ {item.tags.map((tag) => (
+
+ #{tag.name}
+
+ ))}
+
+
+ )}
+
+ {/* Comments placeholder */}
+
+
Activity
+
+ Comments and activity history will appear here once connected to the backend.
+
+
+
+
+
+ {/* Footer */}
+
+ onEdit(item)}
+ className="rounded-xl border border-zinc-700/60 bg-zinc-800/60 px-4 py-2 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100"
+ >
+ Edit
+
+ {!isDone && (
+ onComplete(item)}
+ className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-emerald-400"
+ >
+ Mark as Done
+
+ )}
+
+
+
+ );
+}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-form-modal.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-form-modal.tsx
new file mode 100644
index 000000000..0d3002619
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-form-modal.tsx
@@ -0,0 +1,384 @@
+import React, { useEffect, useState } from 'react';
+import { X } from 'lucide-react';
+import type { WorkItemDetailDto } from '../../dtos/work-item-detail.dto';
+import type { CreateWorkItemDto } from '../../dtos/create-work-item.dto';
+import type { UpdateWorkItemDto } from '../../dtos/update-work-item.dto';
+import type { WorkItemType } from '../../enums/work-item-type.enum';
+import type { WorkItemStatus } from '../../enums/work-item-status.enum';
+import type { WorkItemPriority } from '../../enums/work-item-priority.enum';
+import type { UserSummaryDto } from '@/shared/dtos/user-summary.dto';
+import type { TagDto } from '@/shared/dtos/tag.dto';
+import { WORK_ITEM_TYPES } from '../../enums/work-item-type.enum';
+import { WORK_ITEM_STATUSES } from '../../enums/work-item-status.enum';
+import { WORK_ITEM_PRIORITIES } from '../../enums/work-item-priority.enum';
+import {
+ formatTypeLabel,
+ formatStatusLabel,
+ formatPriorityLabel,
+} from '../../lib/dashboard-ui';
+
+interface WorkItemFormModalProps {
+ isOpen: boolean;
+ item?: WorkItemDetailDto | null;
+ users: UserSummaryDto[];
+ tags: TagDto[];
+ onClose: () => void;
+ onCreate: (dto: CreateWorkItemDto) => Promise;
+ onUpdate: (id: string, dto: UpdateWorkItemDto) => Promise;
+}
+
+interface FormState {
+ title: string;
+ description: string;
+ type: WorkItemType;
+ status: WorkItemStatus;
+ priority: WorkItemPriority;
+ dueDate: string;
+ estimatedMinutes: string;
+ sprintId: string;
+ assigneeUserIds: string[];
+ tagIds: string[];
+}
+
+const DEFAULT_FORM: FormState = {
+ title: '',
+ description: '',
+ type: 'TASK',
+ status: 'TODO',
+ priority: 'MEDIUM',
+ dueDate: '',
+ estimatedMinutes: '',
+ sprintId: '',
+ assigneeUserIds: [],
+ tagIds: [],
+};
+
+const SPRINT_OPTIONS = [
+ { id: '', label: 'No Sprint' },
+ { id: 'spr-001', label: 'Sprint 1' },
+ { id: 'spr-002', label: 'Sprint 2' },
+ { id: 'spr-003', label: 'Sprint 3' },
+];
+
+function Label({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
+
+function Input({ value, onChange, placeholder, type = 'text' }: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+ type?: string;
+}) {
+ return (
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ className="w-full rounded-xl border border-zinc-700/60 bg-zinc-800/60 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 outline-none focus:border-sky-500/60 focus:ring-1 focus:ring-sky-500/30"
+ />
+ );
+}
+
+function Select({ value, onChange, children }: {
+ value: string;
+ onChange: (v: string) => void;
+ children: React.ReactNode;
+}) {
+ return (
+ onChange(e.target.value)}
+ className="w-full rounded-xl border border-zinc-700/60 bg-zinc-800/60 px-3 py-2 text-sm text-zinc-200 outline-none focus:border-sky-500/60 focus:ring-1 focus:ring-sky-500/30"
+ >
+ {children}
+
+ );
+}
+
+export function WorkItemFormModal({
+ isOpen,
+ item,
+ users,
+ tags,
+ onClose,
+ onCreate,
+ onUpdate,
+}: WorkItemFormModalProps) {
+ const isEditing = !!item;
+ const [form, setForm] = useState(DEFAULT_FORM);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (!isOpen) return;
+ if (item) {
+ setForm({
+ title: item.title,
+ description: item.description ?? '',
+ type: item.type,
+ status: item.status,
+ priority: item.priority,
+ dueDate: item.dueDate ?? '',
+ estimatedMinutes: item.estimatedMinutes?.toString() ?? '',
+ sprintId: item.sprintId ?? '',
+ assigneeUserIds: item.assignees.map((a) => a.user.id),
+ tagIds: item.tags.map((t) => t.id),
+ });
+ } else {
+ setForm(DEFAULT_FORM);
+ }
+ setError('');
+ }, [isOpen, item]);
+
+ function set(key: K, value: FormState[K]) {
+ setForm((prev) => ({ ...prev, [key]: value }));
+ }
+
+ function toggleArrayItem(key: 'assigneeUserIds' | 'tagIds', id: string) {
+ setForm((prev) => {
+ const arr = prev[key] as string[];
+ return {
+ ...prev,
+ [key]: arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id],
+ };
+ });
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!form.title.trim()) {
+ setError('Title is required.');
+ return;
+ }
+ setSaving(true);
+ setError('');
+ try {
+ const minutes = form.estimatedMinutes ? parseInt(form.estimatedMinutes, 10) : undefined;
+ if (isEditing && item) {
+ const dto: UpdateWorkItemDto = {
+ title: form.title.trim(),
+ description: form.description.trim() || undefined,
+ status: form.status,
+ priority: form.priority,
+ dueDate: form.dueDate || undefined,
+ estimatedMinutes: minutes,
+ assigneeUserIds: form.assigneeUserIds,
+ tagIds: form.tagIds,
+ };
+ await onUpdate(item.id, dto);
+ } else {
+ const dto: CreateWorkItemDto = {
+ title: form.title.trim(),
+ description: form.description.trim() || undefined,
+ type: form.type,
+ status: form.status,
+ priority: form.priority,
+ dueDate: form.dueDate || undefined,
+ estimatedMinutes: minutes,
+ sprintId: form.sprintId || undefined,
+ assigneeUserIds: form.assigneeUserIds,
+ tagIds: form.tagIds,
+ };
+ await onCreate(dto);
+ }
+ onClose();
+ } catch {
+ setError('Something went wrong. Please try again.');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Overlay */}
+
+
+ {/* Panel */}
+
+ {/* Header */}
+
+
+ {isEditing ? 'Edit Task' : 'New Task'}
+
+
+
+
+
+
+ {/* Form */}
+
+
+
+ );
+}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-list-view.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-list-view.tsx
new file mode 100644
index 000000000..0cea0b7c7
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/work-item-list-view.tsx
@@ -0,0 +1,202 @@
+import React from 'react';
+import { CheckCircle2, Pencil, Eye } from 'lucide-react';
+import type { WorkItemDetailDto } from '../../dtos/work-item-detail.dto';
+import {
+ formatStatusLabel,
+ formatTypeLabel,
+ formatPriorityLabel,
+ getStatusBadgeClasses,
+ getPriorityBadgeClasses,
+ getTypeBadgeClasses,
+ calcProgress,
+ isOverdue,
+ formatDate,
+ getSprintLabel,
+ getInitials,
+ cx,
+} from '../../lib/dashboard-ui';
+
+interface WorkItemListViewProps {
+ items: WorkItemDetailDto[];
+ onEdit: (item: WorkItemDetailDto) => void;
+ onComplete: (item: WorkItemDetailDto) => void;
+ onViewDetail: (item: WorkItemDetailDto) => void;
+}
+
+function Pill({ children, className }: { children: React.ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function AvatarStack({ names }: { names: string[] }) {
+ if (names.length === 0) {
+ return Unassigned ;
+ }
+ return (
+
+ {names.slice(0, 3).map((name, i) => (
+
+ {getInitials(name)}
+
+ ))}
+ {names.length > 3 && (
+
+ +{names.length - 3}
+
+ )}
+
+ );
+}
+
+function ProgressBar({ value }: { value: number }) {
+ return (
+
+ );
+}
+
+export function WorkItemListView({ items, onEdit, onComplete, onViewDetail }: WorkItemListViewProps) {
+ if (items.length === 0) {
+ return (
+
+
+
No tasks found
+
Try adjusting your filters or create a new task.
+
+ );
+ }
+
+ return (
+
+ {/* Table header */}
+
+ Title
+ Type
+ Status
+ Priority
+ Assignees
+ Due Date
+ Sprint
+ Progress
+ Actions
+
+
+
+ {items.map((item) => {
+ const progress = calcProgress(item.totalLoggedMinutes, item.estimatedMinutes);
+ const overdue = isOverdue(item.dueDate, item.status);
+ const assigneeNames = item.assignees.map((a) => a.user.name);
+ const isDone = item.status === 'DONE';
+
+ return (
+
+ {/* Title */}
+
+ onViewDetail(item)}
+ className={cx(
+ 'truncate text-left text-sm font-medium hover:text-sky-300 transition-colors',
+ isDone ? 'text-zinc-500 line-through' : 'text-zinc-100',
+ )}
+ title={item.title}
+ >
+ {item.title}
+
+ {item.id}
+
+
+ {/* Type */}
+
+ {formatTypeLabel(item.type)}
+
+
+ {/* Status */}
+
+ {formatStatusLabel(item.status)}
+
+
+ {/* Priority */}
+
+ {formatPriorityLabel(item.priority)}
+
+
+ {/* Assignees */}
+
+
+ {/* Due Date */}
+
+ {formatDate(item.dueDate)}
+
+
+ {/* Sprint */}
+
+ {getSprintLabel(item.sprintId)}
+
+
+ {/* Progress */}
+
+
+ {/* Actions */}
+
+
onViewDetail(item)}
+ title="View detail"
+ className="rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
+ >
+
+
+
onEdit(item)}
+ title="Edit task"
+ className="rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
+ >
+
+
+ {!isDone && (
+
onComplete(item)}
+ title="Mark as done"
+ className="rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-emerald-500/20 hover:text-emerald-400"
+ >
+
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
new file mode 100644
index 000000000..2a7bc145b
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
@@ -0,0 +1,124 @@
+import type { WorkItemPriority } from '../enums/work-item-priority.enum';
+import type { WorkItemStatus } from '../enums/work-item-status.enum';
+import type { WorkItemType } from '../enums/work-item-type.enum';
+
+export function cx(...classes: Array): string {
+ return classes.filter(Boolean).join(' ');
+}
+
+export function getInitials(name: string): string {
+ return name
+ .split(' ')
+ .map((part) => part[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase();
+}
+
+export function formatStatusLabel(status: WorkItemStatus): string {
+ switch (status) {
+ case 'TODO': return 'Todo';
+ case 'IN_PROGRESS': return 'In Progress';
+ case 'BLOCKED': return 'Blocked';
+ case 'DONE': return 'Done';
+ default: return status;
+ }
+}
+
+export function formatTypeLabel(type: WorkItemType): string {
+ switch (type) {
+ case 'FEATURE': return 'Feature';
+ case 'BUG': return 'Bug';
+ case 'ISSUE': return 'Issue';
+ case 'TASK': return 'Task';
+ default: return type;
+ }
+}
+
+export function formatPriorityLabel(priority: WorkItemPriority): string {
+ switch (priority) {
+ case 'LOW': return 'Low';
+ case 'MEDIUM': return 'Medium';
+ case 'HIGH': return 'High';
+ case 'CRITICAL': return 'Critical';
+ default: return priority;
+ }
+}
+
+export function getStatusBadgeClasses(status: WorkItemStatus): string {
+ switch (status) {
+ case 'DONE':
+ return 'border-emerald-400/30 bg-emerald-500/15 text-emerald-300';
+ case 'BLOCKED':
+ return 'border-rose-400/30 bg-rose-500/15 text-rose-300';
+ case 'IN_PROGRESS':
+ return 'border-sky-400/30 bg-sky-500/15 text-sky-300';
+ case 'TODO':
+ default:
+ return 'border-zinc-400/30 bg-zinc-500/15 text-zinc-400';
+ }
+}
+
+export function getPriorityBadgeClasses(priority: WorkItemPriority): string {
+ switch (priority) {
+ case 'CRITICAL':
+ return 'border-rose-400/30 bg-rose-500/15 text-rose-300';
+ case 'HIGH':
+ return 'border-amber-400/30 bg-amber-500/15 text-amber-300';
+ case 'MEDIUM':
+ return 'border-violet-400/30 bg-violet-500/15 text-violet-300';
+ case 'LOW':
+ default:
+ return 'border-zinc-400/30 bg-zinc-500/15 text-zinc-400';
+ }
+}
+
+export function getTypeBadgeClasses(type: WorkItemType): string {
+ switch (type) {
+ case 'FEATURE':
+ return 'border-cyan-400/30 bg-cyan-500/15 text-cyan-300';
+ case 'BUG':
+ return 'border-rose-400/30 bg-rose-500/15 text-rose-300';
+ case 'ISSUE':
+ return 'border-orange-400/30 bg-orange-500/15 text-orange-300';
+ case 'TASK':
+ default:
+ return 'border-indigo-400/30 bg-indigo-500/15 text-indigo-300';
+ }
+}
+
+export function getStatusDotColor(status: WorkItemStatus): string {
+ switch (status) {
+ case 'DONE': return 'bg-emerald-400';
+ case 'BLOCKED': return 'bg-rose-400';
+ case 'IN_PROGRESS': return 'bg-sky-400';
+ case 'TODO': return 'bg-zinc-500';
+ default: return 'bg-zinc-500';
+ }
+}
+
+export function calcProgress(logged: number, estimated?: number): number {
+ if (!estimated || estimated === 0) return 0;
+ return Math.min(100, Math.round((logged / estimated) * 100));
+}
+
+export function isOverdue(dueDate?: string, status?: WorkItemStatus): boolean {
+ if (!dueDate || status === 'DONE') return false;
+ return new Date(dueDate) < new Date(new Date().toDateString());
+}
+
+export function formatDate(dateStr?: string): string {
+ if (!dateStr) return '—';
+ const d = new Date(dateStr);
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+export function getSprintLabel(sprintId?: string): string {
+ if (!sprintId) return '—';
+ const map: Record = {
+ 'spr-001': 'Sprint 1',
+ 'spr-002': 'Sprint 2',
+ 'spr-003': 'Sprint 3',
+ };
+ return map[sprintId] ?? sprintId;
+}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/mock/work-items.mock.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/mock/work-items.mock.ts
index 1a5f5eb05..4f3d5bc1c 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/mock/work-items.mock.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/mock/work-items.mock.ts
@@ -143,5 +143,220 @@ export const mockWorkItems: WorkItemDetailDto[] = [
environment: 'Web Portal / Requirements',
reproductionSteps: 'Compare board filtering and list filtering expected behavior.'
}
+ },
+ {
+ id: 'wrk-004',
+ sprintId: 'spr-001',
+ title: 'Set up CI pipeline for frontend builds',
+ description: 'Configure GitHub Actions workflow to lint, test, and build the React app on each push.',
+ type: 'TASK',
+ status: 'DONE',
+ priority: 'HIGH',
+ estimatedMinutes: 240,
+ totalLoggedMinutes: 210,
+ dueDate: '2026-04-12',
+ createdAt: '2026-04-08T10:00:00Z',
+ updatedAt: '2026-04-12T15:00:00Z',
+ completedAt: '2026-04-12T15:00:00Z',
+ createdBy: {
+ id: 'usr-001',
+ name: 'Bernardo Manager',
+ email: 'bernardo.manager@demo.com',
+ telegramUserId: 'tg_bernardo_manager'
+ },
+ assignees: [
+ {
+ id: 'asg-004',
+ user: {
+ id: 'usr-003',
+ name: 'Luis Developer',
+ email: 'luis.dev@demo.com',
+ telegramUserId: 'tg_luis_dev'
+ },
+ role: 'OWNER',
+ assignedAt: '2026-04-08T11:00:00Z'
+ }
+ ],
+ tags: [
+ {
+ id: 'tag-001',
+ name: 'Frontend',
+ color: '#3B82F6',
+ description: 'UI and client-side work'
+ }
+ ]
+ },
+ {
+ id: 'wrk-005',
+ sprintId: 'spr-001',
+ title: 'Design shared component library tokens',
+ description: 'Define color, spacing, and typography tokens used across the design system.',
+ type: 'FEATURE',
+ status: 'DONE',
+ priority: 'MEDIUM',
+ estimatedMinutes: 300,
+ totalLoggedMinutes: 300,
+ dueDate: '2026-04-14',
+ createdAt: '2026-04-09T08:00:00Z',
+ updatedAt: '2026-04-14T12:00:00Z',
+ completedAt: '2026-04-14T12:00:00Z',
+ createdBy: {
+ id: 'usr-002',
+ name: 'Ana Developer',
+ email: 'ana.dev@demo.com',
+ telegramUserId: 'tg_ana_dev'
+ },
+ assignees: [
+ {
+ id: 'asg-005',
+ user: {
+ id: 'usr-002',
+ name: 'Ana Developer',
+ email: 'ana.dev@demo.com',
+ telegramUserId: 'tg_ana_dev'
+ },
+ role: 'OWNER',
+ assignedAt: '2026-04-09T08:30:00Z'
+ }
+ ],
+ tags: [
+ {
+ id: 'tag-001',
+ name: 'Frontend',
+ color: '#3B82F6',
+ description: 'UI and client-side work'
+ }
+ ],
+ featureDetails: {
+ businessValue: 'Ensures visual consistency across all UI components.',
+ acceptanceCriteria: 'Tokens documented and applied in at least 3 shared components.'
+ }
+ },
+ {
+ id: 'wrk-006',
+ sprintId: 'spr-002',
+ title: 'Implement sprint progress API endpoint',
+ description: 'Expose a REST endpoint returning current sprint completion percentage and item breakdown.',
+ type: 'FEATURE',
+ status: 'TODO',
+ priority: 'HIGH',
+ estimatedMinutes: 480,
+ totalLoggedMinutes: 0,
+ dueDate: '2026-04-30',
+ createdAt: '2026-04-15T09:00:00Z',
+ updatedAt: '2026-04-15T09:00:00Z',
+ createdBy: {
+ id: 'usr-001',
+ name: 'Bernardo Manager',
+ email: 'bernardo.manager@demo.com',
+ telegramUserId: 'tg_bernardo_manager'
+ },
+ assignees: [
+ {
+ id: 'asg-006',
+ user: {
+ id: 'usr-003',
+ name: 'Luis Developer',
+ email: 'luis.dev@demo.com',
+ telegramUserId: 'tg_luis_dev'
+ },
+ role: 'ASSIGNEE',
+ assignedAt: '2026-04-15T09:30:00Z'
+ }
+ ],
+ tags: [
+ {
+ id: 'tag-002',
+ name: 'Backend',
+ color: '#10B981',
+ description: 'API and service work'
+ }
+ ],
+ featureDetails: {
+ businessValue: 'Enables real-time sprint dashboards for managers.',
+ acceptanceCriteria: 'Endpoint returns 200 with correct data shape. Validated with integration tests.'
+ }
+ },
+ {
+ id: 'wrk-007',
+ sprintId: 'spr-002',
+ title: 'Fix date timezone offset in due date display',
+ description: 'Due dates appear one day off when the user is in UTC-5 or earlier timezones.',
+ type: 'BUG',
+ status: 'IN_PROGRESS',
+ priority: 'MEDIUM',
+ estimatedMinutes: 120,
+ totalLoggedMinutes: 60,
+ dueDate: '2026-04-17',
+ createdAt: '2026-04-13T14:00:00Z',
+ updatedAt: '2026-04-15T11:00:00Z',
+ createdBy: {
+ id: 'usr-003',
+ name: 'Luis Developer',
+ email: 'luis.dev@demo.com',
+ telegramUserId: 'tg_luis_dev'
+ },
+ assignees: [
+ {
+ id: 'asg-007',
+ user: {
+ id: 'usr-002',
+ name: 'Ana Developer',
+ email: 'ana.dev@demo.com',
+ telegramUserId: 'tg_ana_dev'
+ },
+ role: 'OWNER',
+ assignedAt: '2026-04-13T15:00:00Z'
+ }
+ ],
+ tags: [
+ {
+ id: 'tag-003',
+ name: 'Bug',
+ color: '#EF4444',
+ description: 'Defect or error'
+ },
+ {
+ id: 'tag-001',
+ name: 'Frontend',
+ color: '#3B82F6',
+ description: 'UI and client-side work'
+ }
+ ],
+ bugDetails: {
+ severity: 'MEDIUM',
+ environment: 'Web Portal / Production',
+ isReproducible: true,
+ steps: 'Set browser timezone to UTC-5. Open any task with a due date. Observe offset.'
+ }
+ },
+ {
+ id: 'wrk-008',
+ sprintId: 'spr-001',
+ title: 'Write onboarding documentation for new developers',
+ description: 'Create a concise getting-started guide covering setup, conventions, and key workflows.',
+ type: 'TASK',
+ status: 'TODO',
+ priority: 'LOW',
+ estimatedMinutes: 150,
+ totalLoggedMinutes: 0,
+ dueDate: '2026-05-01',
+ createdAt: '2026-04-15T10:00:00Z',
+ updatedAt: '2026-04-15T10:00:00Z',
+ createdBy: {
+ id: 'usr-001',
+ name: 'Bernardo Manager',
+ email: 'bernardo.manager@demo.com',
+ telegramUserId: 'tg_bernardo_manager'
+ },
+ assignees: [],
+ tags: [
+ {
+ id: 'tag-002',
+ name: 'Backend',
+ color: '#10B981',
+ description: 'API and service work'
+ }
+ ]
}
];
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
new file mode 100644
index 000000000..1eb6abc65
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
@@ -0,0 +1,234 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Layers } from 'lucide-react';
+import type { WorkItemDetailDto } from '../dtos/work-item-detail.dto';
+import type { CreateWorkItemDto } from '../dtos/create-work-item.dto';
+import type { UpdateWorkItemDto } from '../dtos/update-work-item.dto';
+import type { WorkItemStatus } from '../enums/work-item-status.enum';
+import { workItemService } from '../services/work-item.service';
+import { mockUsers } from '@/shared/mock/users.mock';
+import { mockTags } from '@/shared/mock/tags.mock';
+import { DashboardSummaryCards } from '../components/dashboard/dashboard-summary-cards';
+import { DashboardToolbar } from '../components/dashboard/dashboard-toolbar';
+import type { ViewMode } from '../components/dashboard/dashboard-toolbar';
+import { WorkItemListView } from '../components/dashboard/work-item-list-view';
+import { KanbanView } from '../components/dashboard/kanban-view';
+import { WorkItemFormModal } from '../components/dashboard/work-item-form-modal';
+import { WorkItemDetailModal } from '../components/dashboard/work-item-detail-modal';
+
+export function WorkItemDashboardPage() {
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Toolbar state
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState('');
+ const [assigneeFilter, setAssigneeFilter] = useState('');
+ const [viewMode, setViewMode] = useState('list');
+
+ // Modal state
+ const [formOpen, setFormOpen] = useState(false);
+ const [editingItem, setEditingItem] = useState(null);
+ const [detailItem, setDetailItem] = useState(null);
+ const [detailOpen, setDetailOpen] = useState(false);
+
+ // Load all items on mount (using the service so we stay in the service-layer contract)
+ const loadItems = useCallback(async () => {
+ setLoading(true);
+ try {
+ // Load a large page to get all items for the dashboard
+ const result = await workItemService.getWorkItems({ page: 1, pageSize: 100 });
+ if (result.success) {
+ // getWorkItems returns WorkItemListItemDto[] but we need full detail for mutations.
+ // Use the mock store directly by fetching each id — the service exposes getWorkItemById.
+ const ids = result.data.items.map((i) => i.id);
+ const details = await Promise.all(ids.map((id) => workItemService.getWorkItemById(id)));
+ const fullItems = details
+ .filter((r) => r.success && r.data !== null)
+ .map((r) => r.data as WorkItemDetailDto);
+ setItems(fullItems);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadItems();
+ }, [loadItems]);
+
+ // In-memory filtered items
+ const filteredItems = useMemo(() => {
+ return items.filter((item) => {
+ const matchesSearch =
+ !search ||
+ item.title.toLowerCase().includes(search.toLowerCase()) ||
+ (item.description ?? '').toLowerCase().includes(search.toLowerCase());
+
+ const matchesStatus = !statusFilter || item.status === statusFilter;
+
+ const matchesAssignee =
+ !assigneeFilter ||
+ item.assignees.some((a) => a.user.id === assigneeFilter);
+
+ return matchesSearch && matchesStatus && matchesAssignee;
+ });
+ }, [items, search, statusFilter, assigneeFilter]);
+
+ // Create handler
+ const handleCreate = useCallback(async (dto: CreateWorkItemDto) => {
+ const result = await workItemService.createWorkItem(dto);
+ if (result.success) {
+ setItems((prev) => [result.data, ...prev]);
+ }
+ }, []);
+
+ // Update handler
+ const handleUpdate = useCallback(async (id: string, dto: UpdateWorkItemDto) => {
+ const result = await workItemService.updateWorkItem(id, dto);
+ if (result.success && result.data) {
+ const updated = result.data;
+ setItems((prev) => prev.map((item) => (item.id === id ? updated : item)));
+ // Refresh detail modal if open for this item
+ if (detailItem?.id === id) {
+ setDetailItem(updated);
+ }
+ }
+ }, [detailItem]);
+
+ // Complete handler
+ const handleComplete = useCallback(async (item: WorkItemDetailDto) => {
+ await handleUpdate(item.id, {
+ status: 'DONE',
+ completedAt: new Date().toISOString(),
+ });
+ }, [handleUpdate]);
+
+ // Open edit
+ const handleEdit = useCallback((item: WorkItemDetailDto) => {
+ setEditingItem(item);
+ setDetailOpen(false);
+ setFormOpen(true);
+ }, []);
+
+ // Open detail
+ const handleViewDetail = useCallback((item: WorkItemDetailDto) => {
+ setDetailItem(item);
+ setDetailOpen(true);
+ }, []);
+
+ // Close form
+ const handleCloseForm = useCallback(() => {
+ setFormOpen(false);
+ setEditingItem(null);
+ }, []);
+
+ // Close detail
+ const handleCloseDetail = useCallback(() => {
+ setDetailOpen(false);
+ setDetailItem(null);
+ }, []);
+
+ // Edit from detail modal
+ const handleEditFromDetail = useCallback((item: WorkItemDetailDto) => {
+ handleCloseDetail();
+ handleEdit(item);
+ }, [handleCloseDetail, handleEdit]);
+
+ // Complete from detail modal
+ const handleCompleteFromDetail = useCallback(async (item: WorkItemDetailDto) => {
+ await handleComplete(item);
+ handleCloseDetail();
+ }, [handleComplete, handleCloseDetail]);
+
+ return (
+
+
+
+ {/* Page header */}
+
+
+
+
+
+
+
Work Items
+
Sprint 1 · Talos OCI DevOps Project
+
+
+
+
+ {/* Summary cards */}
+
+
+
+
+ {/* Toolbar */}
+
+ {
+ setEditingItem(null);
+ setFormOpen(true);
+ }}
+ users={mockUsers}
+ />
+
+
+ {/* Results count */}
+
+ {loading
+ ? 'Loading…'
+ : `${filteredItems.length} of ${items.length} task${items.length !== 1 ? 's' : ''}`}
+
+
+ {/* View area */}
+ {loading ? (
+
+ ) : viewMode === 'list' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Create / Edit modal */}
+
+
+ {/* Detail preview modal */}
+
+
+ );
+}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
index 2a51c73ea..2577faa37 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
@@ -2,6 +2,8 @@ import type { ApiResult } from '@/shared/dtos/api-result.dto';
import type { PagedResult } from '@/shared/dtos/paged-result.dto';
import { mockApi } from '@/shared/services/mock-api';
import { mockWorkItems } from '../mock/work-items.mock';
+import { mockUsers } from '@/shared/mock/users.mock';
+import { mockTags } from '@/shared/mock/tags.mock';
import type { CreateWorkItemDto } from '../dtos/create-work-item.dto';
import type { UpdateWorkItemDto } from '../dtos/update-work-item.dto';
import type { Assignee, WorkItemDetailDto } from '../dtos/work-item-detail.dto';
@@ -9,6 +11,26 @@ import type { WorkItemFiltersDto } from '../dtos/work-item-filters.dto';
import type { WorkItemListItemDto } from '../dtos/work-item-list-item.dto';
import { UserSummaryDto } from "@/shared/dtos/user-summary.dto";
+function resolveAssignees(userIds?: string[]): Assignee[] {
+ if (!userIds?.length) return [];
+ return userIds
+ .map((uid) => mockUsers.find((u) => u.id === uid))
+ .filter((u): u is UserSummaryDto => !!u)
+ .map((user, i) => ({
+ id: `asg-${crypto.randomUUID()}`,
+ user,
+ role: i === 0 ? 'OWNER' : 'ASSIGNEE',
+ assignedAt: new Date().toISOString(),
+ } as Assignee));
+}
+
+function resolveTags(tagIds?: string[]) {
+ if (!tagIds?.length) return [];
+ return tagIds
+ .map((tid) => mockTags.find((t) => t.id === tid))
+ .filter(Boolean) as typeof mockTags;
+}
+
function toListItemDto(item: WorkItemDetailDto): WorkItemListItemDto {
return {
id: item.id,
@@ -121,8 +143,8 @@ export const workItemService = {
email: 'bernardo.manager@demo.com',
telegramUserId: 'tg_bernardo_manager'
},
- assignees: [],
- tags: [],
+ assignees: resolveAssignees(input.assigneeUserIds),
+ tags: resolveTags(input.tagIds),
featureDetails: input.featureDetails,
issueDetails: input.issueDetails,
bugDetails: input.bugDetails
@@ -145,9 +167,13 @@ export const workItemService = {
const current: WorkItemDetailDto = mockWorkItems[index];
+ const { assigneeUserIds, tagIds, ...rest } = input;
+
const updated: WorkItemDetailDto = {
...current,
- ...input,
+ ...rest,
+ assignees: assigneeUserIds !== undefined ? resolveAssignees(assigneeUserIds) : current.assignees,
+ tags: tagIds !== undefined ? resolveTags(tagIds) : current.tags,
updatedAt: new Date().toISOString(),
featureDetails: input.featureDetails ?? current.featureDetails,
issueDetails: input.issueDetails ?? current.issueDetails,
diff --git a/MtdrSpring/backend/src/main/frontend/tailwind.config.js b/MtdrSpring/backend/src/main/frontend/tailwind.config.js
new file mode 100644
index 000000000..84e50559c
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/tailwind.config.js
@@ -0,0 +1,11 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ './src/**/*.{js,jsx,ts,tsx}',
+ './public/index.html',
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
From 2fae25306084c38ec83bf9c075cfc2e42314c41d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 15 Apr 2026 20:53:04 +0000
Subject: [PATCH 17/28] fix: address code review - cleaner date comparison,
dedicated status text color helper, deterministic assignee IDs
Agent-Logs-Url: https://github.com/bernardosantiago44/talos_oci_devops_project/sessions/e230285c-35dc-4860-b9c9-a10c2761acb6
Co-authored-by: bernardosantiago44 <63428964+bernardosantiago44@users.noreply.github.com>
---
.../work-items/components/dashboard/kanban-view.tsx | 9 ++-------
.../src/features/work-items/lib/dashboard-ui.ts | 13 ++++++++++++-
.../work-items/services/work-item.service.ts | 2 +-
3 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/kanban-view.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/kanban-view.tsx
index 571696b0d..e5c7c0c2b 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/kanban-view.tsx
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/components/dashboard/kanban-view.tsx
@@ -6,10 +6,10 @@ import {
formatStatusLabel,
formatTypeLabel,
formatPriorityLabel,
- getStatusBadgeClasses,
getPriorityBadgeClasses,
getTypeBadgeClasses,
getStatusDotColor,
+ getStatusTextColor,
calcProgress,
isOverdue,
formatDate,
@@ -159,12 +159,7 @@ export function KanbanView({ items, onEdit, onComplete, onViewDetail }: KanbanVi
{/* Column header */}
-
c.startsWith('text-')) ?? 'text-zinc-300',
- )}
- >
+
{formatStatusLabel(status)}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
index 2a7bc145b..c7b732483 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
@@ -87,6 +87,16 @@ export function getTypeBadgeClasses(type: WorkItemType): string {
}
}
+export function getStatusTextColor(status: WorkItemStatus): string {
+ switch (status) {
+ case 'DONE': return 'text-emerald-300';
+ case 'BLOCKED': return 'text-rose-300';
+ case 'IN_PROGRESS': return 'text-sky-300';
+ case 'TODO': return 'text-zinc-400';
+ default: return 'text-zinc-300';
+ }
+}
+
export function getStatusDotColor(status: WorkItemStatus): string {
switch (status) {
case 'DONE': return 'bg-emerald-400';
@@ -104,7 +114,8 @@ export function calcProgress(logged: number, estimated?: number): number {
export function isOverdue(dueDate?: string, status?: WorkItemStatus): boolean {
if (!dueDate || status === 'DONE') return false;
- return new Date(dueDate) < new Date(new Date().toDateString());
+ const today = new Date().toISOString().slice(0, 10);
+ return dueDate < today;
}
export function formatDate(dateStr?: string): string {
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
index 2577faa37..d06f18e94 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
@@ -17,7 +17,7 @@ function resolveAssignees(userIds?: string[]): Assignee[] {
.map((uid) => mockUsers.find((u) => u.id === uid))
.filter((u): u is UserSummaryDto => !!u)
.map((user, i) => ({
- id: `asg-${crypto.randomUUID()}`,
+ id: `asg-${user.id}-${i}`,
user,
role: i === 0 ? 'OWNER' : 'ASSIGNEE',
assignedAt: new Date().toISOString(),
From 61b50bc20e89f8d7d87b002d92496ff1360495d5 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago
Date: Wed, 15 Apr 2026 20:28:02 -0600
Subject: [PATCH 18/28] Migrated page state to a viewModel
---
.../src/main/frontend/package-lock.json | 367 ------------------
.../pages/work-item-dashboard-page.tsx | 301 ++++----------
.../viewModels/useWorkItemsViewModel.ts | 159 ++++++++
3 files changed, 244 insertions(+), 583 deletions(-)
create mode 100644 MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
diff --git a/MtdrSpring/backend/src/main/frontend/package-lock.json b/MtdrSpring/backend/src/main/frontend/package-lock.json
index 8a45b839d..8ca352f2d 100644
--- a/MtdrSpring/backend/src/main/frontend/package-lock.json
+++ b/MtdrSpring/backend/src/main/frontend/package-lock.json
@@ -3793,263 +3793,6 @@
"url": "https://opencollective.com/popperjs"
}
},
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
- "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
- "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
- "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
- "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
- "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
- "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
- "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
- "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
- "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
- "cpu": [
- "s390x"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
- "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
- "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
- "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
- "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
- "cpu": [
- "wasm32"
- ],
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@emnapi/core": "1.9.2",
- "@emnapi/runtime": "1.9.2",
- "@napi-rs/wasm-runtime": "^1.1.3"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
- "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
- "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
@@ -21564,116 +21307,6 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
},
- "@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
- "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
- "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
- "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
- "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
- "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
- "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
- "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
- "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
- "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
- "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
- "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
- "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
- "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
- "optional": true,
- "peer": true,
- "requires": {
- "@emnapi/core": "1.9.2",
- "@emnapi/runtime": "1.9.2",
- "@napi-rs/wasm-runtime": "^1.1.3"
- }
- },
- "@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
- "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
- "optional": true,
- "peer": true
- },
- "@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
- "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
- "optional": true,
- "peer": true
- },
"@rolldown/pluginutils": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
index 1eb6abc65..5497bc7da 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
@@ -1,234 +1,103 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Layers } from 'lucide-react';
-import type { WorkItemDetailDto } from '../dtos/work-item-detail.dto';
-import type { CreateWorkItemDto } from '../dtos/create-work-item.dto';
-import type { UpdateWorkItemDto } from '../dtos/update-work-item.dto';
-import type { WorkItemStatus } from '../enums/work-item-status.enum';
-import { workItemService } from '../services/work-item.service';
import { mockUsers } from '@/shared/mock/users.mock';
import { mockTags } from '@/shared/mock/tags.mock';
import { DashboardSummaryCards } from '../components/dashboard/dashboard-summary-cards';
import { DashboardToolbar } from '../components/dashboard/dashboard-toolbar';
-import type { ViewMode } from '../components/dashboard/dashboard-toolbar';
import { WorkItemListView } from '../components/dashboard/work-item-list-view';
import { KanbanView } from '../components/dashboard/kanban-view';
import { WorkItemFormModal } from '../components/dashboard/work-item-form-modal';
import { WorkItemDetailModal } from '../components/dashboard/work-item-detail-modal';
+import { useWorkItemsViewModel, IWorkItemsViewModel } from "@/features/work-items/viewModels/useWorkItemsViewModel";
export function WorkItemDashboardPage() {
- const [items, setItems] = useState([]);
- const [loading, setLoading] = useState(true);
+ const viewModel: IWorkItemsViewModel = useWorkItemsViewModel();
- // Toolbar state
- const [search, setSearch] = useState('');
- const [statusFilter, setStatusFilter] = useState('');
- const [assigneeFilter, setAssigneeFilter] = useState('');
- const [viewMode, setViewMode] = useState('list');
+ return (
+
+
- // Modal state
- const [formOpen, setFormOpen] = useState(false);
- const [editingItem, setEditingItem] = useState
(null);
- const [detailItem, setDetailItem] = useState(null);
- const [detailOpen, setDetailOpen] = useState(false);
-
- // Load all items on mount (using the service so we stay in the service-layer contract)
- const loadItems = useCallback(async () => {
- setLoading(true);
- try {
- // Load a large page to get all items for the dashboard
- const result = await workItemService.getWorkItems({ page: 1, pageSize: 100 });
- if (result.success) {
- // getWorkItems returns WorkItemListItemDto[] but we need full detail for mutations.
- // Use the mock store directly by fetching each id — the service exposes getWorkItemById.
- const ids = result.data.items.map((i) => i.id);
- const details = await Promise.all(ids.map((id) => workItemService.getWorkItemById(id)));
- const fullItems = details
- .filter((r) => r.success && r.data !== null)
- .map((r) => r.data as WorkItemDetailDto);
- setItems(fullItems);
- }
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- loadItems();
- }, [loadItems]);
-
- // In-memory filtered items
- const filteredItems = useMemo(() => {
- return items.filter((item) => {
- const matchesSearch =
- !search ||
- item.title.toLowerCase().includes(search.toLowerCase()) ||
- (item.description ?? '').toLowerCase().includes(search.toLowerCase());
-
- const matchesStatus = !statusFilter || item.status === statusFilter;
-
- const matchesAssignee =
- !assigneeFilter ||
- item.assignees.some((a) => a.user.id === assigneeFilter);
-
- return matchesSearch && matchesStatus && matchesAssignee;
- });
- }, [items, search, statusFilter, assigneeFilter]);
-
- // Create handler
- const handleCreate = useCallback(async (dto: CreateWorkItemDto) => {
- const result = await workItemService.createWorkItem(dto);
- if (result.success) {
- setItems((prev) => [result.data, ...prev]);
- }
- }, []);
-
- // Update handler
- const handleUpdate = useCallback(async (id: string, dto: UpdateWorkItemDto) => {
- const result = await workItemService.updateWorkItem(id, dto);
- if (result.success && result.data) {
- const updated = result.data;
- setItems((prev) => prev.map((item) => (item.id === id ? updated : item)));
- // Refresh detail modal if open for this item
- if (detailItem?.id === id) {
- setDetailItem(updated);
- }
- }
- }, [detailItem]);
-
- // Complete handler
- const handleComplete = useCallback(async (item: WorkItemDetailDto) => {
- await handleUpdate(item.id, {
- status: 'DONE',
- completedAt: new Date().toISOString(),
- });
- }, [handleUpdate]);
-
- // Open edit
- const handleEdit = useCallback((item: WorkItemDetailDto) => {
- setEditingItem(item);
- setDetailOpen(false);
- setFormOpen(true);
- }, []);
-
- // Open detail
- const handleViewDetail = useCallback((item: WorkItemDetailDto) => {
- setDetailItem(item);
- setDetailOpen(true);
- }, []);
-
- // Close form
- const handleCloseForm = useCallback(() => {
- setFormOpen(false);
- setEditingItem(null);
- }, []);
-
- // Close detail
- const handleCloseDetail = useCallback(() => {
- setDetailOpen(false);
- setDetailItem(null);
- }, []);
-
- // Edit from detail modal
- const handleEditFromDetail = useCallback((item: WorkItemDetailDto) => {
- handleCloseDetail();
- handleEdit(item);
- }, [handleCloseDetail, handleEdit]);
-
- // Complete from detail modal
- const handleCompleteFromDetail = useCallback(async (item: WorkItemDetailDto) => {
- await handleComplete(item);
- handleCloseDetail();
- }, [handleComplete, handleCloseDetail]);
-
- return (
-
-
-
- {/* Page header */}
-
-
-
-
-
-
-
Work Items
-
Sprint 1 · Talos OCI DevOps Project
-
-
-
-
- {/* Summary cards */}
-
-
-
-
- {/* Toolbar */}
-
- {
- setEditingItem(null);
- setFormOpen(true);
- }}
- users={mockUsers}
- />
-
-
- {/* Results count */}
-
- {loading
- ? 'Loading…'
- : `${filteredItems.length} of ${items.length} task${items.length !== 1 ? 's' : ''}`}
-
-
- {/* View area */}
- {loading ? (
-
- ) : viewMode === 'list' ? (
-
- ) : (
-
- )}
+ {/* Page header */}
+
+
+
+
+
+
+
Work Items
+
Sprint 1 · Talos OCI DevOps Project
+
+
- {/* Create / Edit modal */}
-
+ {/* Summary cards */}
+
+
+
- {/* Detail preview modal */}
-
+ {/* Toolbar */}
+
+
- );
+
+ {/* Results count */}
+
+ {viewModel.loading
+ ? 'Loading…'
+ : `${viewModel.items.length} of ${viewModel.totalItemCount()} task${viewModel.totalItemCount() !== 1 ? 's' : ''}`}
+
+
+ {/* View area */}
+ {viewModel.loading ? (
+
+ ) : viewModel.viewMode === 'list' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Create / Edit modal */}
+
+
+ {/* Detail preview modal */}
+
+
+ );
}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
new file mode 100644
index 000000000..66aa87540
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
@@ -0,0 +1,159 @@
+import { useState, useCallback, useEffect, useMemo } from 'react';
+import { workItemService } from '../services/work-item.service';
+import { ViewMode } from "@/features/work-items/components/dashboard/dashboard-toolbar";
+import type { WorkItemDetailDto } from '../dtos/work-item-detail.dto';
+import type { CreateWorkItemDto } from '../dtos/create-work-item.dto';
+import type { UpdateWorkItemDto } from '../dtos/update-work-item.dto';
+import type { WorkItemStatus } from '../enums/work-item-status.enum';
+
+export const useWorkItemsViewModel = () => {
+ // 1. Data State
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // 2. UI State (Grouped logically)
+ const [filters, setFilters] = useState({
+ search: '',
+ status: '' as WorkItemStatus | '',
+ assignee: '',
+ });
+ const [viewMode, setViewMode] = useState('list');
+
+ // 3. Modal/Overlay State
+ const [modals, setModals] = useState({
+ formOpen: false,
+ detailOpen: false,
+ editingItem: null as WorkItemDetailDto | null,
+ detailItem: null as WorkItemDetailDto | null,
+ });
+
+ // --- Actions ---
+
+ const loadItems = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await workItemService.getWorkItems({ page: 1, pageSize: 100 });
+ if (result.success) {
+ const ids = result.data.items.map((i) => i.id);
+ const details = await Promise.all(ids.map((id) => workItemService.getWorkItemById(id)));
+ const fullItems = details
+ .filter((r) => r.success && r.data !== null)
+ .map((r) => r.data as WorkItemDetailDto);
+ setItems(fullItems);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { loadItems().then(); }, [loadItems]);
+
+ // Derived State (SwiftUI "Computed Properties")
+ const filteredItems = useMemo(() => {
+ return items.filter((item) => {
+ const matchesSearch = !filters.search ||
+ item.title.toLowerCase().includes(filters.search.toLowerCase()) ||
+ (item.description ?? '').toLowerCase().includes(filters.search.toLowerCase());
+ const matchesStatus = !filters.status || item.status === filters.status;
+ const matchesAssignee = !filters.assignee ||
+ item.assignees.some((a) => a.user.id === filters.assignee);
+
+ return matchesSearch && matchesStatus && matchesAssignee;
+ });
+ }, [items, filters]);
+
+ // --- Handlers ---
+ const handleCreate = async (dto: CreateWorkItemDto) => {
+ const result = await workItemService.createWorkItem(dto);
+ if (result.success) setItems((prev) => [result.data, ...prev]);
+ };
+
+ const handleUpdate = async (id: string, dto: UpdateWorkItemDto) => {
+ const result = await workItemService.updateWorkItem(id, dto);
+ if (result.success && result.data) {
+ const updated = result.data;
+ setItems((prev) => prev.map((item) => (item.id === id ? updated : item)));
+ if (modals.detailItem?.id === id) {
+ setModals(m => ({ ...m, detailItem: updated }));
+ }
+ }
+ };
+
+ const handleEdit = useCallback((item: WorkItemDetailDto) => {
+ setModals({
+ formOpen: true,
+ detailOpen: true,
+ editingItem: item,
+ detailItem: null // Perhaps this needs to be the item?
+ })
+ }, []);
+
+ const handleComplete = async (item: WorkItemDetailDto) => {
+ await handleUpdate(item.id, {
+ status: 'DONE',
+ completedAt: new Date().toISOString()
+ });
+ };
+
+ // UI Navigation / Modal Handlers
+ const openNew = () =>
+ setModals({ ...modals, editingItem: null, formOpen: true});
+
+ const openEdit = (item: WorkItemDetailDto) =>
+ setModals({ ...modals, editingItem: item, formOpen: true, detailOpen: false });
+
+ const openDetail = (item: WorkItemDetailDto) =>
+ setModals({ ...modals, detailItem: item, detailOpen: true });
+
+ const closeAll = () =>
+ setModals({ ...modals, formOpen: false, detailOpen: false, editingItem: null, detailItem: null });
+
+ const handleEditFromDetail = useCallback((item: WorkItemDetailDto) => {
+ closeAll();
+ handleEdit(item);
+ }, [handleEdit, closeAll]);
+
+ const handleCompleteFromDetail = useCallback(async (item: WorkItemDetailDto) => {
+ await handleComplete(item);
+ closeAll();
+ }, [handleEdit, closeAll]);
+
+ // --- Final API ---
+ return {
+ // Data
+ items: filteredItems,
+ totalItemCount: () => { return items.length },
+ loading,
+ viewMode,
+ setViewMode,
+
+ // UI State
+ search: filters.search,
+ statusFilter: filters.status,
+ assigneeFilter: filters.assignee,
+ setSearch: (search: string) => setFilters(f => ({ ...f, search })),
+ setStatusFilter: (status: WorkItemStatus | '') => setFilters(f => ({ ...f, status })),
+ setAssigneeFilter: (assignee: string) => setFilters(f => ({ ...f, assignee })),
+
+ // Modal state
+ ...modals,
+ editingItem: modals.editingItem,
+
+ // Actions
+ actions: {
+ loadItems,
+ openNew,
+ handleCreate,
+ handleUpdate,
+ handleComplete,
+ handleEdit,
+ handleEditFromDetail,
+ handleCompleteFromDetail,
+ openEdit,
+ openDetail,
+ closeAll
+ }
+ };
+};
+
+export type IWorkItemsViewModel = ReturnType;
\ No newline at end of file
From 1ac9f80cef8ceb7b6dce09fb5d016d418bb6e926 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago <63428964+bernardosantiago44@users.noreply.github.com>
Date: Wed, 15 Apr 2026 20:30:49 -0600
Subject: [PATCH 19/28] Add guideline for using viewModel in complex UIs
Emphasize using viewModel for complex pages to manage state effectively.
---
.github/agents/frontend-ui-developer.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/agents/frontend-ui-developer.md b/.github/agents/frontend-ui-developer.md
index e25dd9d11..3b954526d 100644
--- a/.github/agents/frontend-ui-developer.md
+++ b/.github/agents/frontend-ui-developer.md
@@ -46,6 +46,7 @@ You must not:
- Support loading, empty, error, and populated states where relevant.
- Keep accessibility in mind: semantic HTML, labels, keyboard navigation, and sensible contrast.
- Keep styling consistent with the project’s Tailwind and design patterns.
+- For complex pages or heavy state-based interfaces, prefer to use a viewModel in a separate file to avoid big useState soups.
## UI expectations
- Design for clarity first, then polish.
From e875ddd4d873a5217a104ff89d0c20f96d7334d7 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago <63428964+bernardosantiago44@users.noreply.github.com>
Date: Wed, 15 Apr 2026 20:40:11 -0600
Subject: [PATCH 20/28] Update
MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../work-items/viewModels/useWorkItemsViewModel.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
index 66aa87540..e774689c0 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
@@ -81,11 +81,11 @@ export const useWorkItemsViewModel = () => {
const handleEdit = useCallback((item: WorkItemDetailDto) => {
setModals({
- formOpen: true,
- detailOpen: true,
- editingItem: item,
- detailItem: null // Perhaps this needs to be the item?
- })
+ formOpen: true,
+ detailOpen: false,
+ editingItem: item,
+ detailItem: null,
+ });
}, []);
const handleComplete = async (item: WorkItemDetailDto) => {
From 9c655d211750d639d70cf671c11ea17378d80c72 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago <63428964+bernardosantiago44@users.noreply.github.com>
Date: Wed, 15 Apr 2026 20:40:39 -0600
Subject: [PATCH 21/28] Update
MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../src/features/work-items/viewModels/useWorkItemsViewModel.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
index e774689c0..3fd872df0 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
@@ -116,7 +116,7 @@ export const useWorkItemsViewModel = () => {
const handleCompleteFromDetail = useCallback(async (item: WorkItemDetailDto) => {
await handleComplete(item);
closeAll();
- }, [handleEdit, closeAll]);
+ }, [handleComplete, closeAll]);
// --- Final API ---
return {
From 3fefe276b86d02f6badaf8df70441dc0b93860b6 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago <63428964+bernardosantiago44@users.noreply.github.com>
Date: Wed, 15 Apr 2026 20:41:47 -0600
Subject: [PATCH 22/28] Update
MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../src/features/work-items/services/work-item.service.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
index d06f18e94..04eb8524c 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
@@ -24,11 +24,11 @@ function resolveAssignees(userIds?: string[]): Assignee[] {
} as Assignee));
}
-function resolveTags(tagIds?: string[]) {
+function resolveTags(tagIds?: string[]): Array<(typeof mockTags)[number]> {
if (!tagIds?.length) return [];
return tagIds
.map((tid) => mockTags.find((t) => t.id === tid))
- .filter(Boolean) as typeof mockTags;
+ .filter((tag): tag is (typeof mockTags)[number] => !!tag);
}
function toListItemDto(item: WorkItemDetailDto): WorkItemListItemDto {
From 882c9833d9f98614e0d96da4b4830f0f91278d82 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago
Date: Wed, 15 Apr 2026 20:47:25 -0600
Subject: [PATCH 23/28] Changed import type bugs
---
.../src/features/work-items/pages/work-item-dashboard-page.tsx | 3 ++-
.../src/features/work-items/services/work-item.service.ts | 2 +-
.../features/work-items/viewModels/useWorkItemsViewModel.ts | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
index 5497bc7da..dad939c78 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
@@ -7,7 +7,8 @@ import { WorkItemListView } from '../components/dashboard/work-item-list-view';
import { KanbanView } from '../components/dashboard/kanban-view';
import { WorkItemFormModal } from '../components/dashboard/work-item-form-modal';
import { WorkItemDetailModal } from '../components/dashboard/work-item-detail-modal';
-import { useWorkItemsViewModel, IWorkItemsViewModel } from "@/features/work-items/viewModels/useWorkItemsViewModel";
+import { useWorkItemsViewModel } from "@/features/work-items/viewModels/useWorkItemsViewModel";
+import type { IWorkItemsViewModel } from "@/features/work-items/viewModels/useWorkItemsViewModel";
export function WorkItemDashboardPage() {
const viewModel: IWorkItemsViewModel = useWorkItemsViewModel();
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
index 04eb8524c..2159f9876 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/services/work-item.service.ts
@@ -9,7 +9,7 @@ import type { UpdateWorkItemDto } from '../dtos/update-work-item.dto';
import type { Assignee, WorkItemDetailDto } from '../dtos/work-item-detail.dto';
import type { WorkItemFiltersDto } from '../dtos/work-item-filters.dto';
import type { WorkItemListItemDto } from '../dtos/work-item-list-item.dto';
-import { UserSummaryDto } from "@/shared/dtos/user-summary.dto";
+import type { UserSummaryDto } from "@/shared/dtos/user-summary.dto";
function resolveAssignees(userIds?: string[]): Assignee[] {
if (!userIds?.length) return [];
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
index 3fd872df0..334a566f3 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { workItemService } from '../services/work-item.service';
-import { ViewMode } from "@/features/work-items/components/dashboard/dashboard-toolbar";
+import type { ViewMode } from "@/features/work-items/components/dashboard/dashboard-toolbar";
import type { WorkItemDetailDto } from '../dtos/work-item-detail.dto';
import type { CreateWorkItemDto } from '../dtos/create-work-item.dto';
import type { UpdateWorkItemDto } from '../dtos/update-work-item.dto';
From fa138f896a6ee63dbbc138d8d7972c38ed2ad3e9 Mon Sep 17 00:00:00 2001
From: Bernardo Santiago
Date: Thu, 16 Apr 2026 10:09:20 -0600
Subject: [PATCH 24/28] Reduced nested data objects
---
.../work-items/dtos/work-item-detail.dto.ts | 23 ++++++-------------
1 file changed, 7 insertions(+), 16 deletions(-)
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/dtos/work-item-detail.dto.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/dtos/work-item-detail.dto.ts
index ea0464cbf..abddc34bb 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/dtos/work-item-detail.dto.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/dtos/work-item-detail.dto.ts
@@ -1,10 +1,12 @@
import type { TagDto } from '@/shared/dtos/tag.dto';
import type { UserSummaryDto } from '@/shared/dtos/user-summary.dto';
-import type { BugSeverity } from '../enums/bug-severity.enum';
import type { AssignmentRole } from '../enums/assignment-role.enum';
import type { WorkItemPriority } from '../enums/work-item-priority.enum';
import type { WorkItemStatus } from '../enums/work-item-status.enum';
import type { WorkItemType } from '../enums/work-item-type.enum';
+import type { FeatureDetails } from "@/features/work-items/model/feature-details.model";
+import type { IssueDetails } from "@/features/work-items/model/issue-details.model";
+import type { BugDetails } from "@/features/work-items/model/bug-details.model";
export type WorkItemDetailDto = {
id: string;
@@ -22,22 +24,11 @@ export type WorkItemDetailDto = {
updatedAt: string;
completedAt?: string;
createdBy: UserSummaryDto;
- assignees: Array;
+ assignees: Assignee[];
tags: TagDto[];
- featureDetails?: {
- businessValue?: string;
- acceptanceCriteria?: string;
- };
- issueDetails?: {
- environment?: string;
- reproductionSteps?: string;
- };
- bugDetails?: {
- severity?: BugSeverity;
- environment?: string;
- isReproducible?: boolean;
- steps?: string;
- };
+ featureDetails?: FeatureDetails;
+ issueDetails?: IssueDetails;
+ bugDetails?: BugDetails;
};
export type Assignee = {
From fee5880332659188e40ea9bb580fece7e491f284 Mon Sep 17 00:00:00 2001
From: Jusypablo13
Date: Sun, 19 Apr 2026 12:58:25 -0600
Subject: [PATCH 25/28] feat: add WorkItem, Sprint, AppUser domain models,
repositories, and API controllers
- Models: AppUser, Sprint, WorkItem, WorkItemAssignment (JPA entities for CHATBOT_USER schema)
- Repositories: JPA repos for all new entities
- Controllers: WorkItemController, SprintController, AppUserController, AnalyticsController, TimeEntryController
- All endpoints use JdbcTemplate for direct Oracle queries
---
.../controller/AnalyticsController.java | 83 +++++++++++++
.../controller/AppUserController.java | 24 ++++
.../controller/SprintController.java | 25 ++++
.../controller/TimeEntryController.java | 35 ++++++
.../controller/WorkItemController.java | 114 ++++++++++++++++++
.../springboot/MyTodoList/model/AppUser.java | 42 +++++++
.../springboot/MyTodoList/model/Sprint.java | 67 ++++++++++
.../springboot/MyTodoList/model/WorkItem.java | 97 +++++++++++++++
.../MyTodoList/model/WorkItemAssignment.java | 54 +++++++++
.../repository/AppUserRepository.java | 12 ++
.../repository/SprintRepository.java | 9 ++
.../WorkItemAssignmentRepository.java | 13 ++
.../repository/WorkItemRepository.java | 9 ++
13 files changed, 584 insertions(+)
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AnalyticsController.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AppUserController.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/SprintController.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/TimeEntryController.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/WorkItemController.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/AppUser.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/Sprint.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/WorkItem.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/WorkItemAssignment.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/AppUserRepository.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/SprintRepository.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/WorkItemAssignmentRepository.java
create mode 100644 MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/WorkItemRepository.java
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AnalyticsController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AnalyticsController.java
new file mode 100644
index 000000000..fbd0a5660
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AnalyticsController.java
@@ -0,0 +1,83 @@
+package com.springboot.MyTodoList.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.*;
+
+@RestController
+public class AnalyticsController {
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @GetMapping("/analytics/dashboard")
+ public Map getDashboardData() {
+
+ String sql =
+ "SELECT " +
+ " u.NAME AS developer, " +
+ " s.NAME AS sprint_name, " +
+ " COUNT(DISTINCT wi.WORK_ITEM_ID) AS tasks_completed, " +
+ " NVL(SUM(te.sprint_minutes), 0) / 60.0 AS real_hours " +
+ "FROM CHATBOT_USER.WORK_ITEM wi " +
+ "JOIN CHATBOT_USER.WORK_ITEM_ASSIGNMENT wia ON wi.WORK_ITEM_ID = wia.WORK_ITEM_ID " +
+ "JOIN CHATBOT_USER.APP_USER u ON wia.USER_ID = u.USER_ID " +
+ "JOIN CHATBOT_USER.SPRINT s ON wi.SPRINT_ID = s.SPRINT_ID " +
+ "LEFT JOIN ( " +
+ " SELECT WORK_ITEM_ID, SUM(MINUTES) AS sprint_minutes " +
+ " FROM CHATBOT_USER.TIME_ENTRY " +
+ " GROUP BY WORK_ITEM_ID " +
+ ") te ON wi.WORK_ITEM_ID = te.WORK_ITEM_ID " +
+ "WHERE wi.STATUS IN ('DONE', 'COMPLETED', 'CLOSED') " +
+ "GROUP BY u.NAME, s.NAME " +
+ "ORDER BY u.NAME, s.NAME";
+
+ List> rows = jdbcTemplate.queryForList(sql);
+
+ // ── KPIs ──────────────────────────────────────────────────────────────
+ long totalTasks = rows.stream()
+ .mapToLong(r -> ((Number) r.get("TASKS_COMPLETED")).longValue())
+ .sum();
+ double totalHours = rows.stream()
+ .mapToDouble(r -> ((Number) r.get("REAL_HOURS")).doubleValue())
+ .sum();
+
+ Set devNames = new LinkedHashSet<>();
+ rows.forEach(r -> devNames.add((String) r.get("DEVELOPER")));
+ int numDevs = devNames.isEmpty() ? 1 : devNames.size();
+
+ double avgTasks = (double) totalTasks / numDevs;
+ double avgHours = totalHours / numDevs;
+
+ Map kpis = new LinkedHashMap<>();
+ kpis.put("totalTasks", totalTasks);
+ kpis.put("totalHours", round1(totalHours));
+ kpis.put("avgTasksPerDev", round1(avgTasks));
+ kpis.put("avgHoursPerDev", round1(avgHours));
+
+ // ── Response ──────────────────────────────────────────────────────────
+ Map result = new LinkedHashMap<>();
+ result.put("kpis", kpis);
+ result.put("chartData", rows);
+ return result;
+ }
+
+ @GetMapping("/analytics/debug")
+ public Map debug() {
+ Map result = new java.util.LinkedHashMap<>();
+ result.put("workItems", jdbcTemplate.queryForList(
+ "SELECT WORK_ITEM_ID, TITLE, STATUS, SPRINT_ID FROM CHATBOT_USER.WORK_ITEM WHERE WORK_ITEM_ID LIKE 'wi-d%' ORDER BY WORK_ITEM_ID"));
+ result.put("assignments", jdbcTemplate.queryForList(
+ "SELECT ASSIGNMENT_ID, WORK_ITEM_ID, USER_ID FROM CHATBOT_USER.WORK_ITEM_ASSIGNMENT WHERE WORK_ITEM_ID LIKE 'wi-d%' ORDER BY ASSIGNMENT_ID"));
+ result.put("timeEntries", jdbcTemplate.queryForList(
+ "SELECT TIME_ENTRY_ID, WORK_ITEM_ID, MINUTES FROM CHATBOT_USER.TIME_ENTRY WHERE WORK_ITEM_ID LIKE 'wi-d%' ORDER BY TIME_ENTRY_ID"));
+ return result;
+ }
+
+ private double round1(double v) {
+ return Math.round(v * 10.0) / 10.0;
+ }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AppUserController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AppUserController.java
new file mode 100644
index 000000000..3f558cdc8
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AppUserController.java
@@ -0,0 +1,24 @@
+package com.springboot.MyTodoList.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/appusers")
+public class AppUserController {
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @GetMapping
+ public List> getAll() {
+ return jdbcTemplate.queryForList(
+ "SELECT USER_ID AS \"userId\", NAME AS \"name\", EMAIL AS \"email\", " +
+ "TELEGRAM_USER_ID AS \"telegramUserId\" FROM CHATBOT_USER.APP_USER ORDER BY NAME"
+ );
+ }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/SprintController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/SprintController.java
new file mode 100644
index 000000000..24069c9ec
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/SprintController.java
@@ -0,0 +1,25 @@
+package com.springboot.MyTodoList.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/sprints")
+public class SprintController {
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @GetMapping
+ public List> getAll() {
+ return jdbcTemplate.queryForList(
+ "SELECT SPRINT_ID AS \"sprintId\", NAME AS \"name\", STATUS AS \"status\", " +
+ "START_DATE AS \"startDate\", END_DATE AS \"endDate\" " +
+ "FROM CHATBOT_USER.SPRINT ORDER BY START_DATE DESC"
+ );
+ }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/TimeEntryController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/TimeEntryController.java
new file mode 100644
index 000000000..bd23f0f21
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/TimeEntryController.java
@@ -0,0 +1,35 @@
+package com.springboot.MyTodoList.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/time-entries")
+public class TimeEntryController {
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @PostMapping
+ public ResponseEntity> logTime(@RequestBody Map body) {
+ String workItemId = (String) body.get("workItemId");
+ String userId = (String) body.get("userId");
+ int minutes = ((Number) body.getOrDefault("minutes", 0)).intValue();
+ String note = (String) body.getOrDefault("note", "");
+
+ String id = UUID.randomUUID().toString();
+ jdbcTemplate.update(
+ "INSERT INTO CHATBOT_USER.TIME_ENTRY " +
+ "(TIME_ENTRY_ID, WORK_ITEM_ID, USER_ID, MINUTES, STARTED_AT, ENDED_AT, CREATED_AT, NOTE) " +
+ "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)",
+ id, workItemId, userId, minutes, note
+ );
+
+ return ResponseEntity.ok(Map.of("timeEntryId", id, "minutes", minutes));
+ }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/WorkItemController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/WorkItemController.java
new file mode 100644
index 000000000..9565b4ce0
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/WorkItemController.java
@@ -0,0 +1,114 @@
+package com.springboot.MyTodoList.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/workitems")
+public class WorkItemController {
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ // GET /workitems — todas las tasks con info de usuario y sprint
+ @GetMapping
+ public List> getAllWorkItems() {
+ String sql =
+ "SELECT wi.WORK_ITEM_ID, wi.TITLE, wi.DESCRIPTION, wi.STATUS, wi.PRIORITY, " +
+ " wi.WORK_TYPE, wi.DUE_DATE, wi.CREATED_AT, wi.ESTIMATED_MINUTES, " +
+ " s.NAME AS SPRINT_NAME, wi.SPRINT_ID, " +
+ " u.NAME AS ASSIGNEE_NAME, u.USER_ID AS ASSIGNEE_ID " +
+ "FROM CHATBOT_USER.WORK_ITEM wi " +
+ "LEFT JOIN CHATBOT_USER.SPRINT s ON wi.SPRINT_ID = s.SPRINT_ID " +
+ "LEFT JOIN CHATBOT_USER.WORK_ITEM_ASSIGNMENT wia ON wi.WORK_ITEM_ID = wia.WORK_ITEM_ID " +
+ " AND wia.UNASSIGNED_AT IS NULL " +
+ "LEFT JOIN CHATBOT_USER.APP_USER u ON wia.USER_ID = u.USER_ID " +
+ "ORDER BY wi.CREATED_AT DESC";
+ return jdbcTemplate.queryForList(sql);
+ }
+
+ // GET /workitems/user/{telegramUserId} — tasks de un usuario por su Telegram ID (para el bot)
+ @GetMapping("/user/{telegramUserId}")
+ public List> getWorkItemsByTelegramUser(@PathVariable String telegramUserId) {
+ String sql =
+ "SELECT wi.WORK_ITEM_ID, wi.TITLE, wi.STATUS, wi.PRIORITY, wi.DUE_DATE, " +
+ " s.NAME AS SPRINT_NAME " +
+ "FROM CHATBOT_USER.WORK_ITEM wi " +
+ "JOIN CHATBOT_USER.WORK_ITEM_ASSIGNMENT wia ON wi.WORK_ITEM_ID = wia.WORK_ITEM_ID " +
+ " AND wia.UNASSIGNED_AT IS NULL " +
+ "JOIN CHATBOT_USER.APP_USER u ON wia.USER_ID = u.USER_ID " +
+ "LEFT JOIN CHATBOT_USER.SPRINT s ON wi.SPRINT_ID = s.SPRINT_ID " +
+ "WHERE u.TELEGRAM_USER_ID = ? " +
+ "ORDER BY wi.CREATED_AT DESC";
+ return jdbcTemplate.queryForList(sql, telegramUserId);
+ }
+
+ // POST /workitems — crear task y asignarla a un usuario
+ @PostMapping
+ public ResponseEntity> createWorkItem(@RequestBody Map body) {
+ String workItemId = UUID.randomUUID().toString();
+ String title = (String) body.get("title");
+ String description = (String) body.getOrDefault("description", "");
+ String workType = body.getOrDefault("workType", "TASK").toString();
+ String priority = body.getOrDefault("priority", "MEDIUM").toString();
+ String sprintId = (String) body.get("sprintId");
+ String createdBy = body.getOrDefault("createdByUserId", "u-001").toString();
+ String dueDate = (String) body.get("dueDateStr");
+
+ jdbcTemplate.update(
+ "INSERT INTO CHATBOT_USER.WORK_ITEM " +
+ "(WORK_ITEM_ID, TITLE, DESCRIPTION, WORK_TYPE, STATUS, PRIORITY, SPRINT_ID, " +
+ " CREATED_BY_USER_ID, CREATED_AT, UPDATED_AT, DUE_DATE) " +
+ "VALUES (?, ?, ?, ?, 'NEW', ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, " +
+ " CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END)",
+ workItemId, title, description, workType, priority, sprintId,
+ createdBy, dueDate, dueDate
+ );
+
+ String assigneeId = (String) body.get("assigneeUserId");
+ if (assigneeId != null && !assigneeId.isBlank()) {
+ jdbcTemplate.update(
+ "INSERT INTO CHATBOT_USER.WORK_ITEM_ASSIGNMENT " +
+ "(ASSIGNMENT_ID, WORK_ITEM_ID, USER_ID, ASSIGNMENT_ROLE, ASSIGNED_AT) " +
+ "VALUES (?, ?, ?, 'ASSIGNEE', CURRENT_TIMESTAMP)",
+ UUID.randomUUID().toString(), workItemId, assigneeId
+ );
+ }
+
+ return ResponseEntity.ok(Map.of("workItemId", workItemId, "status", "created"));
+ }
+
+ // DELETE /workitems/{id} — eliminar task
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteWorkItem(@PathVariable String id) {
+ jdbcTemplate.update("DELETE FROM CHATBOT_USER.WORK_ITEM_ASSIGNMENT WHERE WORK_ITEM_ID = ?", id);
+ int rows = jdbcTemplate.update("DELETE FROM CHATBOT_USER.WORK_ITEM WHERE WORK_ITEM_ID = ?", id);
+ if (rows == 0) return ResponseEntity.notFound().build();
+ return ResponseEntity.noContent().build();
+ }
+
+ // PUT /workitems/{id}/status — cambiar status
+ @PutMapping("/{id}/status")
+ public ResponseEntity> updateStatus(@PathVariable String id,
+ @RequestBody Map body) {
+ String newStatus = body.get("status");
+ if ("TODO".equals(newStatus)) newStatus = "NEW";
+ boolean isDone = "DONE".equals(newStatus) || "COMPLETED".equals(newStatus);
+
+ int rows = jdbcTemplate.update(
+ "UPDATE CHATBOT_USER.WORK_ITEM SET STATUS = ?, UPDATED_AT = CURRENT_TIMESTAMP, " +
+ "COMPLETED_AT = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END " +
+ "WHERE WORK_ITEM_ID = ?",
+ newStatus, isDone ? 1 : 0, id
+ );
+
+ if (rows == 0) return ResponseEntity.notFound().build();
+ return ResponseEntity.ok(Map.of("workItemId", id, "status", newStatus));
+ }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/AppUser.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/AppUser.java
new file mode 100644
index 000000000..93ef00514
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/AppUser.java
@@ -0,0 +1,42 @@
+package com.springboot.MyTodoList.model;
+
+import jakarta.persistence.*;
+import java.time.OffsetDateTime;
+
+@Entity
+@Table(name = "APP_USER", schema = "CHATBOT_USER")
+public class AppUser {
+
+ @Id
+ @Column(name = "USER_ID")
+ private String userId;
+
+ @Column(name = "NAME")
+ private String name;
+
+ @Column(name = "EMAIL")
+ private String email;
+
+ @Column(name = "TELEGRAM_USER_ID")
+ private String telegramUserId;
+
+ @Column(name = "CREATED_AT")
+ private OffsetDateTime createdAt;
+
+ public AppUser() {}
+
+ public String getUserId() { return userId; }
+ public void setUserId(String userId) { this.userId = userId; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getEmail() { return email; }
+ public void setEmail(String email) { this.email = email; }
+
+ public String getTelegramUserId() { return telegramUserId; }
+ public void setTelegramUserId(String telegramUserId) { this.telegramUserId = telegramUserId; }
+
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/Sprint.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/Sprint.java
new file mode 100644
index 000000000..9ee7edc1b
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/Sprint.java
@@ -0,0 +1,67 @@
+package com.springboot.MyTodoList.model;
+
+import jakarta.persistence.*;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+
+@Entity
+@Table(name = "SPRINT", schema = "CHATBOT_USER")
+public class Sprint {
+
+ @Id
+ @Column(name = "SPRINT_ID")
+ private String sprintId;
+
+ @Column(name = "TEAM_ID")
+ private String teamId;
+
+ @Column(name = "NAME")
+ private String name;
+
+ @Column(name = "GOAL")
+ private String goal;
+
+ @Column(name = "START_DATE")
+ private LocalDate startDate;
+
+ @Column(name = "END_DATE")
+ private LocalDate endDate;
+
+ @Column(name = "STATUS")
+ private String status;
+
+ @Column(name = "CREATED_AT")
+ private OffsetDateTime createdAt;
+
+ @Column(name = "CREATED_BY_USER_ID")
+ private String createdByUserId;
+
+ public Sprint() {}
+
+ public String getSprintId() { return sprintId; }
+ public void setSprintId(String sprintId) { this.sprintId = sprintId; }
+
+ public String getTeamId() { return teamId; }
+ public void setTeamId(String teamId) { this.teamId = teamId; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getGoal() { return goal; }
+ public void setGoal(String goal) { this.goal = goal; }
+
+ public LocalDate getStartDate() { return startDate; }
+ public void setStartDate(LocalDate startDate) { this.startDate = startDate; }
+
+ public LocalDate getEndDate() { return endDate; }
+ public void setEndDate(LocalDate endDate) { this.endDate = endDate; }
+
+ public String getStatus() { return status; }
+ public void setStatus(String status) { this.status = status; }
+
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
+
+ public String getCreatedByUserId() { return createdByUserId; }
+ public void setCreatedByUserId(String createdByUserId) { this.createdByUserId = createdByUserId; }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/WorkItem.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/WorkItem.java
new file mode 100644
index 000000000..dcf271fb9
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/WorkItem.java
@@ -0,0 +1,97 @@
+package com.springboot.MyTodoList.model;
+
+import jakarta.persistence.*;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+
+@Entity
+@Table(name = "WORK_ITEM", schema = "CHATBOT_USER")
+public class WorkItem {
+
+ @Id
+ @Column(name = "WORK_ITEM_ID")
+ private String workItemId;
+
+ @Column(name = "SPRINT_ID")
+ private String sprintId;
+
+ @Column(name = "CREATED_BY_USER_ID")
+ private String createdByUserId;
+
+ @Column(name = "WORK_TYPE")
+ private String workType;
+
+ @Column(name = "TITLE")
+ private String title;
+
+ @Column(name = "DESCRIPTION", columnDefinition = "CLOB")
+ private String description;
+
+ @Column(name = "STATUS")
+ private String status;
+
+ @Column(name = "PRIORITY")
+ private String priority;
+
+ @Column(name = "EXTERNAL_LINK")
+ private String externalLink;
+
+ @Column(name = "ESTIMATED_MINUTES")
+ private Integer estimatedMinutes;
+
+ @Column(name = "DUE_DATE")
+ private LocalDate dueDate;
+
+ @Column(name = "CREATED_AT")
+ private OffsetDateTime createdAt;
+
+ @Column(name = "UPDATED_AT")
+ private OffsetDateTime updatedAt;
+
+ @Column(name = "COMPLETED_AT")
+ private OffsetDateTime completedAt;
+
+ public WorkItem() {}
+
+ public String getWorkItemId() { return workItemId; }
+ public void setWorkItemId(String workItemId) { this.workItemId = workItemId; }
+
+ public String getSprintId() { return sprintId; }
+ public void setSprintId(String sprintId) { this.sprintId = sprintId; }
+
+ public String getCreatedByUserId() { return createdByUserId; }
+ public void setCreatedByUserId(String createdByUserId) { this.createdByUserId = createdByUserId; }
+
+ public String getWorkType() { return workType; }
+ public void setWorkType(String workType) { this.workType = workType; }
+
+ public String getTitle() { return title; }
+ public void setTitle(String title) { this.title = title; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public String getStatus() { return status; }
+ public void setStatus(String status) { this.status = status; }
+
+ public String getPriority() { return priority; }
+ public void setPriority(String priority) { this.priority = priority; }
+
+ public String getExternalLink() { return externalLink; }
+ public void setExternalLink(String externalLink) { this.externalLink = externalLink; }
+
+ public Integer getEstimatedMinutes() { return estimatedMinutes; }
+ public void setEstimatedMinutes(Integer estimatedMinutes) { this.estimatedMinutes = estimatedMinutes; }
+
+ public LocalDate getDueDate() { return dueDate; }
+ public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
+
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
+
+ public OffsetDateTime getUpdatedAt() { return updatedAt; }
+ public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
+
+ public OffsetDateTime getCompletedAt() { return completedAt; }
+ public void setCompletedAt(OffsetDateTime completedAt) { this.completedAt = completedAt; }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/WorkItemAssignment.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/WorkItemAssignment.java
new file mode 100644
index 000000000..cd54749c4
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/model/WorkItemAssignment.java
@@ -0,0 +1,54 @@
+package com.springboot.MyTodoList.model;
+
+import jakarta.persistence.*;
+import java.time.OffsetDateTime;
+
+@Entity
+@Table(name = "WORK_ITEM_ASSIGNMENT", schema = "CHATBOT_USER")
+public class WorkItemAssignment {
+
+ @Id
+ @Column(name = "ASSIGNMENT_ID")
+ private String assignmentId;
+
+ @Column(name = "WORK_ITEM_ID")
+ private String workItemId;
+
+ @Column(name = "USER_ID")
+ private String userId;
+
+ @Column(name = "ASSIGNMENT_ROLE")
+ private String assignmentRole;
+
+ @Column(name = "ASSIGNED_AT")
+ private OffsetDateTime assignedAt;
+
+ @Column(name = "UNASSIGNED_AT")
+ private OffsetDateTime unassignedAt;
+
+ @Column(name = "ASSIGNED_BY_USER_ID")
+ private String assignedByUserId;
+
+ public WorkItemAssignment() {}
+
+ public String getAssignmentId() { return assignmentId; }
+ public void setAssignmentId(String assignmentId) { this.assignmentId = assignmentId; }
+
+ public String getWorkItemId() { return workItemId; }
+ public void setWorkItemId(String workItemId) { this.workItemId = workItemId; }
+
+ public String getUserId() { return userId; }
+ public void setUserId(String userId) { this.userId = userId; }
+
+ public String getAssignmentRole() { return assignmentRole; }
+ public void setAssignmentRole(String assignmentRole) { this.assignmentRole = assignmentRole; }
+
+ public OffsetDateTime getAssignedAt() { return assignedAt; }
+ public void setAssignedAt(OffsetDateTime assignedAt) { this.assignedAt = assignedAt; }
+
+ public OffsetDateTime getUnassignedAt() { return unassignedAt; }
+ public void setUnassignedAt(OffsetDateTime unassignedAt) { this.unassignedAt = unassignedAt; }
+
+ public String getAssignedByUserId() { return assignedByUserId; }
+ public void setAssignedByUserId(String assignedByUserId) { this.assignedByUserId = assignedByUserId; }
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/AppUserRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/AppUserRepository.java
new file mode 100644
index 000000000..448cd7933
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/AppUserRepository.java
@@ -0,0 +1,12 @@
+package com.springboot.MyTodoList.repository;
+
+import com.springboot.MyTodoList.model.AppUser;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface AppUserRepository extends JpaRepository {
+ Optional findByTelegramUserId(String telegramUserId);
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/SprintRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/SprintRepository.java
new file mode 100644
index 000000000..1b916375b
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/SprintRepository.java
@@ -0,0 +1,9 @@
+package com.springboot.MyTodoList.repository;
+
+import com.springboot.MyTodoList.model.Sprint;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface SprintRepository extends JpaRepository {
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/WorkItemAssignmentRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/WorkItemAssignmentRepository.java
new file mode 100644
index 000000000..ec971340c
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/WorkItemAssignmentRepository.java
@@ -0,0 +1,13 @@
+package com.springboot.MyTodoList.repository;
+
+import com.springboot.MyTodoList.model.WorkItemAssignment;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface WorkItemAssignmentRepository extends JpaRepository {
+ List findByWorkItemId(String workItemId);
+ List findByUserId(String userId);
+}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/WorkItemRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/WorkItemRepository.java
new file mode 100644
index 000000000..70b1f8c83
--- /dev/null
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/WorkItemRepository.java
@@ -0,0 +1,9 @@
+package com.springboot.MyTodoList.repository;
+
+import com.springboot.MyTodoList.model.WorkItem;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface WorkItemRepository extends JpaRepository {
+}
From e640d9691d899fcea4b1e474ddb6c51529628930 Mon Sep 17 00:00:00 2001
From: Jusypablo13
Date: Sun, 19 Apr 2026 12:59:07 -0600
Subject: [PATCH 26/28] feat: update bot controller, OracleConfig, DeepSeek
service for WorkItem integration
- OracleConfiguration: use DbSettings pattern
- ToDoItemBotController: add JdbcTemplate injection for WorkItem queries
- BotActions: query WORK_ITEM table instead of TODOITEM only
- application.properties: sanitize hardcoded paths, externalize secrets
- env.sh: add OCI_USER_OCID
- containerengine.tf: upgrade K8s to v1.34.2
---
.../config/OracleConfiguration.java | 41 +++------
.../controller/ToDoItemBotController.java | 12 ++-
.../MyTodoList/service/DeepSeekService.java | 15 +--
.../MyTodoList/util/BotActions.java | 92 +++++++++----------
.../src/main/resources/application.properties | 14 +--
MtdrSpring/env.sh | 4 +
MtdrSpring/terraform/containerengine.tf | 17 +---
7 files changed, 83 insertions(+), 112 deletions(-)
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/OracleConfiguration.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/OracleConfiguration.java
index b672e55f3..91fc40234 100644
--- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/OracleConfiguration.java
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/OracleConfiguration.java
@@ -7,44 +7,33 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
-import org.springframework.core.env.Environment;
-
import javax.sql.DataSource;
import java.sql.SQLException;
-///*
-// This class grabs the appropriate values for OracleDataSource,
-// The method that uses env, grabs it from the environment variables set
-// in the docker container. The method that uses dbSettings is for local testing
-// @author: peter.song@oracle.com
-// */
-//
-//
+
@Configuration
public class OracleConfiguration {
Logger logger = LoggerFactory.getLogger(DbSettings.class);
@Autowired
private DbSettings dbSettings;
- @Autowired
- private Environment env;
@Bean
public DataSource dataSource() throws SQLException{
OracleDataSource ds = new OracleDataSource();
- ds.setDriverType(env.getProperty("driver_class_name"));
- logger.info("Using Driver " + env.getProperty("driver_class_name"));
- ds.setURL(env.getProperty("db_url"));
- logger.info("Using URL: " + env.getProperty("db_url"));
- ds.setUser(env.getProperty("db_user"));
- logger.info("Using Username " + env.getProperty("db_user"));
- ds.setPassword(env.getProperty("dbpassword"));
// For local testing
-// ds.setDriverType(dbSettings.getDriver_class_name());
-// logger.info("Using Driver " + dbSettings.getDriver_class_name());
-// ds.setURL(dbSettings.getUrl());
-// logger.info("Using URL: " + dbSettings.getUrl());
-// ds.setUser(dbSettings.getUsername());
-// logger.info("Using Username: " + dbSettings.getUsername());
-// ds.setPassword(dbSettings.getPassword());
+ ds.setDriverType(dbSettings.getDriver_class_name());
+ logger.info("Using Driver " + dbSettings.getDriver_class_name());
+ ds.setURL(dbSettings.getUrl());
+ logger.info("Using URL: " + dbSettings.getUrl());
+ ds.setUser(dbSettings.getUsername());
+ logger.info("Using Username: " + dbSettings.getUsername());
+ ds.setPassword(dbSettings.getPassword());
+// ds.setDriverType(env.getProperty("driver_class_name"));
+// logger.info("Using Driver " + env.getProperty("driver_class_name"));
+// ds.setURL(env.getProperty("db_url"));
+// logger.info("Using URL: " + env.getProperty("db_url"));
+// ds.setUser(env.getProperty("db_user"));
+// logger.info("Using Username " + env.getProperty("db_user"));
+// ds.setPassword(env.getProperty("dbpassword"));
return ds;
}
}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/ToDoItemBotController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/ToDoItemBotController.java
index 67e6bc49d..ed9b1fd2b 100644
--- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/ToDoItemBotController.java
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/ToDoItemBotController.java
@@ -6,7 +6,9 @@
import com.springboot.MyTodoList.util.BotActions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.client.okhttp.OkHttpTelegramClient;
import org.telegram.telegrambots.longpolling.BotSession;
@@ -27,6 +29,9 @@ public class ToDoItemBotController implements SpringLongPollingBot, LongPolling
private final BotProps botProps;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
@Value("${telegram.bot.token}")
private String telegramBotToken;
@@ -41,7 +46,7 @@ public String getBotToken() {
}
- public ToDoItemBotController( BotProps bp, ToDoItemService tsvc, DeepSeekService ds) {
+ public ToDoItemBotController(BotProps bp, ToDoItemService tsvc, DeepSeekService ds) {
this.botProps = bp;
telegramClient = new OkHttpTelegramClient(getBotToken());
toDoItemService = tsvc;
@@ -62,8 +67,11 @@ public void consume(Update update) {
String messageTextFromTelegram = update.getMessage().getText();
long chatId = update.getMessage().getChatId();
+ String fromUsername = update.getMessage().getFrom() != null ? update.getMessage().getFrom().getUserName() : "unknown";
+ String fromName = update.getMessage().getFrom() != null ? update.getMessage().getFrom().getFirstName() : "unknown";
+ logger.info("📨 Message from {} (@{}) — chatId: {}", fromName, fromUsername, chatId);
- BotActions actions = new BotActions(telegramClient,toDoItemService,deepSeekService);
+ BotActions actions = new BotActions(telegramClient, toDoItemService, deepSeekService, jdbcTemplate);
actions.setRequestText(messageTextFromTelegram);
actions.setChatId(chatId);
if(actions.getTodoService()==null){
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/DeepSeekService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/DeepSeekService.java
index 0e6a3b7aa..e5e5117a4 100644
--- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/DeepSeekService.java
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/DeepSeekService.java
@@ -3,13 +3,12 @@
import java.io.IOException;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
-import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.springframework.stereotype.Service;
@Service
-public class DeepSeekService{
+public class DeepSeekService {
private final CloseableHttpClient httpClient;
private final HttpPost httpPost;
@@ -18,15 +17,9 @@ public DeepSeekService(CloseableHttpClient httpClient, HttpPost httpPost) {
this.httpPost = httpPost;
}
- public String generateText(String prompt) throws IOException, org.apache.hc.core5.http.ParseException {
+ public String generateText(String prompt) throws IOException {
String requestBody = String.format("{\"model\": \"deepseek-chat\",\"messages\": [{\"role\": \"user\", \"content\": \"%s\"}]}", prompt);
-
- try {
- httpPost.setEntity(new StringEntity(requestBody));
- CloseableHttpResponse response = httpClient.execute(httpPost);
- return EntityUtils.toString(response.getEntity());
- } catch (IOException e) {
- throw e;
- }
+ httpPost.setEntity(new StringEntity(requestBody));
+ return httpClient.execute(httpPost, response -> EntityUtils.toString(response.getEntity()));
}
}
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/util/BotActions.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/util/BotActions.java
index 0b4873918..556ff6ccd 100644
--- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/util/BotActions.java
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/util/BotActions.java
@@ -3,10 +3,10 @@
import com.springboot.MyTodoList.model.ToDoItem;
import com.springboot.MyTodoList.service.DeepSeekService;
import com.springboot.MyTodoList.service.ToDoItemService;
+import org.springframework.jdbc.core.JdbcTemplate;
import java.time.OffsetDateTime;
-import java.util.ArrayList;
import java.util.List;
-import java.util.stream.Collectors;
+import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup;
@@ -24,12 +24,14 @@ public class BotActions{
ToDoItemService todoService;
DeepSeekService deepSeekService;
+ JdbcTemplate jdbcTemplate;
- public BotActions(TelegramClient tc,ToDoItemService ts, DeepSeekService ds){
+ public BotActions(TelegramClient tc, ToDoItemService ts, DeepSeekService ds, JdbcTemplate jt){
telegramClient = tc;
todoService = ts;
deepSeekService = ds;
- exit = false;
+ jdbcTemplate = jt;
+ exit = false;
}
public void setRequestText(String cmd){
@@ -149,59 +151,47 @@ public void fnListAll(){
|| requestText.equals(BotLabels.LIST_ALL_ITEMS.getLabel())
|| requestText.equals(BotLabels.MY_TODO_LIST.getLabel())) || exit)
return;
- logger.info("todoSvc: "+todoService);
- List allItems = todoService.findAll();
- ReplyKeyboardMarkup keyboardMarkup = ReplyKeyboardMarkup.builder()
- .resizeKeyboard(true)
- .oneTimeKeyboard(false)
- .selective(true)
- .build();
-
- List keyboard = new ArrayList<>();
-
- // command back to main screen
- KeyboardRow mainScreenRowTop = new KeyboardRow();
- mainScreenRowTop.add(BotLabels.SHOW_MAIN_SCREEN.getLabel());
- keyboard.add(mainScreenRowTop);
-
- KeyboardRow firstRow = new KeyboardRow();
- firstRow.add(BotLabels.ADD_NEW_ITEM.getLabel());
- keyboard.add(firstRow);
-
- KeyboardRow myTodoListTitleRow = new KeyboardRow();
- myTodoListTitleRow.add(BotLabels.MY_TODO_LIST.getLabel());
- keyboard.add(myTodoListTitleRow);
-
- List activeItems = allItems.stream().filter(item -> item.isDone() == false)
- .collect(Collectors.toList());
-
- for (ToDoItem item : activeItems) {
- KeyboardRow currentRow = new KeyboardRow();
- currentRow.add(item.getDescription());
- currentRow.add(item.getID() + BotLabels.DASH.getLabel() + BotLabels.DONE.getLabel());
- keyboard.add(currentRow);
- }
- List doneItems = allItems.stream().filter(item -> item.isDone() == true)
- .collect(Collectors.toList());
+ String telegramUserId = String.valueOf(chatId);
- for (ToDoItem item : doneItems) {
- KeyboardRow currentRow = new KeyboardRow();
- currentRow.add(item.getDescription());
- currentRow.add(item.getID() + BotLabels.DASH.getLabel() + BotLabels.UNDO.getLabel());
- currentRow.add(item.getID() + BotLabels.DASH.getLabel() + BotLabels.DELETE.getLabel());
- keyboard.add(currentRow);
+ // Buscar tasks asignadas a este usuario via TELEGRAM_USER_ID
+ List> myTasks = List.of();
+ try {
+ String sql =
+ "SELECT wi.WORK_ITEM_ID, wi.TITLE, wi.STATUS, s.NAME AS SPRINT_NAME " +
+ "FROM CHATBOT_USER.WORK_ITEM wi " +
+ "JOIN CHATBOT_USER.WORK_ITEM_ASSIGNMENT wia ON wi.WORK_ITEM_ID = wia.WORK_ITEM_ID " +
+ " AND wia.UNASSIGNED_AT IS NULL " +
+ "JOIN CHATBOT_USER.APP_USER u ON wia.USER_ID = u.USER_ID " +
+ "LEFT JOIN CHATBOT_USER.SPRINT s ON wi.SPRINT_ID = s.SPRINT_ID " +
+ "WHERE u.TELEGRAM_USER_ID = ? " +
+ "ORDER BY wi.CREATED_AT DESC";
+ myTasks = jdbcTemplate.queryForList(sql, telegramUserId);
+ } catch (Exception e) {
+ logger.warn("Could not query WORK_ITEM for user {}: {}", telegramUserId, e.getMessage());
}
- // command back to main screen
- KeyboardRow mainScreenRowBottom = new KeyboardRow();
- mainScreenRowBottom.add(BotLabels.SHOW_MAIN_SCREEN.getLabel());
- keyboard.add(mainScreenRowBottom);
+ if (myTasks.isEmpty()) {
+ BotHelper.sendMessageToTelegram(chatId,
+ "You have no tasks assigned yet. Ask your manager to assign tasks to you.", telegramClient);
+ exit = true;
+ return;
+ }
- keyboardMarkup.setKeyboard(keyboard);
+ StringBuilder sb = new StringBuilder("📋 *Your Tasks:*\n\n");
+ for (Map task : myTasks) {
+ String status = String.valueOf(task.get("STATUS"));
+ String emoji = status.equals("DONE") || status.equals("COMPLETED") ? "✅" :
+ status.equals("IN_PROGRESS") ? "🔄" :
+ status.equals("BLOCKED") ? "🚫" : "⏳";
+ sb.append(emoji).append(" *").append(task.get("TITLE")).append("*");
+ if (task.get("SPRINT_NAME") != null) {
+ sb.append(" — ").append(task.get("SPRINT_NAME"));
+ }
+ sb.append(" `[").append(status).append("]`\n");
+ }
- //
- BotHelper.sendMessageToTelegram(chatId, BotLabels.MY_TODO_LIST.getLabel(), telegramClient, keyboardMarkup);//
+ BotHelper.sendMessageToTelegram(chatId, sb.toString(), telegramClient);
exit = true;
}
diff --git a/MtdrSpring/backend/src/main/resources/application.properties b/MtdrSpring/backend/src/main/resources/application.properties
index 3bf494812..6515431ed 100644
--- a/MtdrSpring/backend/src/main/resources/application.properties
+++ b/MtdrSpring/backend/src/main/resources/application.properties
@@ -1,9 +1,9 @@
spring.jpa.database-platform=org.hibernate.community.dialect.Oracle12cDialect
#oracle.jdbc.fanEnabled=false
##this is not used when deployed in kubernetes. Just for local testing
-#spring.datasource.url=jdbc:oracle:thin:@adbps_medium?TNS_ADMIN=/Users/psong/Downloads/Wallet_ADBPS
-#spring.datasource.username=admin
-#spring.datasource.password=WELcome__12345
+spring.datasource.url=${DB_URL:jdbc:oracle:thin:@chatbotbd_medium?TNS_ADMIN=${TNS_ADMIN}}
+spring.datasource.username=${DB_USERNAME:CHATBOT_USER}
+spring.datasource.password=${DB_PASSWORD:}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
@@ -18,20 +18,20 @@ spring.datasource.oracleucp.min-pool-size=10
spring.datasource.oracleucp.max-pool-size=30
##Logging properties for UCP
-logging.level.root=trace
+logging.level.root=${LOG_LEVEL:info}
logging.file.name=logs.log
logging.level.oracle.ucp=trace
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
logging.level.org.hibernate.type=TRACE
-deepseek.api.key=sk-test
+deepseek.api.key=${DEEPSEEK_API_KEY:sk-test}
deepseek.api.url=https://api.deepseek.com/v1/chat/completions
#spring.security.user.name=psong
#spring.security.user.password=WELcome__12345
-#telegram.bot.token=
-#telegram.bot.name=
+telegram.bot.token=${TELEGRAM_BOT_TOKEN:}
+telegram.bot.name=${TELEGRAM_BOT_NAME:local_test_bot}
diff --git a/MtdrSpring/env.sh b/MtdrSpring/env.sh
index ac5382bd2..b298b3daf 100644
--- a/MtdrSpring/env.sh
+++ b/MtdrSpring/env.sh
@@ -73,3 +73,7 @@ alias sshpod1='kubectl exec -i -t $(kubectl get pod --namespace mtdrworkshop --s
export PATH=$PATH:$MTDRWORKSHOP_LOCATION/utils/
+
+# OCI Configuration
+export OCI_USER_OCID="ocid1.user.oc1..aaaaaaaaidyv7v7atn4dauxulsktv636wnbu5t2h4ibogrbosiim5fkadlmq"
+export TEST_USER_OCID=$OCI_USER_OCID
diff --git a/MtdrSpring/terraform/containerengine.tf b/MtdrSpring/terraform/containerengine.tf
index 99635d3f8..b9cfee81e 100644
--- a/MtdrSpring/terraform/containerengine.tf
+++ b/MtdrSpring/terraform/containerengine.tf
@@ -8,7 +8,7 @@ resource "oci_containerengine_cluster" "mtdrworkshop_cluster" {
]
subnet_id = oci_core_subnet.endpoint.id
}
- kubernetes_version = "v1.35.0"
+ kubernetes_version = "v1.34.2"
name = "mtdrworkshopcluster-${var.mtdrKey}"
vcn_id = oci_core_vcn.okevcn.id
#optional
@@ -31,18 +31,12 @@ resource "oci_containerengine_cluster" "mtdrworkshop_cluster" {
services_cidr = "10.96.0.0/16"
}
}
- lifecycle {
- precondition {
- condition = contains(data.oci_containerengine_cluster_option.mtdrworkshop_cluster_option.kubernetes_versions, "v1.35.0")
- error_message = "La versión de Kubernetes v1.35.0 no es soportada por OCI en esta región. Versiones disponibles: ${join(", ", data.oci_containerengine_cluster_option.mtdrworkshop_cluster_option.kubernetes_versions)}"
- }
- }
}
resource "oci_containerengine_node_pool" "oke_node_pool" {
#Required
cluster_id = oci_containerengine_cluster.mtdrworkshop_cluster.id
compartment_id = var.ociCompartmentOcid
- kubernetes_version = "v1.35.0"
+ kubernetes_version = "v1.34.2"
name = "Pool"
#node_shape = "VM.Standard.A1.Flex" #Always Free Option
node_shape = "VM.Standard.E3.Flex"
@@ -78,13 +72,6 @@ resource "oci_containerengine_node_pool" "oke_node_pool" {
//quantity_per_subnet = 1
ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsXyGATqdTnvEDe0aYHGL+QQDjUXf6EIBlKiNLYR4gZhStp4yfn/MEWmCMGg3cbne04HlaeO3zGrUnrtfAQE90XccW9Dc4WkhLYf2vucja9NezAVQZE2qBYiwdZSF9G/FwPI1DzfbXF2UAAN3ix/IwJSWN3KZnd1FOcHOA052QMa7jGOIbi8+skKqkys3gcTaor7eXe/wONimkpPevF30FTQZpsQFU7ZzYcFM3C+XVZ2/UVtZ/MaDf73ub6mYNMpDtDCTMo9FyujzK84EKWIytAKofNwJ/Og3Wqr+CKAeLgCMtWp0926w+ff8dJRDuOxlxgJB48YaFSvjIr4lAv/aX rafael_a_g@6ab23190fb98"
//ssh_public_key = var.resUserPublicKey
-
- lifecycle {
- precondition {
- condition = contains(data.oci_containerengine_node_pool_option.mtdrworkshop_node_pool_option.kubernetes_versions, "v1.35.0")
- error_message = "La versión de Kubernetes v1.35.0 no es válida para el Node Pool. Versiones disponibles: ${join(", ", data.oci_containerengine_node_pool_option.mtdrworkshop_node_pool_option.kubernetes_versions)}"
- }
- }
}
data "oci_containerengine_cluster_option" "mtdrworkshop_cluster_option" {
cluster_option_id = "all"
From e53ea658646d98ad8d3ce7f330443c22c12956d4 Mon Sep 17 00:00:00 2001
From: Jusypablo13
Date: Sun, 19 Apr 2026 13:02:37 -0600
Subject: [PATCH 27/28] feat: connect TypeScript frontend to real backend API
endpoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- NEW: api-client.ts — generic HTTP client replacing mockApi
- REWRITE: work-item.service.ts — real fetch to /workitems with UPPER_CASE→camelCase mapper
- REWRITE: foundation.service.ts — real fetch to /appusers and /sprints
- UPDATE: work-item-status.enum.ts — add 'NEW' status + normalizer
- UPDATE: useWorkItemsViewModel.ts — load users/sprints dynamically
- UPDATE: work-item-dashboard-page.tsx — use viewModel.users instead of mockUsers
- UPDATE: dashboard-ui.ts — dynamic sprint labels + NEW status support
- UPDATE: package.json — add proxy to Spring Boot backend
---
.../backend/src/main/frontend/package.json | 1 +
.../work-items/enums/work-item-status.enum.ts | 17 +-
.../features/work-items/lib/dashboard-ui.ts | 11 +-
.../pages/work-item-dashboard-page.tsx | 5 +-
.../work-items/services/work-item.service.ts | 283 ++++++++++--------
.../viewModels/useWorkItemsViewModel.ts | 23 +-
.../src/shared/services/api-client.ts | 57 ++++
.../src/shared/services/foundation.service.ts | 64 ++--
8 files changed, 307 insertions(+), 154 deletions(-)
create mode 100644 MtdrSpring/backend/src/main/frontend/src/shared/services/api-client.ts
diff --git a/MtdrSpring/backend/src/main/frontend/package.json b/MtdrSpring/backend/src/main/frontend/package.json
index 9157d88f9..077b10ff0 100644
--- a/MtdrSpring/backend/src/main/frontend/package.json
+++ b/MtdrSpring/backend/src/main/frontend/package.json
@@ -18,6 +18,7 @@
"react-scripts": "5.0.0",
"recharts": "^2.1.16"
},
+ "proxy": "http://localhost:8080",
"scripts": {
"start": "craco start",
"build": "craco build"
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/enums/work-item-status.enum.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/enums/work-item-status.enum.ts
index ff8728b42..06fcb9a44 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/enums/work-item-status.enum.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/enums/work-item-status.enum.ts
@@ -1,8 +1,23 @@
export const WORK_ITEM_STATUSES = [
'TODO',
+ 'NEW',
'IN_PROGRESS',
'BLOCKED',
'DONE'
] as const;
-export type WorkItemStatus = (typeof WORK_ITEM_STATUSES)[number];
\ No newline at end of file
+export type WorkItemStatus = (typeof WORK_ITEM_STATUSES)[number];
+
+/** Backend stores 'NEW'; the UI treats it as 'TODO'. */
+export function normalizeStatus(raw: string | null | undefined): WorkItemStatus {
+ if (!raw) return 'TODO';
+ const upper = raw.toUpperCase();
+ if (upper === 'NEW') return 'TODO';
+ if (WORK_ITEM_STATUSES.includes(upper as WorkItemStatus)) return upper as WorkItemStatus;
+ return 'TODO';
+}
+
+/** Convert frontend status back to backend format for API calls. */
+export function toBackendStatus(status: WorkItemStatus): string {
+ return status === 'TODO' ? 'NEW' : status;
+}
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
index c7b732483..ebb38c67b 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/lib/dashboard-ui.ts
@@ -18,6 +18,7 @@ export function getInitials(name: string): string {
export function formatStatusLabel(status: WorkItemStatus): string {
switch (status) {
case 'TODO': return 'Todo';
+ case 'NEW': return 'Todo';
case 'IN_PROGRESS': return 'In Progress';
case 'BLOCKED': return 'Blocked';
case 'DONE': return 'Done';
@@ -124,12 +125,8 @@ export function formatDate(dateStr?: string): string {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
-export function getSprintLabel(sprintId?: string): string {
+export function getSprintLabel(sprintId?: string, sprintMap?: Record): string {
if (!sprintId) return '—';
- const map: Record = {
- 'spr-001': 'Sprint 1',
- 'spr-002': 'Sprint 2',
- 'spr-003': 'Sprint 3',
- };
- return map[sprintId] ?? sprintId;
+ if (sprintMap && sprintMap[sprintId]) return sprintMap[sprintId];
+ return sprintId;
}
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
index dad939c78..1fdc1edee 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/pages/work-item-dashboard-page.tsx
@@ -1,5 +1,4 @@
import { Layers } from 'lucide-react';
-import { mockUsers } from '@/shared/mock/users.mock';
import { mockTags } from '@/shared/mock/tags.mock';
import { DashboardSummaryCards } from '../components/dashboard/dashboard-summary-cards';
import { DashboardToolbar } from '../components/dashboard/dashboard-toolbar';
@@ -47,7 +46,7 @@ export function WorkItemDashboardPage() {
viewMode={viewModel.viewMode}
onViewModeChange={viewModel.setViewMode}
onCreateClick={viewModel.actions.openNew}
- users={mockUsers}
+ users={viewModel.users}
/>
@@ -84,7 +83,7 @@ export function WorkItemDashboardPage() {
mockUsers.find((u) => u.id === uid))
- .filter((u): u is UserSummaryDto => !!u)
- .map((user, i) => ({
- id: `asg-${user.id}-${i}`,
- user,
- role: i === 0 ? 'OWNER' : 'ASSIGNEE',
- assignedAt: new Date().toISOString(),
- } as Assignee));
+import type { UserSummaryDto } from '@/shared/dtos/user-summary.dto';
+import type { WorkItemType } from '../enums/work-item-type.enum';
+import type { WorkItemPriority } from '../enums/work-item-priority.enum';
+import { normalizeStatus, toBackendStatus } from '../enums/work-item-status.enum';
+import { apiClient } from '@/shared/services/api-client';
+
+// ─── Backend response shape from WorkItemController ──────────────
+
+interface BackendWorkItemRow {
+ WORK_ITEM_ID: string;
+ TITLE: string;
+ DESCRIPTION?: string;
+ STATUS: string;
+ PRIORITY: string;
+ WORK_TYPE: string;
+ DUE_DATE?: string;
+ CREATED_AT: string;
+ ESTIMATED_MINUTES?: number;
+ SPRINT_NAME?: string;
+ SPRINT_ID?: string;
+ ASSIGNEE_NAME?: string;
+ ASSIGNEE_ID?: string;
}
-function resolveTags(tagIds?: string[]): Array<(typeof mockTags)[number]> {
- if (!tagIds?.length) return [];
- return tagIds
- .map((tid) => mockTags.find((t) => t.id === tid))
- .filter((tag): tag is (typeof mockTags)[number] => !!tag);
+// ─── Mappers ─────────────────────────────────────────────────────
+
+function mapBackendRowToDetailDto(row: BackendWorkItemRow): WorkItemDetailDto {
+ const assignees: Assignee[] = [];
+ if (row.ASSIGNEE_ID && row.ASSIGNEE_NAME) {
+ assignees.push({
+ id: `asg-${row.ASSIGNEE_ID}`,
+ user: {
+ id: row.ASSIGNEE_ID,
+ name: row.ASSIGNEE_NAME,
+ },
+ role: 'ASSIGNEE',
+ assignedAt: row.CREATED_AT,
+ });
+ }
+
+ return {
+ id: row.WORK_ITEM_ID,
+ sprintId: row.SPRINT_ID,
+ title: row.TITLE,
+ description: row.DESCRIPTION ?? undefined,
+ type: (row.WORK_TYPE ?? 'TASK') as WorkItemType,
+ status: normalizeStatus(row.STATUS),
+ priority: (row.PRIORITY ?? 'MEDIUM') as WorkItemPriority,
+ estimatedMinutes: row.ESTIMATED_MINUTES ?? undefined,
+ totalLoggedMinutes: 0,
+ dueDate: row.DUE_DATE ? String(row.DUE_DATE).slice(0, 10) : undefined,
+ createdAt: row.CREATED_AT,
+ updatedAt: row.CREATED_AT,
+ createdBy: { id: 'system', name: 'System' },
+ assignees,
+ tags: [],
+ };
}
-function toListItemDto(item: WorkItemDetailDto): WorkItemListItemDto {
+function mapToListItemDto(detail: WorkItemDetailDto): WorkItemListItemDto {
return {
- id: item.id,
- sprintId: item.sprintId,
- title: item.title,
- type: item.type,
- status: item.status,
- priority: item.priority,
- estimatedMinutes: item.estimatedMinutes,
- totalLoggedMinutes: item.totalLoggedMinutes,
- dueDate: item.dueDate,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- createdBy: item.createdBy,
- assignees: item.assignees.map((assignment: Assignee): UserSummaryDto => assignment.user),
- tags: item.tags
+ id: detail.id,
+ sprintId: detail.sprintId,
+ title: detail.title,
+ type: detail.type,
+ status: detail.status,
+ priority: detail.priority,
+ estimatedMinutes: detail.estimatedMinutes,
+ totalLoggedMinutes: detail.totalLoggedMinutes,
+ dueDate: detail.dueDate,
+ createdAt: detail.createdAt,
+ updatedAt: detail.updatedAt,
+ createdBy: detail.createdBy,
+ assignees: detail.assignees.map((a: Assignee): UserSummaryDto => a.user),
+ tags: detail.tags,
};
}
-function applyFilters(
+function applyClientSideFilters(
items: WorkItemDetailDto[],
filters?: WorkItemFiltersDto
): WorkItemDetailDto[] {
- if (!filters) {
- return items;
- }
+ if (!filters) return items;
- return items.filter((item: WorkItemDetailDto): boolean => {
- const matchesSearch: boolean =
+ return items.filter((item) => {
+ const matchesSearch =
!filters.search ||
item.title.toLowerCase().includes(filters.search.toLowerCase()) ||
(item.description ?? '').toLowerCase().includes(filters.search.toLowerCase());
- const matchesSprint: boolean = !filters.sprintId || item.sprintId === filters.sprintId;
- const matchesType: boolean = !filters.type || item.type === filters.type;
- const matchesStatus: boolean = !filters.status || item.status === filters.status;
- const matchesPriority: boolean = !filters.priority || item.priority === filters.priority;
+ const matchesSprint = !filters.sprintId || item.sprintId === filters.sprintId;
+ const matchesType = !filters.type || item.type === filters.type;
+ const matchesStatus = !filters.status || item.status === filters.status;
+ const matchesPriority = !filters.priority || item.priority === filters.priority;
- const matchesAssignee: boolean =
+ const matchesAssignee =
!filters.assigneeUserId ||
- item.assignees.some((assignment) => assignment.user.id === filters.assigneeUserId);
-
- const matchesCreatedBy: boolean =
- !filters.createdByUserId || item.createdBy.id === filters.createdByUserId;
-
- const matchesTags: boolean =
- !filters.tagIds?.length ||
- filters.tagIds.every((tagId) => item.tags.some((tag) => tag.id === tagId));
-
- return (
- matchesSearch &&
- matchesSprint &&
- matchesType &&
- matchesStatus &&
- matchesPriority &&
- matchesAssignee &&
- matchesCreatedBy &&
- matchesTags
- );
+ item.assignees.some((a) => a.user.id === filters.assigneeUserId);
+
+ return matchesSearch && matchesSprint && matchesType && matchesStatus && matchesPriority && matchesAssignee;
});
}
+// ─── Service (real HTTP calls) ───────────────────────────────────
+
export const workItemService = {
async getWorkItems(
filters?: WorkItemFiltersDto
): Promise>> {
- const filtered: WorkItemDetailDto[] = applyFilters(mockWorkItems, filters);
- const page = filters?.page ?? 1;
- const pageSize = filters?.pageSize ?? 10;
+ const result = await apiClient.get('/workitems');
+
+ if (!result.success) {
+ return {
+ success: false,
+ data: { items: [], total: 0, page: 1, pageSize: 10, totalPages: 0 },
+ message: result.message,
+ };
+ }
+
+ const allDetails = result.data.map(mapBackendRowToDetailDto);
+ const filtered = applyClientSideFilters(allDetails, filters);
+ const page = filters?.page ?? 1;
+ const pageSize = filters?.pageSize ?? 100;
const start = (page - 1) * pageSize;
- const end = start + pageSize;
- const pagedItems: WorkItemListItemDto[] = filtered.slice(start, end).map(toListItemDto);
-
- return mockApi({
- items: pagedItems,
- total: filtered.length,
- page,
- pageSize,
- totalPages: Math.max(1, Math.ceil(filtered.length / pageSize))
- });
+ const paged = filtered.slice(start, start + pageSize).map(mapToListItemDto);
+
+ return {
+ success: true,
+ data: {
+ items: paged,
+ total: filtered.length,
+ page,
+ pageSize,
+ totalPages: Math.max(1, Math.ceil(filtered.length / pageSize)),
+ },
+ };
},
async getWorkItemById(id: string): Promise> {
- const found = mockWorkItems.find((item) => item.id === id) ?? null;
+ // The backend doesn't have a single-item endpoint, so fetch all and filter.
+ const result = await apiClient.get('/workitems');
+
+ if (!result.success) {
+ return { success: false, data: null, message: result.message };
+ }
- return mockApi(found);
+ const row = result.data.find((r) => r.WORK_ITEM_ID === id);
+ if (!row) return { success: true, data: null };
+
+ return { success: true, data: mapBackendRowToDetailDto(row) };
},
async createWorkItem(input: CreateWorkItemDto): Promise> {
- const now = new Date().toISOString();
+ const body = {
+ title: input.title,
+ description: input.description ?? '',
+ workType: input.type ?? 'TASK',
+ priority: input.priority ?? 'MEDIUM',
+ sprintId: input.sprintId ?? null,
+ dueDateStr: input.dueDate ?? null,
+ assigneeUserId: input.assigneeUserIds?.[0] ?? null,
+ };
+
+ const result = await apiClient.post<{ workItemId: string }>('/workitems', body);
+ if (!result.success) {
+ return { success: false, data: null as unknown as WorkItemDetailDto, message: result.message };
+ }
+
+ // Build an optimistic local object so the UI updates immediately
+ const now = new Date().toISOString();
const created: WorkItemDetailDto = {
- id: `wrk-${crypto.randomUUID()}`,
+ id: result.data.workItemId,
sprintId: input.sprintId,
title: input.title,
description: input.description,
type: input.type,
- status: input.status ?? 'TODO',
+ status: 'TODO',
priority: input.priority,
- externalLink: input.externalLink,
estimatedMinutes: input.estimatedMinutes,
totalLoggedMinutes: 0,
dueDate: input.dueDate,
createdAt: now,
updatedAt: now,
- createdBy: {
- id: 'usr-001',
- name: 'Bernardo Manager',
- email: 'bernardo.manager@demo.com',
- telegramUserId: 'tg_bernardo_manager'
- },
- assignees: resolveAssignees(input.assigneeUserIds),
- tags: resolveTags(input.tagIds),
- featureDetails: input.featureDetails,
- issueDetails: input.issueDetails,
- bugDetails: input.bugDetails
+ createdBy: { id: 'current', name: 'Current User' },
+ assignees: [],
+ tags: [],
};
- mockWorkItems.unshift(created);
-
- return mockApi(created);
+ return { success: true, data: created };
},
async updateWorkItem(
id: string,
input: UpdateWorkItemDto
): Promise> {
- const index = mockWorkItems.findIndex((item) => item.id === id);
-
- if (index === -1) {
- return mockApi(null);
+ // The backend currently only supports status updates via PUT /workitems/{id}/status
+ if (input.status) {
+ const statusResult = await apiClient.put<{ workItemId: string; status: string }>(
+ `/workitems/${id}/status`,
+ { status: toBackendStatus(input.status) }
+ );
+ if (!statusResult.success) {
+ return { success: false, data: null, message: statusResult.message };
+ }
}
- const current: WorkItemDetailDto = mockWorkItems[index];
-
- const { assigneeUserIds, tagIds, ...rest } = input;
-
- const updated: WorkItemDetailDto = {
- ...current,
- ...rest,
- assignees: assigneeUserIds !== undefined ? resolveAssignees(assigneeUserIds) : current.assignees,
- tags: tagIds !== undefined ? resolveTags(tagIds) : current.tags,
- updatedAt: new Date().toISOString(),
- featureDetails: input.featureDetails ?? current.featureDetails,
- issueDetails: input.issueDetails ?? current.issueDetails,
- bugDetails: input.bugDetails ?? current.bugDetails
- };
-
- mockWorkItems[index] = updated;
+ // Re-fetch to get the updated item
+ return this.getWorkItemById(id);
+ },
- return mockApi(updated);
- }
+ async deleteWorkItem(id: string): Promise> {
+ const result = await apiClient.delete(`/workitems/${id}`);
+ return { success: result.success, data: result.success, message: result.message };
+ },
};
\ No newline at end of file
diff --git a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
index 334a566f3..e271d7e7b 100644
--- a/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/features/work-items/viewModels/useWorkItemsViewModel.ts
@@ -1,15 +1,20 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { workItemService } from '../services/work-item.service';
+import { foundationService } from '@/shared/services/foundation.service';
+import type { SprintDto } from '@/shared/services/foundation.service';
import type { ViewMode } from "@/features/work-items/components/dashboard/dashboard-toolbar";
import type { WorkItemDetailDto } from '../dtos/work-item-detail.dto';
import type { CreateWorkItemDto } from '../dtos/create-work-item.dto';
import type { UpdateWorkItemDto } from '../dtos/update-work-item.dto';
import type { WorkItemStatus } from '../enums/work-item-status.enum';
+import type { UserSummaryDto } from '@/shared/dtos/user-summary.dto';
export const useWorkItemsViewModel = () => {
// 1. Data State
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
+ const [users, setUsers] = useState([]);
+ const [sprints, setSprints] = useState([]);
// 2. UI State (Grouped logically)
const [filters, setFilters] = useState({
@@ -32,8 +37,10 @@ export const useWorkItemsViewModel = () => {
const loadItems = useCallback(async () => {
setLoading(true);
try {
+ // Single API call — the service maps backend rows to WorkItemDetailDto
const result = await workItemService.getWorkItems({ page: 1, pageSize: 100 });
if (result.success) {
+ // getWorkItems now returns mapped DTOs directly; fetch full detail set
const ids = result.data.items.map((i) => i.id);
const details = await Promise.all(ids.map((id) => workItemService.getWorkItemById(id)));
const fullItems = details
@@ -46,7 +53,19 @@ export const useWorkItemsViewModel = () => {
}
}, []);
- useEffect(() => { loadItems().then(); }, [loadItems]);
+ const loadFoundationData = useCallback(async () => {
+ const [usersResult, sprintsResult] = await Promise.all([
+ foundationService.getUsers(),
+ foundationService.getSprints(),
+ ]);
+ if (usersResult.success) setUsers(usersResult.data);
+ if (sprintsResult.success) setSprints(sprintsResult.data);
+ }, []);
+
+ useEffect(() => {
+ loadFoundationData().then();
+ loadItems().then();
+ }, [loadItems, loadFoundationData]);
// Derived State (SwiftUI "Computed Properties")
const filteredItems = useMemo(() => {
@@ -126,6 +145,8 @@ export const useWorkItemsViewModel = () => {
loading,
viewMode,
setViewMode,
+ users,
+ sprints,
// UI State
search: filters.search,
diff --git a/MtdrSpring/backend/src/main/frontend/src/shared/services/api-client.ts b/MtdrSpring/backend/src/main/frontend/src/shared/services/api-client.ts
new file mode 100644
index 000000000..e370844a4
--- /dev/null
+++ b/MtdrSpring/backend/src/main/frontend/src/shared/services/api-client.ts
@@ -0,0 +1,57 @@
+import type { ApiResult } from '../dtos/api-result.dto';
+
+/**
+ * Generic HTTP client for the Spring Boot backend.
+ * The React dev-server proxy (package.json → "proxy": "http://localhost:8080")
+ * forwards all unmatched requests to the backend, so we use relative URLs.
+ */
+
+async function request(
+ url: string,
+ options: RequestInit = {}
+): Promise> {
+ try {
+ const res = await fetch(url, {
+ headers: { 'Content-Type': 'application/json', ...options.headers },
+ ...options,
+ });
+
+ if (!res.ok) {
+ const errorText = await res.text().catch(() => res.statusText);
+ return {
+ success: false,
+ data: null as unknown as T,
+ message: `HTTP ${res.status}: ${errorText}`,
+ };
+ }
+
+ // Handle 204 No Content
+ if (res.status === 204) {
+ return { success: true, data: null as unknown as T };
+ }
+
+ const data: T = await res.json();
+ return { success: true, data };
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Network error';
+ return { success: false, data: null as unknown as T, message };
+ }
+}
+
+export const apiClient = {
+ get(url: string): Promise> {
+ return request(url);
+ },
+
+ post(url: string, body: unknown): Promise> {
+ return request(url, { method: 'POST', body: JSON.stringify(body) });
+ },
+
+ put(url: string, body: unknown): Promise> {
+ return request(url, { method: 'PUT', body: JSON.stringify(body) });
+ },
+
+ delete(url: string): Promise> {
+ return request(url, { method: 'DELETE' });
+ },
+};
diff --git a/MtdrSpring/backend/src/main/frontend/src/shared/services/foundation.service.ts b/MtdrSpring/backend/src/main/frontend/src/shared/services/foundation.service.ts
index 48c8d5622..6c4d44a20 100644
--- a/MtdrSpring/backend/src/main/frontend/src/shared/services/foundation.service.ts
+++ b/MtdrSpring/backend/src/main/frontend/src/shared/services/foundation.service.ts
@@ -3,48 +3,74 @@ import type { SelectOption } from '../models/select-option.model';
import type { UserSummaryDto } from '../dtos/user-summary.dto';
import type { TagDto } from '../dtos/tag.dto';
import type { TeamSummaryDto } from '../dtos/team-summary.dto';
-import { mockApi } from './mock-api';
-import { mockUsers } from '../mock/users.mock';
+import { apiClient } from './api-client';
import { mockTags } from '../mock/tags.mock';
import { mockTeams } from '../mock/teams.mock';
+// ─── Sprint DTO for UI consumption ──────────────────────────────
+export interface SprintDto {
+ sprintId: string;
+ name: string;
+ status?: string;
+ startDate?: string;
+ endDate?: string;
+}
+
export const foundationService = {
+ /** Fetch real users from backend /appusers endpoint */
async getUsers(): Promise> {
- return mockApi(mockUsers);
+ const result = await apiClient.get('/appusers');
+ if (!result.success) {
+ return { success: false, data: [], message: result.message };
+ }
+ // Backend already returns camelCase thanks to SQL aliases
+ return { success: true, data: result.data };
+ },
+
+ /** Fetch real sprints from backend /sprints endpoint */
+ async getSprints(): Promise> {
+ const result = await apiClient.get('/sprints');
+ if (!result.success) {
+ return { success: false, data: [], message: result.message };
+ }
+ return { success: true, data: result.data };
},
+ /** Tags are not stored in the backend yet — still local */
async getTags(): Promise> {
- return mockApi(mockTags);
+ return { success: true, data: structuredClone(mockTags) };
},
+ /** Teams are not stored in the backend yet — still local */
async getTeams(): Promise> {
- return mockApi(mockTeams);
+ return { success: true, data: structuredClone(mockTeams) };
},
async getUserOptions(): Promise> {
- const options: SelectOption[] = mockUsers.map((user) => ({
+ const usersResult = await this.getUsers();
+ if (!usersResult.success) {
+ return { success: false, data: [], message: usersResult.message };
+ }
+ const options: SelectOption[] = usersResult.data.map((user) => ({
value: user.id,
- label: user.name
+ label: user.name,
}));
-
- return mockApi(options);
+ return { success: true, data: options };
},
async getTagOptions(): Promise> {
const options: SelectOption[] = mockTags.map((tag) => ({
value: tag.id,
- label: tag.name
- }))
-
- return mockApi(options);
+ label: tag.name,
+ }));
+ return { success: true, data: options };
},
async getTeamOptions(): Promise> {
const options: SelectOption[] = mockTeams.map((team) => ({
value: team.id,
- label: team.name
- }))
-
- return mockApi(options);
- }
-}
\ No newline at end of file
+ label: team.name,
+ }));
+ return { success: true, data: options };
+ },
+};
\ No newline at end of file
From 545d4c4e0cfea1dbfbaee96ac566b6b407c33534 Mon Sep 17 00:00:00 2001
From: Jusypablo13
Date: Sun, 19 Apr 2026 13:32:03 -0600
Subject: [PATCH 28/28] fix: make Telegram bot optional with
@ConditionalOnExpression to allow local REST API testing
---
.../springboot/MyTodoList/controller/ToDoItemBotController.java | 2 ++
.../src/main/java/com/springboot/MyTodoList/util/BotClient.java | 2 ++
2 files changed, 4 insertions(+)
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/ToDoItemBotController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/ToDoItemBotController.java
index ed9b1fd2b..c4a213d97 100644
--- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/ToDoItemBotController.java
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/ToDoItemBotController.java
@@ -8,6 +8,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.client.okhttp.OkHttpTelegramClient;
@@ -20,6 +21,7 @@
import org.telegram.telegrambots.meta.generics.TelegramClient;
@Component
+@ConditionalOnExpression("!'${telegram.bot.token:}'.trim().isEmpty()")
public class ToDoItemBotController implements SpringLongPollingBot, LongPollingSingleThreadUpdateConsumer {
private static final Logger logger = LoggerFactory.getLogger(ToDoItemBotController.class);
diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/util/BotClient.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/util/BotClient.java
index aa902e98e..42a2c6ece 100644
--- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/util/BotClient.java
+++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/util/BotClient.java
@@ -2,6 +2,7 @@
import org.telegram.telegrambots.meta.generics.TelegramClient;
import org.telegram.telegrambots.client.okhttp.OkHttpTelegramClient;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -9,6 +10,7 @@
@Configuration
+@ConditionalOnExpression("!'${telegram.bot.token:}'.trim().isEmpty()")
public class BotClient {
@Bean