Compare commits
30 Commits
amalia/06-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cd295abf2c | |||
| e6dfac1ffe | |||
| 6ce7c93106 | |||
| f446aec734 | |||
| 08d67a304a | |||
| 16ea551b4c | |||
| c67fc9a230 | |||
| c66ce4a39b | |||
| 840a89ea0a | |||
| 2e51c01c7a | |||
| 24fcc1ee76 | |||
| f38081b1eb | |||
| a0cafbf2e2 | |||
| 14adaa8526 | |||
| 65e9ed5ce6 | |||
| 2cf96135f9 | |||
| 14a9e2c687 | |||
| c0205ce2bf | |||
| 315ecc565e | |||
| 8c50768c98 | |||
| 5cc73d2290 | |||
| ac3c673500 | |||
| e1b9241c35 | |||
| cc49a1fcd3 | |||
| c63b8cd385 | |||
| ba74539542 | |||
| 3a91bb5b17 | |||
| 91ad56348f | |||
| 4fad913890 | |||
| 7b23192121 |
17
.playwright-mcp/console-2026-04-14T09-51-59-546Z.log
Normal file
17
.playwright-mcp/console-2026-04-14T09-51-59-546Z.log
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[ 665ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=bf7d8134:14336
|
||||||
|
[ 708ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 709ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 715ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 716ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 716ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 716ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 716ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 716ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
17
.playwright-mcp/console-2026-04-14T09-52-33-736Z.log
Normal file
17
.playwright-mcp/console-2026-04-14T09-52-33-736Z.log
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[ 358ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=bf7d8134:14336
|
||||||
|
[ 375ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 375ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 379ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 379ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 379ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 379ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 380ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
20
.playwright-mcp/console-2026-04-14T09-53-25-466Z.log
Normal file
20
.playwright-mcp/console-2026-04-14T09-53-25-466Z.log
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[ 137ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=bf7d8134:14336
|
||||||
|
[ 143ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 143ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 145ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 146ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 146ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 175ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/api/auth/session:0
|
||||||
|
[ 43606ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/api/auth/login:0
|
||||||
|
[ 77901ms] [ERROR] Unsupported style property %s. Did you mean %s? &[data-active] &[dataActive] @ http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=bf7d8134:1804
|
||||||
18
.playwright-mcp/console-2026-04-15T00-10-32-890Z.log
Normal file
18
.playwright-mcp/console-2026-04-15T00-10-32-890Z.log
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[ 240ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=bf7d8134:14336
|
||||||
|
[ 265ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 265ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 272ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 273ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 273ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 428ms] [ERROR] Unsupported style property %s. Did you mean %s? &[data-active] &[dataActive] @ http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=bf7d8134:1804
|
||||||
18
.playwright-mcp/console-2026-04-15T00-12-16-354Z.log
Normal file
18
.playwright-mcp/console-2026-04-15T00-12-16-354Z.log
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[ 193ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=bf7d8134:14336
|
||||||
|
[ 216ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 216ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 222ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 222ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 223ms] [ERROR] forwardRef render functions accept exactly two parameters: props and ref. %s Did you forget to use the ref parameter? @ http://localhost:3000/node_modules/.vite/deps/react-B6J-hxuQ.js?v=bf7d8134:644
|
||||||
|
[ 279ms] [ERROR] Unsupported style property %s. Did you mean %s? &[data-active] &[dataActive] @ http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=bf7d8134:1804
|
||||||
21
.playwright-mcp/page-2026-04-14T09-52-00-449Z.yml
Normal file
21
.playwright-mcp/page-2026-04-14T09-52-00-449Z.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- generic:
|
||||||
|
- generic:
|
||||||
|
- generic: Loading...
|
||||||
|
- generic [ref=e4]:
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- img [ref=e6]
|
||||||
|
- img [ref=e8]
|
||||||
|
- heading "Bun + Elysia + Vite + React" [level=1] [ref=e16]
|
||||||
|
- paragraph [ref=e17]: Full-stack starter template with Mantine UI, TanStack Router, and session-based auth.
|
||||||
|
- generic [ref=e18]:
|
||||||
|
- link "Login" [ref=e19] [cursor=pointer]:
|
||||||
|
- /url: /login
|
||||||
|
- generic [ref=e20]:
|
||||||
|
- img [ref=e22]
|
||||||
|
- generic [ref=e26]: Login
|
||||||
|
- link "Dashboard" [ref=e27] [cursor=pointer]:
|
||||||
|
- /url: /dashboard
|
||||||
|
- generic [ref=e28]:
|
||||||
|
- img [ref=e30]
|
||||||
|
- generic [ref=e34]: Dashboard
|
||||||
4
.playwright-mcp/page-2026-04-14T09-52-34-199Z.yml
Normal file
4
.playwright-mcp/page-2026-04-14T09-52-34-199Z.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
- generic [active]:
|
||||||
|
- generic:
|
||||||
|
- generic:
|
||||||
|
- generic: Loading...
|
||||||
39
.playwright-mcp/page-2026-04-14T09-53-25-737Z.yml
Normal file
39
.playwright-mcp/page-2026-04-14T09-53-25-737Z.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- generic:
|
||||||
|
- generic:
|
||||||
|
- generic: Loading...
|
||||||
|
- generic [ref=e6]:
|
||||||
|
- heading "Login" [level=2] [ref=e7]
|
||||||
|
- paragraph [ref=e8]:
|
||||||
|
- text: "Demo:"
|
||||||
|
- strong [ref=e9]: superadmin@example.com
|
||||||
|
- text: /
|
||||||
|
- strong [ref=e10]: superadmin123
|
||||||
|
- text: "or:"
|
||||||
|
- strong [ref=e11]: user@example.com
|
||||||
|
- text: /
|
||||||
|
- strong [ref=e12]: user123
|
||||||
|
- generic [ref=e13]:
|
||||||
|
- generic [ref=e14]: Email *
|
||||||
|
- generic [ref=e15]:
|
||||||
|
- img [ref=e17]
|
||||||
|
- textbox "Email" [ref=e20]:
|
||||||
|
- /placeholder: email@example.com
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- generic [ref=e22]: Password *
|
||||||
|
- generic [ref=e23]:
|
||||||
|
- img [ref=e25]
|
||||||
|
- textbox "Password" [ref=e30]
|
||||||
|
- button [ref=e32] [cursor=pointer]:
|
||||||
|
- img [ref=e34]
|
||||||
|
- button "Sign in" [ref=e36] [cursor=pointer]:
|
||||||
|
- generic [ref=e37]:
|
||||||
|
- img [ref=e39]
|
||||||
|
- generic [ref=e43]: Sign in
|
||||||
|
- separator [ref=e44]:
|
||||||
|
- generic [ref=e45]: or
|
||||||
|
- link "Login with Google" [ref=e46] [cursor=pointer]:
|
||||||
|
- /url: /api/auth/google
|
||||||
|
- generic [ref=e47]:
|
||||||
|
- img [ref=e49]
|
||||||
|
- generic [ref=e54]: Login with Google
|
||||||
40
.playwright-mcp/page-2026-04-14T09-54-11-031Z.yml
Normal file
40
.playwright-mcp/page-2026-04-14T09-54-11-031Z.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
- generic [ref=e6]:
|
||||||
|
- heading "Login" [level=2] [ref=e7]
|
||||||
|
- paragraph [ref=e8]:
|
||||||
|
- text: "Demo:"
|
||||||
|
- strong [ref=e9]: superadmin@example.com
|
||||||
|
- text: /
|
||||||
|
- strong [ref=e10]: superadmin123
|
||||||
|
- text: "or:"
|
||||||
|
- strong [ref=e11]: user@example.com
|
||||||
|
- text: /
|
||||||
|
- strong [ref=e12]: user123
|
||||||
|
- alert [ref=e55]:
|
||||||
|
- generic [ref=e56]:
|
||||||
|
- img [ref=e58]
|
||||||
|
- generic [ref=e61]: Email atau password salah
|
||||||
|
- generic [ref=e13]:
|
||||||
|
- generic [ref=e14]: Email *
|
||||||
|
- generic [ref=e15]:
|
||||||
|
- img [ref=e17]
|
||||||
|
- textbox "Email" [ref=e20]:
|
||||||
|
- /placeholder: email@example.com
|
||||||
|
- text: superadmin@example.com
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- generic [ref=e22]: Password *
|
||||||
|
- generic [ref=e23]:
|
||||||
|
- img [ref=e25]
|
||||||
|
- textbox "Password" [ref=e30]: superadmin123
|
||||||
|
- button [ref=e32] [cursor=pointer]:
|
||||||
|
- img [ref=e34]
|
||||||
|
- button "Sign in" [ref=e36] [cursor=pointer]:
|
||||||
|
- generic [ref=e37]:
|
||||||
|
- img [ref=e39]
|
||||||
|
- generic [ref=e43]: Sign in
|
||||||
|
- separator [ref=e44]:
|
||||||
|
- generic [ref=e45]: or
|
||||||
|
- link "Login with Google" [ref=e46] [cursor=pointer]:
|
||||||
|
- /url: /api/auth/google
|
||||||
|
- generic [ref=e47]:
|
||||||
|
- img [ref=e49]
|
||||||
|
- generic [ref=e54]: Login with Google
|
||||||
4
.playwright-mcp/page-2026-04-15T00-10-33-255Z.yml
Normal file
4
.playwright-mcp/page-2026-04-15T00-10-33-255Z.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
- generic [active]:
|
||||||
|
- generic:
|
||||||
|
- generic:
|
||||||
|
- generic: Loading...
|
||||||
131
.playwright-mcp/page-2026-04-15T00-12-16-729Z.yml
Normal file
131
.playwright-mcp/page-2026-04-15T00-12-16-729Z.yml
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- generic:
|
||||||
|
- generic:
|
||||||
|
- generic: Loading...
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- banner [ref=e4]:
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- generic [ref=e6]:
|
||||||
|
- button [ref=e7] [cursor=pointer]
|
||||||
|
- generic [ref=e9]:
|
||||||
|
- img [ref=e11]
|
||||||
|
- paragraph [ref=e13]: Monitoring System
|
||||||
|
- generic [ref=e14]:
|
||||||
|
- button "Toggle color scheme" [ref=e15] [cursor=pointer]:
|
||||||
|
- img [ref=e17]
|
||||||
|
- generic "User" [ref=e20] [cursor=pointer]:
|
||||||
|
- img [ref=e21]
|
||||||
|
- navigation [ref=e23]:
|
||||||
|
- generic [ref=e24]:
|
||||||
|
- link "Dashboard" [ref=e25] [cursor=pointer]:
|
||||||
|
- /url: /dashboard
|
||||||
|
- img [ref=e27]
|
||||||
|
- generic [ref=e31]: Dashboard
|
||||||
|
- img [ref=e33]
|
||||||
|
- link "Applications" [ref=e35] [cursor=pointer]:
|
||||||
|
- /url: /apps
|
||||||
|
- img [ref=e37]
|
||||||
|
- generic [ref=e41]: Applications
|
||||||
|
- img [ref=e43]
|
||||||
|
- link "Log Activity" [ref=e45] [cursor=pointer]:
|
||||||
|
- /url: /logs
|
||||||
|
- img [ref=e47]
|
||||||
|
- generic [ref=e50]: Log Activity
|
||||||
|
- img [ref=e52]
|
||||||
|
- link "Error Reports" [ref=e54] [cursor=pointer]:
|
||||||
|
- /url: /bug-reports
|
||||||
|
- img [ref=e56]
|
||||||
|
- generic [ref=e58]: Error Reports
|
||||||
|
- img [ref=e60]
|
||||||
|
- link "Users" [ref=e62] [cursor=pointer]:
|
||||||
|
- /url: /users
|
||||||
|
- img [ref=e64]
|
||||||
|
- generic [ref=e67]: Users
|
||||||
|
- img [ref=e69]
|
||||||
|
- generic [ref=e72]:
|
||||||
|
- generic [ref=e73]:
|
||||||
|
- paragraph [ref=e74]: SYSTEM STATUS
|
||||||
|
- paragraph [ref=e77]: All Systems Operational
|
||||||
|
- button "Log out" [ref=e78] [cursor=pointer]:
|
||||||
|
- generic [ref=e79]:
|
||||||
|
- img [ref=e81]
|
||||||
|
- generic [ref=e85]: Log out
|
||||||
|
- main [ref=e86]:
|
||||||
|
- generic [ref=e88]:
|
||||||
|
- generic [ref=e90]:
|
||||||
|
- heading "Overview Dashboard" [level=2] [ref=e91]
|
||||||
|
- paragraph [ref=e92]: Welcome back, Super Admin. Here is what's happening today.
|
||||||
|
- generic [ref=e93]:
|
||||||
|
- generic [ref=e94]:
|
||||||
|
- img [ref=e97]
|
||||||
|
- generic [ref=e101]:
|
||||||
|
- paragraph [ref=e102]: Total Applications
|
||||||
|
- paragraph [ref=e103]: "1"
|
||||||
|
- generic [ref=e104]:
|
||||||
|
- img [ref=e107]
|
||||||
|
- generic [ref=e109]:
|
||||||
|
- paragraph [ref=e110]: New Errors
|
||||||
|
- paragraph [ref=e111]: "1"
|
||||||
|
- generic [ref=e112]:
|
||||||
|
- img [ref=e115]
|
||||||
|
- generic [ref=e120]:
|
||||||
|
- paragraph [ref=e121]: Users
|
||||||
|
- paragraph [ref=e122]: "4"
|
||||||
|
- generic [ref=e123]:
|
||||||
|
- heading "Registered Applications" [level=3] [ref=e124]
|
||||||
|
- link "View All Apps" [ref=e125] [cursor=pointer]:
|
||||||
|
- /url: /apps
|
||||||
|
- generic [ref=e126]:
|
||||||
|
- generic [ref=e127]: View All Apps
|
||||||
|
- img [ref=e129]
|
||||||
|
- generic [ref=e132]:
|
||||||
|
- generic [ref=e133]:
|
||||||
|
- generic [ref=e134]:
|
||||||
|
- img [ref=e137]
|
||||||
|
- generic [ref=e139]:
|
||||||
|
- paragraph [ref=e140]: Desa+
|
||||||
|
- paragraph [ref=e141]: VERSION 2.4.1
|
||||||
|
- generic [ref=e143]: ACTIVE
|
||||||
|
- link "View" [ref=e144] [cursor=pointer]:
|
||||||
|
- /url: /apps/desa-plus
|
||||||
|
- generic [ref=e145]:
|
||||||
|
- generic [ref=e146]: View
|
||||||
|
- img [ref=e148]
|
||||||
|
- generic [ref=e150]:
|
||||||
|
- heading "Recent Error Reports" [level=3] [ref=e151]
|
||||||
|
- link "View All Errors" [ref=e152] [cursor=pointer]:
|
||||||
|
- /url: /bug-reports
|
||||||
|
- generic [ref=e153]:
|
||||||
|
- generic [ref=e154]: View All Errors
|
||||||
|
- img [ref=e156]
|
||||||
|
- table [ref=e159]:
|
||||||
|
- rowgroup [ref=e160]:
|
||||||
|
- row "Application Error Message Version Time Severity" [ref=e161]:
|
||||||
|
- columnheader "Application" [ref=e162]
|
||||||
|
- columnheader "Error Message" [ref=e163]
|
||||||
|
- columnheader "Version" [ref=e164]
|
||||||
|
- columnheader "Time" [ref=e165]
|
||||||
|
- columnheader "Severity" [ref=e166]
|
||||||
|
- rowgroup [ref=e167]:
|
||||||
|
- row "desa-plus error saat menambah data project v2.1 1 days ago ON_HOLD" [ref=e168]:
|
||||||
|
- cell "desa-plus" [ref=e169]:
|
||||||
|
- paragraph [ref=e170]: desa-plus
|
||||||
|
- cell "error saat menambah data project" [ref=e171]:
|
||||||
|
- paragraph [ref=e172]: error saat menambah data project
|
||||||
|
- cell "v2.1" [ref=e173]:
|
||||||
|
- generic [ref=e175]: v2.1
|
||||||
|
- cell "1 days ago" [ref=e176]:
|
||||||
|
- paragraph [ref=e177]: 1 days ago
|
||||||
|
- cell "ON_HOLD" [ref=e178]:
|
||||||
|
- generic [ref=e180]: ON_HOLD
|
||||||
|
- row "desa-plus error pada saat login v2.1.0 1 days ago OPEN" [ref=e181]:
|
||||||
|
- cell "desa-plus" [ref=e182]:
|
||||||
|
- paragraph [ref=e183]: desa-plus
|
||||||
|
- cell "error pada saat login" [ref=e184]:
|
||||||
|
- paragraph [ref=e185]: error pada saat login
|
||||||
|
- cell "v2.1.0" [ref=e186]:
|
||||||
|
- generic [ref=e188]: v2.1.0
|
||||||
|
- cell "1 days ago" [ref=e189]:
|
||||||
|
- paragraph [ref=e190]: 1 days ago
|
||||||
|
- cell "OPEN" [ref=e191]:
|
||||||
|
- generic [ref=e193]: OPEN
|
||||||
13
.qwen/settings.json
Normal file
13
.qwen/settings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest",
|
||||||
|
"--headless"
|
||||||
|
],
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$version": 3
|
||||||
|
}
|
||||||
9
.qwen/settings.json.orig
Normal file
9
.qwen/settings.json.orig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@playwright/mcp@latest", "--headless"],
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,15 @@ React 19 + Vite 8 (middleware mode in dev). File-based routing with TanStack Rou
|
|||||||
- HMR: Vite 8 with `@vitejs/plugin-react` v6. `dedupeRefreshPlugin` fixes double React Refresh injection.
|
- HMR: Vite 8 with `@vitejs/plugin-react` v6. `dedupeRefreshPlugin` fixes double React Refresh injection.
|
||||||
- Editor: `REACT_EDITOR` env var. `zed` and `subl` use `file:line:col`, others use `--goto file:line:col`.
|
- Editor: `REACT_EDITOR` env var. `zed` and `subl` use `file:line:col`, others use `--goto file:line:col`.
|
||||||
|
|
||||||
|
## Playwright MCP
|
||||||
|
|
||||||
|
Playwright MCP server enables AI-assisted browser automation for testing and debugging.
|
||||||
|
|
||||||
|
- MCP config: `.qwen/settings.json` — Qwen Code auto-loads on session start
|
||||||
|
- Playwright config: `playwright.config.ts` — E2E test configuration
|
||||||
|
- Run manually: `bun run mcp:playwright` — starts headless browser MCP server
|
||||||
|
- Install browsers: `bunx playwright install` — downloads Chromium and other browsers
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests use `bun:test`. Three levels:
|
Tests use `bun:test`. Three levels:
|
||||||
|
|||||||
100
PLAYWRIGHT_MCP.md
Normal file
100
PLAYWRIGHT_MCP.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Playwright MCP Setup
|
||||||
|
|
||||||
|
This project includes Playwright MCP (Model Context Protocol) for AI-assisted browser automation.
|
||||||
|
|
||||||
|
## What is Playwright MCP?
|
||||||
|
|
||||||
|
Playwright MCP allows AI assistants (like Claude) to interact with a real browser through the Model Context Protocol. This enables:
|
||||||
|
- Automated browser testing
|
||||||
|
- Web scraping and data extraction
|
||||||
|
- Visual testing and screenshots
|
||||||
|
- Navigation and interaction with web pages
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
All dependencies are already installed:
|
||||||
|
- `@playwright/mcp` - MCP server for Playwright
|
||||||
|
- `@playwright/test` - Playwright test framework
|
||||||
|
- `playwright` - Browser automation library
|
||||||
|
- Chromium browser (downloaded via `bunx playwright install`)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Qwen Code MCP Config (`.qwen/settings.json`)
|
||||||
|
|
||||||
|
Qwen Code automatically loads this file on new session start:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@playwright/mcp@latest", "--headless"],
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Playwright Config (`playwright.config.ts`)
|
||||||
|
Standard E2E test configuration with:
|
||||||
|
- Chromium browser
|
||||||
|
- Base URL: http://localhost:3000
|
||||||
|
- Auto-starts dev server for testing
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Start MCP Server
|
||||||
|
```bash
|
||||||
|
bun run mcp:playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the MCP server on port 3000 in headless mode. AI assistants can connect to this server to control the browser.
|
||||||
|
|
||||||
|
### Run E2E Tests
|
||||||
|
```bash
|
||||||
|
# Using Playwright's test runner
|
||||||
|
bunx playwright test
|
||||||
|
|
||||||
|
# Using the existing test suite
|
||||||
|
bun run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install/Update Browsers
|
||||||
|
```bash
|
||||||
|
# Install all browsers
|
||||||
|
bunx playwright install
|
||||||
|
|
||||||
|
# Install specific browser
|
||||||
|
bunx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with AI Assistants
|
||||||
|
|
||||||
|
When using an AI assistant that supports MCP:
|
||||||
|
1. Start your app: `bun run dev`
|
||||||
|
2. Start the MCP server: `bun run mcp:playwright`
|
||||||
|
3. The AI assistant can now:
|
||||||
|
- Navigate to your app
|
||||||
|
- Take screenshots
|
||||||
|
- Click elements and fill forms
|
||||||
|
- Test user flows
|
||||||
|
- Debug UI issues
|
||||||
|
|
||||||
|
## Available MCP Tools
|
||||||
|
|
||||||
|
The Playwright MCP server provides tools for:
|
||||||
|
- `browser_navigate` - Navigate to a URL
|
||||||
|
- `browser_screenshot` - Take a screenshot
|
||||||
|
- `browser_click` - Click an element
|
||||||
|
- `browser_type` - Type text into an element
|
||||||
|
- `browser_select_option` - Select dropdown options
|
||||||
|
- `browser_hover` - Hover over elements
|
||||||
|
- `browser_evaluate` - Execute JavaScript
|
||||||
|
- `browser_snapshot` - Get page accessibility snapshot
|
||||||
|
- And more...
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `mcp.json` - MCP server configuration
|
||||||
|
- `playwright.config.ts` - Playwright test configuration
|
||||||
|
- `tests/e2e/` - E2E test files
|
||||||
8
bun.lock
8
bun.lock
@@ -6,6 +6,7 @@
|
|||||||
"name": "bun-react-template",
|
"name": "bun-react-template",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"@elysiajs/eden": "^1.4.9",
|
||||||
"@elysiajs/html": "^1.4.0",
|
"@elysiajs/html": "^1.4.0",
|
||||||
"@mantine/charts": "^9.0.0",
|
"@mantine/charts": "^9.0.0",
|
||||||
"@mantine/core": "^8.3.18",
|
"@mantine/core": "^8.3.18",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"swr": "^2.4.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.10",
|
||||||
@@ -99,6 +101,8 @@
|
|||||||
|
|
||||||
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
|
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
|
||||||
|
|
||||||
|
"@elysiajs/eden": ["@elysiajs/eden@1.4.9", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="],
|
||||||
|
|
||||||
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
|
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||||
@@ -427,6 +431,8 @@
|
|||||||
|
|
||||||
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
|
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
|
||||||
|
|
||||||
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -705,6 +711,8 @@
|
|||||||
|
|
||||||
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
|
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
|
||||||
|
|
||||||
|
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||||
|
|
||||||
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],
|
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],
|
||||||
|
|||||||
35
login-snapshot.yml
Normal file
35
login-snapshot.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
- generic [ref=e6]:
|
||||||
|
- heading "Login" [level=2] [ref=e7]
|
||||||
|
- paragraph [ref=e8]:
|
||||||
|
- text: "Demo:"
|
||||||
|
- strong [ref=e9]: superadmin@example.com
|
||||||
|
- text: /
|
||||||
|
- strong [ref=e10]: superadmin123
|
||||||
|
- text: "or:"
|
||||||
|
- strong [ref=e11]: user@example.com
|
||||||
|
- text: /
|
||||||
|
- strong [ref=e12]: user123
|
||||||
|
- generic [ref=e13]:
|
||||||
|
- generic [ref=e14]: Email *
|
||||||
|
- generic [ref=e15]:
|
||||||
|
- img [ref=e17]
|
||||||
|
- textbox "Email" [ref=e20]:
|
||||||
|
- /placeholder: email@example.com
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- generic [ref=e22]: Password *
|
||||||
|
- generic [ref=e23]:
|
||||||
|
- img [ref=e25]
|
||||||
|
- textbox "Password" [ref=e30]
|
||||||
|
- button [ref=e32] [cursor=pointer]:
|
||||||
|
- img [ref=e34]
|
||||||
|
- button "Sign in" [ref=e36] [cursor=pointer]:
|
||||||
|
- generic [ref=e37]:
|
||||||
|
- img [ref=e39]
|
||||||
|
- generic [ref=e43]: Sign in
|
||||||
|
- separator [ref=e44]:
|
||||||
|
- generic [ref=e45]: or
|
||||||
|
- link "Login with Google" [ref=e46] [cursor=pointer]:
|
||||||
|
- /url: /api/auth/google
|
||||||
|
- generic [ref=e47]:
|
||||||
|
- img [ref=e49]
|
||||||
|
- generic [ref=e54]: Login with Google
|
||||||
11
package.json
11
package.json
@@ -18,14 +18,17 @@
|
|||||||
"db:seed": "bun run prisma/seed.ts",
|
"db:seed": "bun run prisma/seed.ts",
|
||||||
"db:studio": "bunx prisma studio",
|
"db:studio": "bunx prisma studio",
|
||||||
"db:generate": "bunx prisma generate",
|
"db:generate": "bunx prisma generate",
|
||||||
"db:push": "bunx prisma db push"
|
"db:push": "bunx prisma db push",
|
||||||
|
"mcp:playwright": "playwright-mcp --headless --port 3000"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"@elysiajs/eden": "^1.4.9",
|
||||||
"@elysiajs/html": "^1.4.0",
|
"@elysiajs/html": "^1.4.0",
|
||||||
"@mantine/charts": "^9.0.0",
|
"@mantine/charts": "^9.0.0",
|
||||||
"@mantine/core": "^8.3.18",
|
"@mantine/core": "^8.3.18",
|
||||||
"@mantine/hooks": "^8.3.18",
|
"@mantine/hooks": "^8.3.18",
|
||||||
|
"@mantine/notifications": "^8.3.18",
|
||||||
"@prisma/client": "6",
|
"@prisma/client": "6",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@tanstack/react-router": "^1.168.10",
|
"@tanstack/react-router": "^1.168.10",
|
||||||
@@ -36,15 +39,19 @@
|
|||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1",
|
||||||
|
"swr": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.10",
|
||||||
|
"@playwright/mcp": "^0.0.70",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tanstack/router-vite-plugin": "^1.166.27",
|
"@tanstack/router-vite-plugin": "^1.166.27",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"playwright": "^1.59.1",
|
||||||
"prisma": "6",
|
"prisma": "6",
|
||||||
"puppeteer-core": "^24.40.0",
|
"puppeteer-core": "^24.40.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
|
|||||||
90
playwright-report/index.html
Normal file
90
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
27
playwright.config.ts
Normal file
27
playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: 'bun run dev',
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
12
prisma/migrations/20260413034305_add/migration.sql
Normal file
12
prisma/migrations/20260413034305_add/migration.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Changed the type of `type` on the `log` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LogType" AS ENUM ('CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "log" DROP COLUMN "type",
|
||||||
|
ADD COLUMN "type" "LogType" NOT NULL;
|
||||||
2
prisma/migrations/20260413044057_add/migration.sql
Normal file
2
prisma/migrations/20260413044057_add/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "image" TEXT;
|
||||||
2
prisma/migrations/20260413071605_add/migration.sql
Normal file
2
prisma/migrations/20260413071605_add/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "bug_log" ALTER COLUMN "userId" DROP NOT NULL;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The `app` column on the `bug` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "bug" DROP COLUMN "app",
|
||||||
|
ADD COLUMN "app" TEXT;
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "App";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The values [USER,SUPER_ADMIN] on the enum `Role` will be removed. If these variants are still used in the database, this will fail.
|
||||||
|
- You are about to drop the column `app` on the `bug` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterEnum
|
||||||
|
BEGIN;
|
||||||
|
CREATE TYPE "Role_new" AS ENUM ('ADMIN', 'DEVELOPER');
|
||||||
|
ALTER TABLE "public"."user" ALTER COLUMN "role" DROP DEFAULT;
|
||||||
|
ALTER TABLE "user" ALTER COLUMN "role" TYPE "Role_new" USING ("role"::text::"Role_new");
|
||||||
|
ALTER TYPE "Role" RENAME TO "Role_old";
|
||||||
|
ALTER TYPE "Role_new" RENAME TO "Role";
|
||||||
|
DROP TYPE "public"."Role_old";
|
||||||
|
ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'ADMIN';
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "bug" DROP COLUMN "app",
|
||||||
|
ADD COLUMN "appId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'ADMIN';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "App" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"version" TEXT NOT NULL,
|
||||||
|
"minVersion" TEXT NOT NULL,
|
||||||
|
"maintenance" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "App_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "bug" ADD CONSTRAINT "bug_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "App" ALTER COLUMN "version" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "minVersion" DROP NOT NULL;
|
||||||
@@ -9,16 +9,10 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
USER
|
|
||||||
ADMIN
|
ADMIN
|
||||||
SUPER_ADMIN
|
|
||||||
DEVELOPER
|
DEVELOPER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum App{
|
|
||||||
desa_plus
|
|
||||||
hipmi
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BugSource{
|
enum BugSource{
|
||||||
QC
|
QC
|
||||||
@@ -35,13 +29,22 @@ enum BugStatus{
|
|||||||
CLOSED
|
CLOSED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LogType{
|
||||||
|
CREATE
|
||||||
|
UPDATE
|
||||||
|
DELETE
|
||||||
|
LOGIN
|
||||||
|
LOGOUT
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
role Role @default(USER)
|
role Role @default(ADMIN)
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -66,10 +69,23 @@ model Session {
|
|||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model App {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
version String?
|
||||||
|
minVersion String?
|
||||||
|
maintenance Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
bugs Bug[]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
model Log {
|
model Log {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
type String
|
type LogType
|
||||||
message String
|
message String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -81,12 +97,12 @@ model Log {
|
|||||||
model Bug {
|
model Bug {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String?
|
userId String?
|
||||||
app App
|
appId String?
|
||||||
affectedVersion String
|
affectedVersion String
|
||||||
device String
|
device String
|
||||||
os String
|
os String
|
||||||
status BugStatus
|
status BugStatus
|
||||||
source BugSource
|
source BugSource
|
||||||
description String
|
description String
|
||||||
stackTrace String?
|
stackTrace String?
|
||||||
fixedVersion String?
|
fixedVersion String?
|
||||||
@@ -95,6 +111,7 @@ model Bug {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
app App? @relation(fields: [appId], references: [id])
|
||||||
|
|
||||||
images BugImage[]
|
images BugImage[]
|
||||||
logs BugLog[]
|
logs BugLog[]
|
||||||
@@ -116,13 +133,13 @@ model BugImage {
|
|||||||
model BugLog {
|
model BugLog {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
bugId String
|
bugId String
|
||||||
userId String
|
userId String?
|
||||||
status BugStatus
|
status BugStatus
|
||||||
description String
|
description String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade)
|
bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("bug_log")
|
@@map("bug_log")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ const SUPER_ADMIN_EMAILS = (process.env.SUPER_ADMIN_EMAIL ?? '').split(',').map(
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const users = [
|
const users = [
|
||||||
{ name: 'Super Admin', email: 'superadmin@example.com', password: 'superadmin123', role: 'SUPER_ADMIN' as const },
|
|
||||||
{ name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const },
|
{ name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const },
|
||||||
{ name: 'User', email: 'user@example.com', password: 'user123', role: 'USER' as const },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
@@ -21,13 +19,28 @@ async function main() {
|
|||||||
console.log(`Seeded: ${u.email} (${u.role})`)
|
console.log(`Seeded: ${u.email} (${u.role})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote super admin emails from env
|
// Promote DEVELOPER emails from env
|
||||||
for (const email of SUPER_ADMIN_EMAILS) {
|
for (const email of SUPER_ADMIN_EMAILS) {
|
||||||
const user = await prisma.user.findUnique({ where: { email } })
|
const password = await Bun.password.hash('developer123', { algorithm: 'bcrypt' })
|
||||||
if (user && user.role !== 'SUPER_ADMIN') {
|
await prisma.user.upsert({
|
||||||
await prisma.user.update({ where: { email }, data: { role: 'SUPER_ADMIN' } })
|
where: { email },
|
||||||
console.log(`Promoted to SUPER_ADMIN: ${email}`)
|
update: { role: 'DEVELOPER', password },
|
||||||
}
|
create: { name: email.split('@')[0].toUpperCase(), email, password, role: 'DEVELOPER' },
|
||||||
|
})
|
||||||
|
console.log(`Promoted to DEVELOPER: ${email}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = [
|
||||||
|
{ id: 'desa-plus', name: 'Desa+' },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const a of apps) {
|
||||||
|
await prisma.app.upsert({
|
||||||
|
where: { id: a.id },
|
||||||
|
update: { name: a.name },
|
||||||
|
create: { id: a.id, name: a.name },
|
||||||
|
})
|
||||||
|
console.log(`Seeded: ${a.name}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
569
src/app.ts
569
src/app.ts
@@ -3,6 +3,7 @@ import { html } from '@elysiajs/html'
|
|||||||
import { Elysia } from 'elysia'
|
import { Elysia } from 'elysia'
|
||||||
import { prisma } from './lib/db'
|
import { prisma } from './lib/db'
|
||||||
import { env } from './lib/env'
|
import { env } from './lib/env'
|
||||||
|
import { createSystemLog } from './lib/logger'
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
return new Elysia()
|
return new Elysia()
|
||||||
@@ -36,20 +37,27 @@ export function createApp() {
|
|||||||
return { error: 'Email atau password salah' }
|
return { error: 'Email atau password salah' }
|
||||||
}
|
}
|
||||||
// Auto-promote super admin from env
|
// Auto-promote super admin from env
|
||||||
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') {
|
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
|
||||||
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } })
|
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
|
||||||
}
|
}
|
||||||
const token = crypto.randomUUID()
|
const token = crypto.randomUUID()
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
|
||||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
||||||
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
||||||
|
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
|
||||||
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
|
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
|
||||||
})
|
})
|
||||||
|
|
||||||
.post('/api/auth/logout', async ({ request, set }) => {
|
.post('/api/auth/logout', async ({ request, set }) => {
|
||||||
const cookie = request.headers.get('cookie') ?? ''
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
if (token) await prisma.session.deleteMany({ where: { token } })
|
if (token) {
|
||||||
|
const sessionObj = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (sessionObj) {
|
||||||
|
await createSystemLog(sessionObj.userId, 'LOGOUT', 'Logged out successfully')
|
||||||
|
await prisma.session.deleteMany({ where: { token } })
|
||||||
|
}
|
||||||
|
}
|
||||||
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
|
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
@@ -70,98 +78,477 @@ export function createApp() {
|
|||||||
return { user: session.user }
|
return { user: session.user }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Google OAuth ──────────────────────────────────
|
|
||||||
.get('/api/auth/google', ({ request, set }) => {
|
|
||||||
const origin = new URL(request.url).origin
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: env.GOOGLE_CLIENT_ID,
|
|
||||||
redirect_uri: `${origin}/api/auth/callback/google`,
|
|
||||||
response_type: 'code',
|
|
||||||
scope: 'openid email profile',
|
|
||||||
access_type: 'offline',
|
|
||||||
prompt: 'consent',
|
|
||||||
})
|
|
||||||
set.status = 302; set.headers['location'] =`https://accounts.google.com/o/oauth2/v2/auth?${params}`
|
|
||||||
})
|
|
||||||
|
|
||||||
.get('/api/auth/callback/google', async ({ request, set }) => {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
const code = url.searchParams.get('code')
|
|
||||||
const origin = url.origin
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
set.status = 302; set.headers['location'] ='/login?error=google_failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange code for tokens
|
|
||||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
code,
|
|
||||||
client_id: env.GOOGLE_CLIENT_ID,
|
|
||||||
client_secret: env.GOOGLE_CLIENT_SECRET,
|
|
||||||
redirect_uri: `${origin}/api/auth/callback/google`,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!tokenRes.ok) {
|
|
||||||
set.status = 302; set.headers['location'] ='/login?error=google_failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = (await tokenRes.json()) as { access_token: string }
|
|
||||||
|
|
||||||
// Get user info
|
|
||||||
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
||||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!userInfoRes.ok) {
|
|
||||||
set.status = 302; set.headers['location'] ='/login?error=google_failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const googleUser = (await userInfoRes.json()) as { email: string; name: string }
|
|
||||||
|
|
||||||
// Upsert user (no password for Google users)
|
|
||||||
const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email)
|
|
||||||
const user = await prisma.user.upsert({
|
|
||||||
where: { email: googleUser.email },
|
|
||||||
update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) },
|
|
||||||
create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create session
|
|
||||||
const token = crypto.randomUUID()
|
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
|
||||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
|
||||||
|
|
||||||
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
|
||||||
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Monitoring API ────────────────────────────────
|
// ─── Monitoring API ────────────────────────────────
|
||||||
.get('/api/dashboard/stats', () => ({
|
.get('/api/dashboard/stats', async () => {
|
||||||
totalApps: 3,
|
const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } })
|
||||||
newErrors: 185,
|
const users = await prisma.user.count()
|
||||||
activeUsers: '24.5k',
|
return {
|
||||||
trends: { totalApps: 1, newErrors: 12, activeUsers: 5.2 }
|
totalApps: 1,
|
||||||
}))
|
newErrors: newErrors,
|
||||||
|
activeUsers: users,
|
||||||
.get('/api/apps', () => [
|
trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 }
|
||||||
{ id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
|
}
|
||||||
{ id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' },
|
})
|
||||||
{ id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' },
|
|
||||||
])
|
.get('/api/dashboard/recent-errors', async () => {
|
||||||
|
const bugs = await prisma.bug.findMany({
|
||||||
.get('/api/apps/:appId', ({ params: { appId } }) => {
|
take: 5,
|
||||||
const apps = {
|
orderBy: { createdAt: 'desc' }
|
||||||
'desa-plus': { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
|
})
|
||||||
|
return bugs.map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
app: b.appId,
|
||||||
|
message: b.description,
|
||||||
|
version: b.affectedVersion,
|
||||||
|
time: b.createdAt.toISOString(),
|
||||||
|
severity: b.status
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/api/apps', async ({ query }) => {
|
||||||
|
const search = (query.search as string) || ''
|
||||||
|
const where: any = {}
|
||||||
|
if (search) {
|
||||||
|
where.name = { contains: search, mode: 'insensitive' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = await prisma.app.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
_count: { select: { bugs: true } },
|
||||||
|
bugs: { where: { status: 'OPEN' }, select: { id: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return apps.map((app) => ({
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active',
|
||||||
|
errors: app.bugs.length,
|
||||||
|
version: app.version ?? '-',
|
||||||
|
maintenance: app.maintenance,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/api/apps/:appId', async ({ params: { appId }, set }) => {
|
||||||
|
const app = await prisma.app.findUnique({
|
||||||
|
where: { id: appId },
|
||||||
|
include: {
|
||||||
|
_count: { select: { bugs: true } },
|
||||||
|
bugs: { where: { status: 'OPEN' }, select: { id: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
set.status = 404
|
||||||
|
return { error: 'App not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active',
|
||||||
|
errors: app.bugs.length,
|
||||||
|
version: app.version ?? '-',
|
||||||
|
minVersion: app.minVersion,
|
||||||
|
maintenance: app.maintenance,
|
||||||
|
totalBugs: app._count.bugs,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/api/logs', async ({ query }) => {
|
||||||
|
const page = Number(query.page) || 1
|
||||||
|
const limit = Number(query.limit) || 20
|
||||||
|
const search = (query.search as string) || ''
|
||||||
|
const type = query.type as any
|
||||||
|
const userId = query.userId as string
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ message: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ user: { name: { contains: search, mode: 'insensitive' } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
where.type = type
|
||||||
|
}
|
||||||
|
if (userId && userId !== 'all') {
|
||||||
|
where.userId = userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
prisma.log.findMany({
|
||||||
|
where,
|
||||||
|
include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.log.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: logs,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
totalItems: total
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/api/logs', async ({ request, set }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
userId = session.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as { type: string, message: string }
|
||||||
|
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || ''
|
||||||
|
|
||||||
|
await createSystemLog(actingUserId, body.type as any, body.message)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/api/operators', async ({ query }) => {
|
||||||
|
const page = Number(query.page) || 1
|
||||||
|
const limit = Number(query.limit) || 20
|
||||||
|
const search = (query.search as string) || ''
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ email: { contains: search, mode: 'insensitive' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: users,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
totalItems: total
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/api/operators/stats', async () => {
|
||||||
|
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
|
||||||
|
prisma.user.count({where: {active: true}}),
|
||||||
|
prisma.session.count({
|
||||||
|
where: { expiresAt: { gte: new Date() } },
|
||||||
|
}),
|
||||||
|
prisma.user.groupBy({
|
||||||
|
by: ['role'],
|
||||||
|
_count: true
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalStaff,
|
||||||
|
activeNow,
|
||||||
|
rolesCount: rolesGroup.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/api/operators', async ({ request, set }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as { name: string; email: string; password: string; role: string }
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: body.email } })
|
||||||
|
if (existing) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: 'Email sudah terdaftar' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await Bun.password.hash(body.password)
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
email: body.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: body.role as any,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await createSystemLog(userId, 'CREATE', `Created new user: ${body.name} (${body.email})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: user.id, name: user.name, email: user.email, role: user.role }
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/api/operators/:id', async ({ params: { id }, request, set }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as { name?: string; email?: string; role?: string; active?: boolean }
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.name !== undefined && { name: body.name }),
|
||||||
|
...(body.email !== undefined && { email: body.email }),
|
||||||
|
...(body.role !== undefined && { role: body.role as any }),
|
||||||
|
...(body.active !== undefined && { active: body.active }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: user.id, name: user.name, email: user.email, role: user.role, active: user.active }
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete('/api/operators/:id', async ({ params: { id }, request, set }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id } })
|
||||||
|
if (!user) {
|
||||||
|
set.status = 404
|
||||||
|
return { error: 'User not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting self
|
||||||
|
if (userId === id) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: 'Cannot delete your own account' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.session.deleteMany({ where: { userId: id } })
|
||||||
|
await prisma.user.update({ where: { id }, data: { active: false } })
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await createSystemLog(userId, 'DELETE', `Deactivated user: ${user.name} (${user.email})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/api/logs/operators', async () => {
|
||||||
|
return await prisma.user.findMany({
|
||||||
|
select: { id: true, name: true, image: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/api/bugs', async ({ query }) => {
|
||||||
|
const page = Number(query.page) || 1
|
||||||
|
const limit = Number(query.limit) || 20
|
||||||
|
const search = (query.search as string) || ''
|
||||||
|
const app = query.app as any
|
||||||
|
const status = query.status as any
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ device: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ os: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ affectedVersion: { contains: search, mode: 'insensitive' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (app && app !== 'all') {
|
||||||
|
where.appId = app
|
||||||
|
}
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bugs, total] = await Promise.all([
|
||||||
|
prisma.bug.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
images: true,
|
||||||
|
logs: {
|
||||||
|
include: { user: { select: { id: true, name: true, image: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.bug.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: bugs,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
totalItems: total,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/api/bugs', async ({ request, set }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
userId = session.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as any
|
||||||
|
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||||
|
const actingUserId = userId || defaultAdmin?.id || ''
|
||||||
|
|
||||||
|
const bug = await prisma.bug.create({
|
||||||
|
data: {
|
||||||
|
appId: body.app,
|
||||||
|
affectedVersion: body.affectedVersion,
|
||||||
|
device: body.device,
|
||||||
|
os: body.os,
|
||||||
|
status: body.status || 'OPEN',
|
||||||
|
source: body.source || 'USER',
|
||||||
|
description: body.description,
|
||||||
|
stackTrace: body.stackTrace,
|
||||||
|
userId: userId,
|
||||||
|
images: body.imageUrl ? {
|
||||||
|
create: {
|
||||||
|
imageUrl: body.imageUrl
|
||||||
|
}
|
||||||
|
} : undefined,
|
||||||
|
logs: {
|
||||||
|
create: {
|
||||||
|
userId: actingUserId,
|
||||||
|
status: body.status || 'OPEN',
|
||||||
|
description: 'Bug reported initially.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return bug
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/api/bugs/:id/feedback', async ({ params: { id }, request }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
userId = session.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as { feedBack: string }
|
||||||
|
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||||
|
const actingUserId = userId || defaultAdmin?.id || undefined
|
||||||
|
|
||||||
|
const bug = await prisma.bug.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
feedBack: body.feedBack,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (actingUserId) {
|
||||||
|
await createSystemLog(actingUserId, 'UPDATE', `Updated bug report feedback - ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bug
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/api/bugs/:id/status', async ({ params: { id }, request }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
userId = session.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as { status: string; description?: string }
|
||||||
|
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||||
|
const actingUserId = userId || defaultAdmin?.id || undefined
|
||||||
|
|
||||||
|
const bug = await prisma.bug.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: body.status as any,
|
||||||
|
logs: {
|
||||||
|
create: {
|
||||||
|
userId: actingUserId,
|
||||||
|
status: body.status as any,
|
||||||
|
description: body.description || `Status updated to ${body.status}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (actingUserId) {
|
||||||
|
await createSystemLog(actingUserId, 'UPDATE', `Updated bug report status to ${body.status}-${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bug
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── System Status API ─────────────────────────────
|
||||||
|
.get('/api/system/status', async () => {
|
||||||
|
try {
|
||||||
|
// Check database connectivity
|
||||||
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
const activeSessions = await prisma.session.count({
|
||||||
|
where: { expiresAt: { gte: new Date() } },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
status: 'operational',
|
||||||
|
database: 'connected',
|
||||||
|
activeSessions,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
status: 'degraded',
|
||||||
|
database: 'disconnected',
|
||||||
|
activeSessions: 0,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Example API ───────────────────────────────────
|
// ─── Example API ───────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
|
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
|
||||||
import '@mantine/core/styles.css'
|
import '@mantine/core/styles.css'
|
||||||
|
import '@mantine/notifications/styles.css'
|
||||||
|
import { Notifications } from '@mantine/notifications'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { createRouter, RouterProvider } from '@tanstack/react-router'
|
import { createRouter, RouterProvider } from '@tanstack/react-router'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
@@ -61,6 +63,7 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<ColorSchemeScript defaultColorScheme="auto" />
|
<ColorSchemeScript defaultColorScheme="auto" />
|
||||||
<MantineProvider theme={theme} defaultColorScheme="auto">
|
<MantineProvider theme={theme} defaultColorScheme="auto">
|
||||||
|
<Notifications />
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box, useComputedColorScheme } from '@mantine/core'
|
import { Avatar, Button, Card, Group, Stack, Text, useComputedColorScheme } from '@mantine/core'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
|
import { TbChevronRight, TbDeviceMobile } from 'react-icons/tb'
|
||||||
|
|
||||||
interface AppCardProps {
|
interface AppCardProps {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
status: 'active' | 'warning' | 'error'
|
status: 'active' | 'warning' | 'error'
|
||||||
users: number
|
users?: number
|
||||||
errors: number
|
errors: number
|
||||||
version: string
|
version: string
|
||||||
|
maintenance?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppCard({ id, name, status, users, errors, version }: AppCardProps) {
|
export function AppCard({ id, name, status, errors, version }: AppCardProps) {
|
||||||
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
|
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
|
||||||
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||||
|
|
||||||
@@ -46,12 +47,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
|
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
|
||||||
<Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text>
|
{/* <Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text> */}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Badge color={statusColor} variant="dot" size="sm">
|
{/* <Badge color={statusColor} variant="dot" size="sm">
|
||||||
{status.toUpperCase()}
|
{status.toUpperCase()}
|
||||||
</Badge>
|
</Badge> */}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* <Stack gap="md" mt="sm">
|
{/* <Stack gap="md" mt="sm">
|
||||||
|
|||||||
@@ -1,35 +1,22 @@
|
|||||||
|
import { BarChart, LineChart } from '@mantine/charts'
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Group,
|
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Box,
|
|
||||||
Badge,
|
|
||||||
useMantineTheme
|
useMantineTheme
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { LineChart, BarChart } from '@mantine/charts'
|
import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb'
|
||||||
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
|
|
||||||
|
|
||||||
const activityData = [
|
interface ChartProps {
|
||||||
{ date: 'Mar 26', logs: 1200 },
|
data?: any[]
|
||||||
{ date: 'Mar 27', logs: 1900 },
|
isLoading?: boolean
|
||||||
{ date: 'Mar 28', logs: 1540 },
|
}
|
||||||
{ date: 'Mar 29', logs: 2400 },
|
|
||||||
{ date: 'Mar 30', logs: 2100 },
|
|
||||||
{ date: 'Mar 31', logs: 3200 },
|
|
||||||
{ date: 'Apr 01', logs: 3800 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const villageComparisonData = [
|
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
||||||
{ village: 'Sukatani', activity: 4500 },
|
|
||||||
{ village: 'Sukamaju', activity: 3800 },
|
|
||||||
{ village: 'Bojong Gede', activity: 3200 },
|
|
||||||
{ village: 'Beji', activity: 2800 },
|
|
||||||
{ village: 'Tapos', activity: 2400 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function VillageActivityLineChart() {
|
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,15 +32,20 @@ export function VillageActivityLineChart() {
|
|||||||
<Text size="xs" c="dimmed">Trend over the last 7 days</Text>
|
<Text size="xs" c="dimmed">Trend over the last 7 days</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
|
{
|
||||||
Growing
|
isLoading && (
|
||||||
</Badge>
|
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
|
||||||
|
...
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box h={300} mt="lg">
|
<Box h={300} mt="lg">
|
||||||
<LineChart
|
<LineChart
|
||||||
h={300}
|
h={300}
|
||||||
data={activityData}
|
data={data}
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
series={[{ name: 'logs', color: '#2563EB' }]}
|
series={[{ name: 'logs', color: '#2563EB' }]}
|
||||||
curveType="monotone"
|
curveType="monotone"
|
||||||
@@ -61,6 +53,9 @@ export function VillageActivityLineChart() {
|
|||||||
gridAxis="x"
|
gridAxis="x"
|
||||||
withTooltip
|
withTooltip
|
||||||
tooltipAnimationDuration={200}
|
tooltipAnimationDuration={200}
|
||||||
|
tooltipProps={{
|
||||||
|
allowEscapeViewBox: { x: true, y: false },
|
||||||
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
'.recharts-line-curve': {
|
'.recharts-line-curve': {
|
||||||
@@ -76,7 +71,7 @@ export function VillageActivityLineChart() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VillageComparisonBarChart() {
|
export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps) {
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,7 +84,7 @@ export function VillageComparisonBarChart() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
|
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
|
||||||
<Text size="xs" c="dimmed">Top 5 most active village deployments</Text>
|
<Text size="xs" c="dimmed">Most active village deployments</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -97,22 +92,42 @@ export function VillageComparisonBarChart() {
|
|||||||
<Box h={300} mt="lg">
|
<Box h={300} mt="lg">
|
||||||
<BarChart
|
<BarChart
|
||||||
h={300}
|
h={300}
|
||||||
data={villageComparisonData}
|
data={data}
|
||||||
dataKey="village"
|
dataKey="village"
|
||||||
series={[{ name: 'activity', color: 'indigo.6' }]}
|
series={[{ name: 'activity', color: 'blue.6' }]} // Menggunakan warna dari theme
|
||||||
withTooltip
|
withTooltip
|
||||||
tickLine="none"
|
|
||||||
gridAxis="y"
|
|
||||||
barProps={{
|
barProps={{
|
||||||
radius: [8, 8, 4, 4],
|
radius: [8, 8, 0, 0],
|
||||||
|
fill: 'url(#barGradient)', // Menggunakan gradient yang Anda buat
|
||||||
}}
|
}}
|
||||||
styles={{
|
tooltipProps={{
|
||||||
bar: {
|
cursor: { fill: '#373A40', opacity: 0.4 },
|
||||||
fill: 'url(#barGradient)',
|
allowEscapeViewBox: { x: false, y: false },
|
||||||
|
content: ({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#1A1B1E',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #373A40',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||||
|
pointerEvents: 'none', // Sangat penting agar tidak mengganggu hover
|
||||||
|
whiteSpace: 'nowrap' // Mencegah teks turun ke bawah
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: 600, color: '#fff', marginBottom: '4px' }}>
|
||||||
|
{payload[0].payload.village}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#2563EB' }}>
|
||||||
|
Activity: <span style={{ fontWeight: 700 }}>{payload[0].value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Custom SVG Gradient definitions for Premium SaaS look */}
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} />
|
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
||||||
|
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -7,29 +8,33 @@ import {
|
|||||||
Burger,
|
Burger,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Menu,
|
Menu,
|
||||||
NavLink,
|
NavLink,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon
|
ThemeIcon,
|
||||||
|
useComputedColorScheme,
|
||||||
|
useMantineColorScheme
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDisclosure } from '@mantine/hooks'
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
|
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
|
TbAlertTriangle,
|
||||||
TbApps,
|
TbApps,
|
||||||
TbArrowLeft,
|
TbArrowLeft,
|
||||||
TbChevronRight,
|
TbChevronRight,
|
||||||
TbDashboard,
|
TbDashboard,
|
||||||
TbDeviceMobile,
|
TbDeviceMobile,
|
||||||
|
TbHistory,
|
||||||
TbLogout,
|
TbLogout,
|
||||||
TbSettings,
|
|
||||||
TbUserCircle,
|
|
||||||
TbSun,
|
|
||||||
TbMoon,
|
TbMoon,
|
||||||
|
TbSettings,
|
||||||
|
TbSun,
|
||||||
TbUser,
|
TbUser,
|
||||||
TbHistory
|
TbUserCircle
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
@@ -48,16 +53,52 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
const matches = useMatches()
|
const matches = useMatches()
|
||||||
const currentPath = matches[matches.length - 1]?.pathname
|
const currentPath = matches[matches.length - 1]?.pathname
|
||||||
|
|
||||||
|
// ─── Connect to auth system ──────────────────────────
|
||||||
|
const { data: sessionData } = useSession()
|
||||||
|
const user = sessionData?.user
|
||||||
|
const logout = useLogout()
|
||||||
|
|
||||||
|
// ─── Fetch registered apps from database ─────────────
|
||||||
|
const { data: appsData } = useQuery({
|
||||||
|
queryKey: ['apps'],
|
||||||
|
queryFn: () => fetch('/api/apps', { credentials: 'include' }).then((r) => r.json()),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Fetch system status from database ───────────────
|
||||||
|
const { data: systemStatus } = useQuery({
|
||||||
|
queryKey: ['system', 'status'],
|
||||||
|
queryFn: () => fetch('/api/system/status', { credentials: 'include' }).then((r) => r.json()),
|
||||||
|
refetchInterval: 30_000, // refresh every 30 seconds
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
const globalNav = [
|
const globalNav = [
|
||||||
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
|
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
|
||||||
{ label: 'Applications', icon: TbApps, to: '/apps' },
|
{ label: 'Applications', icon: TbApps, to: '/apps' },
|
||||||
{ label: 'Log Activity', icon: TbHistory, to: '/logs' },
|
{ label: 'Log Activity', icon: TbHistory, to: '/logs' },
|
||||||
|
{ label: 'Error Reports', icon: TbAlertTriangle, to: '/bug-reports' },
|
||||||
{ label: 'Users', icon: TbUser, to: '/users' },
|
{ label: 'Users', icon: TbUser, to: '/users' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeApp = appId ? APP_CONFIGS[appId] : null
|
const activeApp = appId ? APP_CONFIGS[appId] : null
|
||||||
const navLinks = activeApp ? activeApp.menus : globalNav
|
const navLinks = activeApp ? activeApp.menus : globalNav
|
||||||
|
|
||||||
|
// Build app selector data from API
|
||||||
|
const appSelectData = (appsData || []).map((app: any) => ({
|
||||||
|
value: app.id,
|
||||||
|
label: app.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// System status indicator
|
||||||
|
const isOperational = systemStatus?.status === 'operational'
|
||||||
|
const statusColor = isOperational ? '#10b981' : '#f59e0b'
|
||||||
|
const statusText = isOperational ? 'All Systems Operational' : 'System Degraded'
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 70 }}
|
header={{ height: 70 }}
|
||||||
@@ -112,21 +153,47 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={undefined}
|
src={undefined}
|
||||||
alt="User"
|
alt={user?.name || 'User'}
|
||||||
color="brand-blue"
|
color="brand-blue"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
>
|
||||||
|
{user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<Menu.Label>
|
||||||
|
<Text size="sm" fw={600} truncate>{user.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>{user.email}</Text>
|
||||||
|
</Menu.Label>
|
||||||
|
<Menu.Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Menu.Label>Application</Menu.Label>
|
<Menu.Label>Application</Menu.Label>
|
||||||
<Menu.Item leftSection={<TbUserCircle size={16} />}>Profile</Menu.Item>
|
<Menu.Item
|
||||||
<Menu.Item leftSection={<TbSettings size={16} />}>Settings</Menu.Item>
|
leftSection={<TbUserCircle size={16} />}
|
||||||
|
onClick={() => navigate({ to: '/profile' })}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<TbSettings size={16} />}
|
||||||
|
onClick={() => navigate({ to: '/dashboard' })}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Label>Danger Zone</Menu.Label>
|
<Menu.Label>Danger Zone</Menu.Label>
|
||||||
<Menu.Item color="red" leftSection={<TbLogout size={16} />}>
|
<Menu.Item
|
||||||
Logout
|
color="red"
|
||||||
|
leftSection={<TbLogout size={16} />}
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={logout.isPending}
|
||||||
|
>
|
||||||
|
{logout.isPending ? 'Logging out...' : 'Logout'}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -157,10 +224,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
<Select
|
<Select
|
||||||
label="Selected Application"
|
label="Selected Application"
|
||||||
value={appId}
|
value={appId}
|
||||||
data={[
|
data={appSelectData.length > 0 ? appSelectData : [
|
||||||
{ value: 'desa-plus', label: 'Desa+' },
|
{ value: 'desa-plus', label: 'Desa+' },
|
||||||
{ value: 'e-commerce', label: 'E-Commerce' },
|
|
||||||
{ value: 'fitness-app', label: 'Fitness App' },
|
|
||||||
]}
|
]}
|
||||||
onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })}
|
onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -222,19 +287,26 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
>
|
>
|
||||||
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
|
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981' }} />
|
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, boxShadow: `0 0 6px ${statusColor}` }} />
|
||||||
<Text size="sm" fw={500}>All Systems Operational</Text>
|
<Text size="sm" fw={500}>{statusText}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{systemStatus && (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{systemStatus.activeSessions} active session{systemStatus.activeSessions !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
fullWidth
|
fullWidth
|
||||||
leftSection={<TbLogout size={16} />}
|
leftSection={logout.isPending ? <Loader size={16} color="red" /> : <TbLogout size={16} />}
|
||||||
mt="md"
|
mt="md"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={logout.isPending}
|
||||||
>
|
>
|
||||||
Log out
|
{logout.isPending ? 'Logging out...' : 'Log out'}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -15,62 +15,35 @@ import {
|
|||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDisclosure } from '@mantine/hooks'
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
|
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
|
||||||
|
|
||||||
const mockErrors = [
|
export interface ErrorDataTableProps {
|
||||||
{
|
appId?: string
|
||||||
id: 1,
|
}
|
||||||
message: 'NullPointerException at village_sync.dart:45',
|
|
||||||
village: 'Sukatani',
|
|
||||||
version: 'v1.2.0',
|
|
||||||
timestamp: '2026-04-01 14:30:15',
|
|
||||||
severity: 'critical',
|
|
||||||
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
message: 'Failed to load citizen record session',
|
|
||||||
village: 'Sukamaju',
|
|
||||||
version: 'v1.1.8',
|
|
||||||
timestamp: '2026-04-01 14:15:22',
|
|
||||||
severity: 'high',
|
|
||||||
stackTrace: 'Error: Connection timeout reaching upstream citizen-db\n at HttpClient.get (network.dart:88)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
message: 'SocketException: Connection timed out',
|
|
||||||
village: 'Cikini',
|
|
||||||
version: 'v1.2.0',
|
|
||||||
timestamp: '2026-04-01 13:55:10',
|
|
||||||
severity: 'medium',
|
|
||||||
stackTrace: 'SocketException: OS Error: Connection timed out, errno = 110, address = 10.0.2.2, port = 54332'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
message: 'UI Thread blocking > 500ms',
|
|
||||||
village: 'Beji',
|
|
||||||
version: 'v1.1.2',
|
|
||||||
timestamp: '2026-04-01 13:40:00',
|
|
||||||
severity: 'low',
|
|
||||||
stackTrace: 'ANR (Application Not Responding) detected in main thread.'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ErrorDataTable() {
|
export function ErrorDataTable({ appId }: ErrorDataTableProps) {
|
||||||
const [opened, { open, close }] = useDisclosure(false)
|
const [opened, { open, close }] = useDisclosure(false)
|
||||||
const [selectedError, setSelectedError] = useState<any>(null)
|
const [selectedError, setSelectedError] = useState<any>(null)
|
||||||
|
|
||||||
|
const { data: bugsData, isLoading } = useQuery({
|
||||||
|
queryKey: ['bugs', appId],
|
||||||
|
queryFn: () => fetch(`/api/bugs?app=${appId || 'all'}&limit=10`).then((r) => r.json()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bugs = bugsData?.data || []
|
||||||
|
|
||||||
const handleRowClick = (error: any) => {
|
const handleRowClick = (error: any) => {
|
||||||
setSelectedError(error)
|
setSelectedError(error)
|
||||||
open()
|
open()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSeverityColor = (sev: string) => {
|
const getSeverityColor = (sev: string) => {
|
||||||
switch(sev) {
|
switch(sev?.toUpperCase()) {
|
||||||
case 'critical': return 'red'
|
case 'OPEN': return 'red'
|
||||||
case 'high': return 'orange'
|
case 'IN_PROGRESS': return 'orange'
|
||||||
case 'medium': return 'yellow'
|
case 'ON_HOLD': return 'yellow'
|
||||||
default: return 'gray'
|
default: return 'gray'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +59,7 @@ export function ErrorDataTable() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fw={700}>LATEST ERROR REPORTS</Text>
|
<Text fw={700}>LATEST ERROR REPORTS</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Button component={Link} to='/apps/desa-plus/errors' variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}>
|
<Button component={Link} to={appId ? `/apps/${appId}/errors` : '/bug-reports'} variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}>
|
||||||
View All Reports
|
View All Reports
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -97,37 +70,49 @@ export function ErrorDataTable() {
|
|||||||
<Table.Thead bg="rgba(0,0,0,0.1)">
|
<Table.Thead bg="rgba(0,0,0,0.1)">
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th px="xl">Error Message</Table.Th>
|
<Table.Th px="xl">Error Message</Table.Th>
|
||||||
<Table.Th>Village</Table.Th>
|
<Table.Th>Reporter</Table.Th>
|
||||||
<Table.Th>App Version</Table.Th>
|
<Table.Th>App Version</Table.Th>
|
||||||
<Table.Th>Timestamp</Table.Th>
|
<Table.Th>Timestamp</Table.Th>
|
||||||
<Table.Th pr="xl">Severity</Table.Th>
|
<Table.Th pr="xl">Severity</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{mockErrors.map((error) => (
|
{isLoading ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5} align="center" py="xl">
|
||||||
|
Loading errors...
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : bugs.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5} align="center" py="xl">
|
||||||
|
No errors found.
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : bugs.map((error: any) => (
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
key={error.id}
|
key={error.id}
|
||||||
onClick={() => handleRowClick(error)}
|
onClick={() => handleRowClick(error)}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<Table.Td px="xl">
|
<Table.Td px="xl">
|
||||||
<Text size="sm" fw={600} lineClamp={1}>{error.message}</Text>
|
<Text size="sm" fw={600} lineClamp={1}>{error.description}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge variant="dot" color="brand-blue" radius="sm">{error.village}</Badge>
|
<Badge variant="dot" color="brand-blue" radius="sm">{error.user?.name || error.userId || 'System'}</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" fw={700} c="dimmed">{error.version}</Text>
|
<Text size="xs" fw={700} c="dimmed">{error.affectedVersion || 'N/A'}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<TbHistory size={12} color="gray" />
|
<TbHistory size={12} color="gray" />
|
||||||
<Text size="xs" c="dimmed">{error.timestamp}</Text>
|
<Text size="xs" c="dimmed">{new Date(error.createdAt).toLocaleString()}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td pr="xl">
|
<Table.Td pr="xl">
|
||||||
<Badge color={getSeverityColor(error.severity)} variant="light" size="sm">
|
<Badge color={getSeverityColor(error.status)} variant="light" size="sm">
|
||||||
{error.severity.toUpperCase()}
|
{(error.status || '').toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -156,17 +141,17 @@ export function ErrorDataTable() {
|
|||||||
<Stack p="lg" gap="xl">
|
<Stack p="lg" gap="xl">
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
|
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
|
||||||
<Text fw={700} size="lg" color="red">{selectedError.message}</Text>
|
<Text fw={700} size="lg" color="red">{selectedError.description}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<SimpleGrid cols={2} spacing="lg">
|
<SimpleGrid cols={2} spacing="lg">
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="xs" fw={700} c="dimmed" mb={4}>VILLAGE</Text>
|
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTER</Text>
|
||||||
<Text fw={600}>{selectedError.village}</Text>
|
<Text fw={600}>{selectedError.user?.name || selectedError.userId || 'System'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text>
|
<Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text>
|
||||||
<Badge variant="outline">{selectedError.version}</Badge>
|
<Badge variant="outline">{selectedError.affectedVersion || 'N/A'}</Badge>
|
||||||
</Box>
|
</Box>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
|||||||
43
src/frontend/config/api.ts
Normal file
43
src/frontend/config/api.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export const API_BASE_URL = import.meta.env.VITE_URL_API_DESA_PLUS
|
||||||
|
|
||||||
|
export const API_URLS = {
|
||||||
|
getVillages: (page: number, search: string) =>
|
||||||
|
`${API_BASE_URL}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`,
|
||||||
|
infoVillages: (id: string) =>
|
||||||
|
`${API_BASE_URL}/api/monitoring/info-villages?id=${id}`,
|
||||||
|
gridVillages: (id: string) =>
|
||||||
|
`${API_BASE_URL}/api/monitoring/grid-villages?id=${id}`,
|
||||||
|
graphLogVillages: (id: string, time: string) =>
|
||||||
|
`${API_BASE_URL}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
|
||||||
|
getUsers: (page: number, search: string) =>
|
||||||
|
`${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`,
|
||||||
|
getLogsAllVillages: (page: number, search: string) =>
|
||||||
|
`${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`,
|
||||||
|
getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`,
|
||||||
|
getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`,
|
||||||
|
getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`,
|
||||||
|
postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`,
|
||||||
|
createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`,
|
||||||
|
createUser: () => `${API_BASE_URL}/api/monitoring/create-user`,
|
||||||
|
listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`,
|
||||||
|
listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`,
|
||||||
|
listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`,
|
||||||
|
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
|
||||||
|
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
|
||||||
|
editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`,
|
||||||
|
getGlobalLogs: (page: number, search: string, type: string, userId: string) =>
|
||||||
|
`/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`,
|
||||||
|
getLogOperators: () => `/api/logs/operators`,
|
||||||
|
getOperators: (page: number, search: string) =>
|
||||||
|
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
|
||||||
|
getOperatorStats: () => `/api/operators/stats`,
|
||||||
|
createOperator: () => `/api/operators`,
|
||||||
|
editOperator: (id: string) => `/api/operators/${id}`,
|
||||||
|
deleteOperator: (id: string) => `/api/operators/${id}`,
|
||||||
|
getBugs: (page: number, search: string, app: string, status: string) =>
|
||||||
|
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
|
||||||
|
createBug: () => `/api/bugs`,
|
||||||
|
updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
|
||||||
|
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,
|
||||||
|
createLog: () => `/api/logs`,
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IconType } from 'react-icons'
|
import { IconType } from 'react-icons'
|
||||||
import { TbChartBar, TbHistory, TbAlertTriangle, TbSettings, TbShoppingCart, TbPackage, TbCreditCard, TbBuilding } from 'react-icons/tb'
|
import { TbAlertTriangle, TbBuilding, TbChartBar, TbCreditCard, TbHistory, TbPackage, TbShoppingCart, TbUsers } from 'react-icons/tb'
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
value: string
|
value: string
|
||||||
@@ -23,6 +23,7 @@ export const APP_CONFIGS: Record<string, AppConfig> = {
|
|||||||
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/desa-plus/logs' },
|
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/desa-plus/logs' },
|
||||||
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' },
|
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' },
|
||||||
{ value: 'villages', label: 'Villages', icon: TbBuilding, to: '/apps/desa-plus/villages' },
|
{ value: 'villages', label: 'Villages', icon: TbBuilding, to: '/apps/desa-plus/villages' },
|
||||||
|
{ value: 'users', label: 'Users', icon: TbUsers, to: '/apps/desa-plus/users' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'e-commerce': {
|
'e-commerce': {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
export type Role = 'USER' | 'ADMIN' | 'SUPER_ADMIN'
|
export type Role = | 'ADMIN' | 'DEVELOPER'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,12 +41,7 @@ export function useLogin() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(['auth', 'session'], data)
|
queryClient.setQueryData(['auth', 'session'], data)
|
||||||
// Super admin → dashboard, others → profile
|
navigate({ to: '/dashboard' })
|
||||||
if (data.user.role === 'SUPER_ADMIN') {
|
|
||||||
navigate({ to: '/dashboard' })
|
|
||||||
} else {
|
|
||||||
navigate({ to: '/profile' })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,244 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
Paper,
|
|
||||||
Accordion,
|
Accordion,
|
||||||
ThemeIcon,
|
Avatar,
|
||||||
TextInput,
|
Badge,
|
||||||
Select,
|
|
||||||
Code,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Code,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Timeline,
|
||||||
|
Title
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
TbAlertTriangle,
|
TbAlertTriangle,
|
||||||
TbBug,
|
TbBug,
|
||||||
|
TbCircleCheck,
|
||||||
|
TbCircleX,
|
||||||
TbDeviceDesktop,
|
TbDeviceDesktop,
|
||||||
TbDeviceMobile,
|
TbDeviceMobile,
|
||||||
TbSearch,
|
|
||||||
TbFilter,
|
TbFilter,
|
||||||
TbCircleCheck,
|
TbHistory,
|
||||||
TbUserCheck
|
TbPhoto,
|
||||||
|
TbPlus,
|
||||||
|
TbSearch
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
|
import { API_URLS } from '../config/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/errors')({
|
export const Route = createFileRoute('/apps/$appId/errors')({
|
||||||
component: AppErrorsPage,
|
component: AppErrorsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockErrors = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'NullPointerException: village_id is null',
|
|
||||||
message: 'Occurred during background sync with central server.',
|
|
||||||
version: '2.4.1',
|
|
||||||
device: 'PC Admin (Windows 10)',
|
|
||||||
time: '2 mins ago',
|
|
||||||
severity: 'critical',
|
|
||||||
users: 24,
|
|
||||||
frequency: 145,
|
|
||||||
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)\nat android.app.IntentService$ServiceHandler.handleMessage(IntentService.java:78)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'SocketTimeoutException: Connection reset by peer',
|
|
||||||
message: 'Failed to upload document: surat_kematian_01.pdf',
|
|
||||||
version: '2.4.0',
|
|
||||||
device: 'Android Tablet (Samsung Tab A8)',
|
|
||||||
time: '15 mins ago',
|
|
||||||
severity: 'high',
|
|
||||||
users: 5,
|
|
||||||
frequency: 12,
|
|
||||||
stackTrace: 'java.net.SocketTimeoutException: timeout\nat okio.Okio$4.newTimeoutException(Okio.java:232)\nat okio.AsyncTimeout.exit(AsyncTimeout.java:285)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'SQLiteException: no such column: village_id',
|
|
||||||
message: 'Failed to query local village profile database.',
|
|
||||||
version: '2.4.1',
|
|
||||||
device: 'PC Admin (Windows 7)',
|
|
||||||
time: '1 hour ago',
|
|
||||||
severity: 'medium',
|
|
||||||
users: 2,
|
|
||||||
frequency: 4,
|
|
||||||
stackTrace: 'java.io.IOException: No space left on device\nat java.io.FileOutputStream.writeBytes(Native Method)'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function AppErrorsPage() {
|
function AppErrorsPage() {
|
||||||
const { appId } = useParams({ from: '/apps/$appId/errors' })
|
const { appId } = useParams({ from: '/apps/$appId/errors' })
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [app, setApp] = useState(appId)
|
||||||
|
const [status, setStatus] = useState('all')
|
||||||
|
|
||||||
|
|
||||||
|
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const toggleLogs = (bugId: string) => {
|
||||||
|
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['bugs', { page, search, app, status }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch apps for the dropdown
|
||||||
|
const { data: appsList } = useQuery({
|
||||||
|
queryKey: ['apps-list'],
|
||||||
|
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create Bug Modal Logic
|
||||||
|
const [opened, { open, close }] = useDisclosure(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
description: '',
|
||||||
|
app: appId,
|
||||||
|
status: 'OPEN',
|
||||||
|
source: 'USER',
|
||||||
|
affectedVersion: '',
|
||||||
|
device: '',
|
||||||
|
os: '',
|
||||||
|
stackTrace: '',
|
||||||
|
imageUrl: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update Status Modal Logic
|
||||||
|
const [updateModalOpened, { open: openUpdateModal, close: closeUpdateModal }] = useDisclosure(false)
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false)
|
||||||
|
const [selectedBugId, setSelectedBugId] = useState<string | null>(null)
|
||||||
|
const [updateForm, setUpdateForm] = useState({
|
||||||
|
status: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Feedback Modal Logic
|
||||||
|
const [feedbackModalOpened, { open: openFeedbackModal, close: closeFeedbackModal }] = useDisclosure(false)
|
||||||
|
const [isUpdatingFeedback, setIsUpdatingFeedback] = useState(false)
|
||||||
|
const [feedbackForm, setFeedbackForm] = useState({
|
||||||
|
feedBack: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleUpdateFeedback = async () => {
|
||||||
|
if (!selectedBugId || !feedbackForm.feedBack) return
|
||||||
|
|
||||||
|
setIsUpdatingFeedback(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.updateBugFeedback(selectedBugId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(feedbackForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Feedback has been updated.',
|
||||||
|
color: 'teal',
|
||||||
|
icon: <TbCircleCheck size={18} />,
|
||||||
|
})
|
||||||
|
refetch()
|
||||||
|
closeFeedbackModal()
|
||||||
|
setFeedbackForm({ feedBack: '' })
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to update feedback')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
color: 'red',
|
||||||
|
icon: <TbCircleX size={18} />,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingFeedback(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStatus = async () => {
|
||||||
|
if (!selectedBugId || !updateForm.status) return
|
||||||
|
|
||||||
|
setIsUpdating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.updateBugStatus(selectedBugId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updateForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Status has been updated.',
|
||||||
|
color: 'teal',
|
||||||
|
icon: <TbCircleCheck size={18} />,
|
||||||
|
})
|
||||||
|
refetch()
|
||||||
|
closeUpdateModal()
|
||||||
|
setUpdateForm({ status: '', description: '' })
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to update status')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
color: 'red',
|
||||||
|
icon: <TbCircleX size={18} />,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateBug = async () => {
|
||||||
|
if (!createForm.description || !createForm.affectedVersion || !createForm.device || !createForm.os) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Validation Error',
|
||||||
|
message: 'Please fill in all required fields.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.createBug(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(createForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await fetch(API_URLS.createLog(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'CREATE', message: `Report error baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` })
|
||||||
|
}).catch(console.error)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Error report has been created.',
|
||||||
|
color: 'teal',
|
||||||
|
icon: <TbCircleCheck size={18} />,
|
||||||
|
})
|
||||||
|
refetch()
|
||||||
|
close()
|
||||||
|
setCreateForm({
|
||||||
|
description: '',
|
||||||
|
app: 'desa_plus',
|
||||||
|
status: 'OPEN',
|
||||||
|
source: 'USER',
|
||||||
|
affectedVersion: '',
|
||||||
|
device: '',
|
||||||
|
os: '',
|
||||||
|
stackTrace: '',
|
||||||
|
imageUrl: '',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to create error report')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
color: 'red',
|
||||||
|
icon: <TbCircleX size={18} />,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bugs = data?.data || []
|
||||||
|
const totalPages = data?.totalPages || 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
@@ -80,93 +246,433 @@ function AppErrorsPage() {
|
|||||||
<Title order={3}>Error Reporting Center</Title>
|
<Title order={3}>Error Reporting Center</Title>
|
||||||
<Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text>
|
<Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button variant="light" color="red" leftSection={<TbBug size={16} />}>
|
<Button
|
||||||
Export Logs
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
leftSection={<TbPlus size={18} />}
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
Report Error
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
<Modal
|
||||||
<Group mb="md" grow>
|
opened={updateModalOpened}
|
||||||
|
onClose={closeUpdateModal}
|
||||||
|
title={<Text fw={700} size="lg">Update Bug Status</Text>}
|
||||||
|
radius="xl"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="New Status"
|
||||||
|
placeholder="Select status"
|
||||||
|
required
|
||||||
|
data={[
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
{ value: 'RESOLVED', label: 'Resolved' },
|
||||||
|
{ value: 'RELEASED', label: 'Released' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' },
|
||||||
|
]}
|
||||||
|
value={updateForm.status}
|
||||||
|
onChange={(val) => setUpdateForm({ ...updateForm, status: val || '' })}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Update Note (Optional)"
|
||||||
|
placeholder="E.g. Fixed in commit xxxxx / Assigned to team"
|
||||||
|
minRows={3}
|
||||||
|
value={updateForm.description}
|
||||||
|
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isUpdating}
|
||||||
|
onClick={handleUpdateStatus}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={feedbackModalOpened}
|
||||||
|
onClose={closeFeedbackModal}
|
||||||
|
title={<Text fw={700} size="lg">Developer Feedback</Text>}
|
||||||
|
radius="xl"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Textarea
|
||||||
|
data-autofocus
|
||||||
|
label="Feedback / Note"
|
||||||
|
placeholder="Explain the issue, root cause, or resolution..."
|
||||||
|
required
|
||||||
|
minRows={4}
|
||||||
|
value={feedbackForm.feedBack}
|
||||||
|
onChange={(e) => setFeedbackForm({ ...feedbackForm, feedBack: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isUpdatingFeedback}
|
||||||
|
onClick={handleUpdateFeedback}
|
||||||
|
>
|
||||||
|
Save Feedback
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={<Text fw={700} size="lg">Report New Error</Text>}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="What happened? Describe the error in detail..."
|
||||||
|
required
|
||||||
|
minRows={3}
|
||||||
|
value={createForm.description}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<Select
|
||||||
|
label="Application"
|
||||||
|
data={appsList?.map((a: any) => ({ value: a.id, label: a.name })) || []}
|
||||||
|
value={createForm.app}
|
||||||
|
onChange={(val) => setCreateForm({ ...createForm, app: val as any })}
|
||||||
|
placeholder="Select application"
|
||||||
|
disabled={!appsList}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Source"
|
||||||
|
data={[
|
||||||
|
{ value: 'USER', label: 'User' },
|
||||||
|
{ value: 'QC', label: 'QC' },
|
||||||
|
{ value: 'SYSTEM', label: 'System' },
|
||||||
|
]}
|
||||||
|
value={createForm.source}
|
||||||
|
onChange={(val) => setCreateForm({ ...createForm, source: val as any })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<TextInput
|
||||||
|
label="Version"
|
||||||
|
placeholder="e.g. 2.4.1"
|
||||||
|
required
|
||||||
|
value={createForm.affectedVersion}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Initial Status"
|
||||||
|
data={[
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
]}
|
||||||
|
value={createForm.status}
|
||||||
|
onChange={(val) => setCreateForm({ ...createForm, status: val as any })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<TextInput
|
||||||
|
label="Device"
|
||||||
|
placeholder="e.g. iPhone 13, Windows 11 PC"
|
||||||
|
required
|
||||||
|
value={createForm.device}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, device: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="OS"
|
||||||
|
placeholder="e.g. iOS 15.4, Windows 11"
|
||||||
|
required
|
||||||
|
value={createForm.os}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, os: e.target.value })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search error message, village, or stack trace..."
|
label="Image URL (Optional)"
|
||||||
|
placeholder="https://example.com/screenshot.png"
|
||||||
|
value={createForm.imageUrl}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Stack Trace (Optional)"
|
||||||
|
placeholder="Paste code or error logs here..."
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
minRows={2}
|
||||||
|
value={createForm.stackTrace}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, stackTrace: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={handleCreateBug}
|
||||||
|
>
|
||||||
|
Submit Error Report
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }} mb="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search description, device, os..."
|
||||||
leftSection={<TbSearch size={16} />}
|
leftSection={<TbSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Severity"
|
placeholder="Status"
|
||||||
data={['Critical', 'High', 'Medium', 'Low']}
|
data={[
|
||||||
leftSection={<TbFilter size={16} />}
|
{ value: 'all', label: 'All Status' },
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
{ value: 'RESOLVED', label: 'Resolved' },
|
||||||
|
{ value: 'RELEASED', label: 'Released' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' },
|
||||||
|
]}
|
||||||
|
value={status}
|
||||||
|
onChange={(val) => setStatus(val || 'all')}
|
||||||
radius="md"
|
radius="md"
|
||||||
clearable
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
<Group justify="flex-end">
|
||||||
|
<Button variant="subtle" color="gray" leftSection={<TbFilter size={16} />} onClick={() => { setSearch(''); setStatus('all') }}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
<Accordion variant="separated" radius="xl">
|
{isLoading ? (
|
||||||
{mockErrors.map((error) => (
|
<Stack align="center" py="xl">
|
||||||
<Accordion.Item
|
<Loader size="lg" type="dots" />
|
||||||
key={error.id}
|
<Text size="sm" c="dimmed">Loading error reports...</Text>
|
||||||
value={error.id.toString()}
|
</Stack>
|
||||||
style={{ border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-default)', marginBottom: '12px' }}
|
) : bugs.length === 0 ? (
|
||||||
>
|
<Paper p="xl" withBorder style={{ borderStyle: 'dashed', textAlign: 'center' }}>
|
||||||
<Accordion.Control>
|
<TbBug size={48} color="gray" style={{ marginBottom: 12, opacity: 0.5 }} />
|
||||||
<Group wrap="nowrap">
|
<Text fw={600}>No error reports found</Text>
|
||||||
<ThemeIcon
|
<Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
|
||||||
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
|
</Paper>
|
||||||
variant="light"
|
) : (
|
||||||
size="lg"
|
<Accordion variant="separated" radius="xl">
|
||||||
radius="md"
|
{bugs.map((bug: any) => (
|
||||||
>
|
<Accordion.Item
|
||||||
<TbAlertTriangle size={20} />
|
key={bug.id}
|
||||||
</ThemeIcon>
|
value={bug.id}
|
||||||
<Box style={{ flex: 1 }}>
|
style={{
|
||||||
<Group justify="space-between">
|
border: '1px solid var(--mantine-color-default-border)',
|
||||||
<Text size="sm" fw={600} lineClamp={1}>{error.title}</Text>
|
background: 'var(--mantine-color-default)',
|
||||||
<Badge color={error.severity === 'critical' ? 'red' : 'orange'} variant="dot" size="xs">
|
marginBottom: '12px',
|
||||||
{error.severity.toUpperCase()}
|
}}
|
||||||
</Badge>
|
>
|
||||||
</Group>
|
<Accordion.Control>
|
||||||
<Group gap="md">
|
<Group wrap="nowrap">
|
||||||
<Text size="xs" c="dimmed">{error.time} • v{error.version}</Text>
|
<ThemeIcon
|
||||||
<Group gap={4} visibleFrom="sm">
|
color={
|
||||||
<TbUserCheck size={12} color="gray" />
|
bug.status === 'OPEN'
|
||||||
<Text size="xs" c="dimmed">{error.users} Users Affected</Text>
|
? 'red'
|
||||||
|
: bug.status === 'IN_PROGRESS'
|
||||||
|
? 'blue'
|
||||||
|
: 'teal'
|
||||||
|
}
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<TbAlertTriangle size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{bug.description}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
color={
|
||||||
|
bug.status === 'OPEN'
|
||||||
|
? 'red'
|
||||||
|
: bug.status === 'IN_PROGRESS'
|
||||||
|
? 'blue'
|
||||||
|
: 'teal'
|
||||||
|
}
|
||||||
|
variant="dot"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{bug.status}
|
||||||
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
<Group gap="md">
|
||||||
</Box>
|
<Text size="xs" c="dimmed">
|
||||||
</Group>
|
{new Date(bug.createdAt).toLocaleString()} • {bug.app?.toUpperCase()} • v{bug.affectedVersion}
|
||||||
</Accordion.Control>
|
</Text>
|
||||||
<Accordion.Panel>
|
|
||||||
<Stack gap="md" py="xs">
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
|
||||||
<Box>
|
|
||||||
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
|
|
||||||
<Text size="sm" fw={500}>{error.message}</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
{error.device.includes('PC') ? <TbDeviceDesktop size={14} color="gray" /> : <TbDeviceMobile size={14} color="gray" />}
|
|
||||||
<Text size="xs" fw={500}>{error.device}</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
|
|
||||||
<Code block color="red" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px', border: '1px solid var(--mantine-color-default-border)' }}>
|
|
||||||
{error.stackTrace}
|
|
||||||
</Code>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group justify="flex-end" pt="sm">
|
|
||||||
<Button variant="light" size="compact-xs" color="blue">Assign Developer</Button>
|
|
||||||
<Button variant="light" size="compact-xs" color="teal" leftSection={<TbCircleCheck size={14} />}>Mark as Fixed</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Accordion.Control>
|
||||||
</Accordion.Panel>
|
<Accordion.Panel>
|
||||||
</Accordion.Item>
|
<Stack gap="lg" py="xs">
|
||||||
))}
|
{/* Device Info */}
|
||||||
</Accordion>
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{bug.device.toLowerCase().includes('windows') || bug.device.toLowerCase().includes('mac') || bug.device.toLowerCase().includes('pc') ? (
|
||||||
|
<TbDeviceDesktop size={14} color="gray" />
|
||||||
|
) : (
|
||||||
|
<TbDeviceMobile size={14} color="gray" />
|
||||||
|
)}
|
||||||
|
<Text size="xs" fw={500}>{bug.device} ({bug.os})</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>SOURCE</Text>
|
||||||
|
<Badge variant="light" color="gray" size="sm">{bug.source}</Badge>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Feedback & Reporter Info */}
|
||||||
|
{(bug.user || bug.feedBack) && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
|
{bug.user && (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTED BY</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Avatar src={bug.user.image} radius="xl" size="sm" color="blue">
|
||||||
|
{bug.user.name?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Text size="sm">{bug.user.name}</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{bug.feedBack && (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVELOPER FEEDBACK</Text>
|
||||||
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{bug.feedBack}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stack Trace */}
|
||||||
|
{bug.stackTrace && (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
color="red"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: '11px',
|
||||||
|
border: '1px solid var(--mantine-color-default-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bug.stackTrace}
|
||||||
|
</Code>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{bug.images && bug.images.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs" mb={8}>
|
||||||
|
<TbPhoto size={16} color="gray" />
|
||||||
|
<Text size="xs" fw={700} c="dimmed">ATTACHED IMAGES ({bug.images.length})</Text>
|
||||||
|
</Group>
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
|
||||||
|
{bug.images.map((img: any) => (
|
||||||
|
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
|
||||||
|
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs / History */}
|
||||||
|
{bug.logs && bug.logs.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb={showLogs[bug.id] ? 12 : 0}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<TbHistory size={16} color="gray" />
|
||||||
|
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG ({bug.logs.length})</Text>
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-xs"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => toggleLogs(bug.id)}
|
||||||
|
>
|
||||||
|
{showLogs[bug.id] ? 'Hide' : 'Show'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Collapse in={showLogs[bug.id]}>
|
||||||
|
<Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2} mt="md">
|
||||||
|
{bug.logs.map((log: any) => (
|
||||||
|
<Timeline.Item
|
||||||
|
key={log.id}
|
||||||
|
bullet={
|
||||||
|
<Badge size="xs" circle color={log.status === 'RESOLVED' ? 'teal' : 'blue'}> </Badge>
|
||||||
|
}
|
||||||
|
title={<Text size="sm" fw={600}>{log.status}</Text>}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed" mb={4}>
|
||||||
|
{new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{log.description}</Text>
|
||||||
|
</Timeline.Item>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" pt="sm">
|
||||||
|
<Button variant="light" size="compact-xs" color="blue" onClick={() => {
|
||||||
|
setSelectedBugId(bug.id)
|
||||||
|
setFeedbackForm({ feedBack: bug.feedBack || '' })
|
||||||
|
openFeedbackModal()
|
||||||
|
}}>Developer Feedback</Button>
|
||||||
|
<Button variant="light" size="compact-xs" color="teal" onClick={() => {
|
||||||
|
setSelectedBugId(bug.id)
|
||||||
|
setUpdateForm({ status: bug.status, description: '' })
|
||||||
|
openUpdateModal()
|
||||||
|
}}>Update Status</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Group justify="center" mt="xl">
|
||||||
|
<Pagination total={totalPages} value={page} onChange={setPage} radius="xl" />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,148 +1,239 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
|
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
|
||||||
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
|
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
|
||||||
import { SummaryCard } from '@/frontend/components/SummaryCard'
|
import { SummaryCard } from '@/frontend/components/SummaryCard'
|
||||||
|
import { useSession } from '@/frontend/hooks/useAuth'
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
Badge,
|
||||||
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Modal,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
Modal,
|
|
||||||
Button,
|
|
||||||
TextInput,
|
|
||||||
Switch,
|
Switch,
|
||||||
Badge,
|
Text,
|
||||||
Textarea
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
Title
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDisclosure } from '@mantine/hooks'
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
|
import { notifications } from '@mantine/notifications'
|
||||||
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
TbActivity,
|
TbActivity,
|
||||||
TbAlertTriangle,
|
TbAlertTriangle,
|
||||||
TbBuildingCommunity,
|
TbBuildingCommunity,
|
||||||
TbRefresh,
|
|
||||||
TbVersions
|
TbVersions
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { API_URLS } from '../config/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/')({
|
export const Route = createFileRoute('/apps/$appId/')({
|
||||||
component: AppOverviewPage,
|
component: AppOverviewPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
|
|
||||||
function AppOverviewPage() {
|
function AppOverviewPage() {
|
||||||
const { appId } = useParams({ from: '/apps/$appId/' })
|
const { appId } = useParams({ from: '/apps/$appId/' })
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isDesaPlus = appId === 'desa-plus'
|
const isDesaPlus = appId === 'desa-plus'
|
||||||
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
|
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const isDeveloper = session?.user?.role === 'DEVELOPER'
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [latestVersion, setLatestVersion] = useState('')
|
||||||
|
const [minVersion, setMinVersion] = useState('')
|
||||||
|
const [messageUpdate, setMessageUpdate] = useState('')
|
||||||
|
const [maintenance, setMaintenance] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
// Data Fetching
|
||||||
|
const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
|
||||||
|
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
|
||||||
|
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : null, fetcher)
|
||||||
|
|
||||||
|
const { data: appData, isLoading: appLoading } = useQuery({
|
||||||
|
queryKey: ['apps', appId],
|
||||||
|
queryFn: () => fetch(`/api/apps/${appId}`).then((r) => r.json()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const grid = gridRes?.data
|
||||||
|
const dailyData = dailyRes?.data || []
|
||||||
|
const comparisonData = comparisonRes?.data || []
|
||||||
|
|
||||||
|
// Initialize form when data loads or modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (grid?.version && versionModalOpened) {
|
||||||
|
setLatestVersion(grid.version.mobile_latest_version || '')
|
||||||
|
setMinVersion(grid.version.mobile_minimum_version || '')
|
||||||
|
setMessageUpdate(grid.version.mobile_message_update || '')
|
||||||
|
setMaintenance(grid.version.mobile_maintenance === 'true')
|
||||||
|
}
|
||||||
|
}, [grid, versionModalOpened])
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
mutateGrid()
|
||||||
|
mutateDaily()
|
||||||
|
mutateComparison()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveVersion = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_URLS.postVersionUpdate(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mobile_latest_version: latestVersion,
|
||||||
|
mobile_minimum_version: minVersion,
|
||||||
|
mobile_maintenance: maintenance,
|
||||||
|
mobile_message_update: messageUpdate,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetch(API_URLS.createLog(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'UPDATE', message: `Update version information: ${JSON.stringify({ latestVersion, minVersion, maintenance, messageUpdate })}` })
|
||||||
|
}).catch(console.error)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Update Successful',
|
||||||
|
message: 'Application version information has been updated.',
|
||||||
|
color: 'teal',
|
||||||
|
})
|
||||||
|
mutateGrid()
|
||||||
|
closeVersionModal()
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Update Failed',
|
||||||
|
message: 'Failed to update version information. Please check your data.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Network Error',
|
||||||
|
message: 'Could not connect to the server. Please try again later.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
|
<Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput label="Active Version" defaultValue="v1.2.0" />
|
<TextInput
|
||||||
<TextInput label="Minimum Version" defaultValue="v1.0.0" />
|
label="Active Version"
|
||||||
|
placeholder="e.g. 2.0.5"
|
||||||
|
value={latestVersion}
|
||||||
|
onChange={(e) => setLatestVersion(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Minimum Version"
|
||||||
|
placeholder="e.g. 2.0.0"
|
||||||
|
value={minVersion}
|
||||||
|
onChange={(e) => setMinVersion(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Update Message"
|
label="Update Message"
|
||||||
placeholder="Enter release notes or update message..."
|
placeholder="Enter release notes or update message..."
|
||||||
|
value={messageUpdate}
|
||||||
|
onChange={(e) => setMessageUpdate(e.currentTarget.value)}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
autosize
|
autosize
|
||||||
/>
|
/>
|
||||||
<Switch label="Maintenance Mode" description="Enable to put the app in maintenance mode for users." />
|
<Switch
|
||||||
<Button fullWidth onClick={closeVersionModal}>Save Changes</Button>
|
label="Maintenance Mode"
|
||||||
|
description="Enable to put the app in maintenance mode for users."
|
||||||
|
checked={maintenance}
|
||||||
|
onChange={(e) => setMaintenance(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<Button fullWidth onClick={handleSaveVersion} loading={isSaving}>Save Changes</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
{/* 🔝 HEADER SECTION */}
|
<Group justify="space-between">
|
||||||
{/* <Paper withBorder p="lg" radius="2xl" className="glass"> */}
|
<Stack gap={0}>
|
||||||
<Group justify="space-between">
|
<Title order={3}>Overview</Title>
|
||||||
<Stack gap={0}>
|
<Text size="sm" c="dimmed">Detailed metrics for {isDesaPlus ? 'Desa+' : appId}</Text>
|
||||||
<Title order={3}>Overview</Title>
|
</Stack>
|
||||||
<Text size="sm" c="dimmed">Last updated: Just now</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Group gap="md">
|
{/* <Group gap="md">
|
||||||
{/* <Select
|
<ActionIcon variant="light" color="brand-blue" size="lg" radius="md" onClick={handleRefresh}>
|
||||||
placeholder="Date Range"
|
<TbRefresh size={20} />
|
||||||
data={['Today', '7 Days', '30 Days']}
|
</ActionIcon>
|
||||||
defaultValue="Today"
|
</Group> */}
|
||||||
leftSection={<TbCalendar size={16} />}
|
|
||||||
radius="md"
|
|
||||||
w={140}
|
|
||||||
/> */}
|
|
||||||
<ActionIcon variant="light" color="brand-blue" size="lg" radius="md">
|
|
||||||
<TbRefresh size={20} />
|
|
||||||
</ActionIcon>
|
|
||||||
{/* <Button
|
|
||||||
variant="gradient"
|
|
||||||
gradient={{ from: '#2563EB', to: '#7C3AED' }}
|
|
||||||
radius="md"
|
|
||||||
leftSection={<TbFilter size={18} />}
|
|
||||||
>
|
|
||||||
Add Filter
|
|
||||||
</Button> */}
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
|
||||||
{/* </Paper> */}
|
|
||||||
|
|
||||||
{/* 📊 1. SUMMARY CARDS */}
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
<SummaryCard
|
||||||
<SummaryCard
|
title="Active Version"
|
||||||
title="Active Version"
|
value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
|
||||||
value="v1.2.0"
|
icon={TbVersions}
|
||||||
icon={TbVersions}
|
color="brand-blue"
|
||||||
color="brand-blue"
|
onClick={isDeveloper ? openVersionModal : undefined}
|
||||||
onClick={openVersionModal}
|
>
|
||||||
>
|
<Group justify="space-between" mt="md">
|
||||||
<Group justify="space-between" mt="md">
|
<Stack gap={0}>
|
||||||
<Stack gap={0}>
|
<Text size="xs" c="dimmed">Min. Version</Text>
|
||||||
<Text size="xs" c="dimmed">Min. Version</Text>
|
<Text size="sm" fw={600}>{grid?.version?.mobile_minimum_version || '-'}</Text>
|
||||||
<Text size="sm" fw={600}>v1.0.0</Text>
|
</Stack>
|
||||||
</Stack>
|
<Stack gap={0} align="flex-end">
|
||||||
<Stack gap={0} align="flex-end">
|
<Text size="xs" c="dimmed">Maintenance</Text>
|
||||||
<Text size="xs" c="dimmed">Maintenance</Text>
|
<Badge size="sm" color={grid?.version?.mobile_maintenance === 'true' ? 'red' : 'gray'} variant="light">
|
||||||
<Badge size="sm" color="gray" variant="light">False</Badge>
|
{grid?.version?.mobile_maintenance?.toUpperCase() || 'FALSE'}
|
||||||
</Stack>
|
</Badge>
|
||||||
</Group>
|
</Stack>
|
||||||
</SummaryCard>
|
</Group>
|
||||||
<SummaryCard
|
</SummaryCard>
|
||||||
title="Total Activity Today"
|
|
||||||
value="3,842"
|
|
||||||
icon={TbActivity}
|
|
||||||
color="teal"
|
|
||||||
trend={{ value: '14.2%', positive: true }}
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
title="Total Villages Active"
|
|
||||||
value="138"
|
|
||||||
icon={TbBuildingCommunity}
|
|
||||||
color="indigo"
|
|
||||||
onClick={() => navigate({ to: `/apps/${appId}/villages` })}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" mt="md">
|
|
||||||
<Text size="xs" c="dimmed">Nonactive Villages</Text>
|
|
||||||
<Badge size="sm" color="red" variant="light">24</Badge>
|
|
||||||
</Group>
|
|
||||||
</SummaryCard>
|
|
||||||
<SummaryCard
|
|
||||||
title="Errors Today"
|
|
||||||
value="12"
|
|
||||||
icon={TbAlertTriangle}
|
|
||||||
color="red"
|
|
||||||
isError={true}
|
|
||||||
trend={{ value: '4.8%', positive: false }}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{/* 📈 📊 2 & 3. CHARTS GRID */}
|
<SummaryCard
|
||||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
title="Total Activity Today"
|
||||||
<VillageActivityLineChart />
|
value={gridLoading ? '...' : (grid?.activity?.today?.toLocaleString() || '0')}
|
||||||
<VillageComparisonBarChart />
|
icon={TbActivity}
|
||||||
</SimpleGrid>
|
color="teal"
|
||||||
|
trend={grid?.activity?.increase ? { value: `${grid.activity.increase}%`, positive: grid.activity.increase > 0 } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 🐞 4. LATEST ERROR REPORTS */}
|
<SummaryCard
|
||||||
<ErrorDataTable />
|
title="Total Villages Active"
|
||||||
</Stack>
|
value={gridLoading ? '...' : (grid?.village?.active || '0')}
|
||||||
|
icon={TbBuildingCommunity}
|
||||||
|
color="indigo"
|
||||||
|
onClick={() => navigate({ to: `/apps/${appId}/villages` })}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mt="md">
|
||||||
|
<Text size="xs" c="dimmed">Nonactive Villages</Text>
|
||||||
|
<Badge size="sm" color="red" variant="light">{grid?.village?.inactive || 0}</Badge>
|
||||||
|
</Group>
|
||||||
|
</SummaryCard>
|
||||||
|
|
||||||
|
<SummaryCard
|
||||||
|
title="Errors Open"
|
||||||
|
value={appLoading ? '...' : (appData?.errors || '0')}
|
||||||
|
icon={TbAlertTriangle}
|
||||||
|
color="red"
|
||||||
|
isError={true}
|
||||||
|
onClick={() => navigate({ to: `/apps/${appId}/errors` })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||||
|
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} />
|
||||||
|
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<ErrorDataTable appId={appId} />
|
||||||
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import useSWR from 'swr'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Container,
|
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -8,116 +9,247 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
TextInput,
|
TextInput,
|
||||||
Select,
|
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Tooltip,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Code,
|
Code,
|
||||||
Button
|
Button,
|
||||||
|
Box,
|
||||||
|
Pagination,
|
||||||
|
ThemeIcon,
|
||||||
|
ScrollArea,
|
||||||
|
Container,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
|
import { useMediaQuery } from '@mantine/hooks'
|
||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
import { TbSearch, TbFilter, TbDownload, TbCalendar } from 'react-icons/tb'
|
import {
|
||||||
|
TbSearch,
|
||||||
|
TbDownload,
|
||||||
|
TbX,
|
||||||
|
TbHistory,
|
||||||
|
TbCalendar,
|
||||||
|
TbUser,
|
||||||
|
TbHome2
|
||||||
|
} from 'react-icons/tb'
|
||||||
|
import { API_URLS } from '../config/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/logs')({
|
export const Route = createFileRoute('/apps/$appId/logs')({
|
||||||
component: AppLogsPage,
|
component: AppLogsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockLogs = [
|
interface LogEntry {
|
||||||
{ id: 1, type: 'DOCUMENT', village: 'Sukatani', activity: 'GENERATE_SURAT_DOMISILI', operator: 'Budi Santoso', time: '2 mins ago', status: 'SUCCESS' },
|
id: string
|
||||||
{ id: 2, type: 'FINANCE', village: 'Sukamaju', activity: 'UPLOAD_LAPORAN_REALISASI_Q1', operator: 'Siti Aminah', time: '15 mins ago', status: 'SUCCESS' },
|
createdAt: string
|
||||||
{ id: 3, type: 'SYNC', village: 'Cikini', activity: 'SYNC_DATA_PENDUDUK_SIAK', operator: 'System', time: '1 hour ago', status: 'WARNING' },
|
action: string
|
||||||
{ id: 4, type: 'SECURITY', village: 'Bojong Gede', activity: 'LOGIN_ADMIN_DESA', operator: 'Rahmat Hidayat', time: '2 hours ago', status: 'SUCCESS' },
|
desc: string
|
||||||
{ id: 5, type: 'DOCUMENT', village: 'Tapos', activity: 'VERIFIKASI_SURAT_KEMATIAN', operator: 'Agus Setiawan', time: '4 hours ago', status: 'SUCCESS' },
|
username: string
|
||||||
]
|
village: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
|
|
||||||
function AppLogsPage() {
|
function AppLogsPage() {
|
||||||
const { appId } = useParams({ from: '/apps/$appId/logs' })
|
const { appId } = useParams({ from: '/apps/$appId/logs' })
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
const isDesaPlus = appId === 'desa-plus'
|
const isDesaPlus = appId === 'desa-plus'
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||||
|
|
||||||
|
const apiUrl = isDesaPlus ? API_URLS.getLogsAllVillages(page, searchQuery) : null
|
||||||
|
const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
|
||||||
|
const logs: LogEntry[] = response?.data?.log || []
|
||||||
|
|
||||||
|
const handleSearchChange = (val: string) => {
|
||||||
|
setSearch(val)
|
||||||
|
if (val.length >= 3 || val.length === 0) {
|
||||||
|
setSearchQuery(val)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearch('')
|
||||||
|
setSearchQuery('')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionColor = (action: string) => {
|
||||||
|
const a = action.toUpperCase()
|
||||||
|
if (a === 'LOGIN') return 'blue'
|
||||||
|
if (a === 'LOGOUT') return 'gray'
|
||||||
|
if (a === 'CREATE') return 'teal'
|
||||||
|
if (a === 'UPDATE') return 'orange'
|
||||||
|
if (a === 'DELETE') return 'red'
|
||||||
|
return 'brand-blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDesaPlus) {
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
||||||
|
<TbHistory size={48} color="gray" opacity={0.5} />
|
||||||
|
<Title order={3} mt="md">Activity Logs</Title>
|
||||||
|
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl" py="md">
|
||||||
<Group justify="space-between" align="center">
|
<Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #7C3AED' }}>
|
||||||
<Stack gap={0}>
|
<Stack gap="lg">
|
||||||
<Title order={3}>{isDesaPlus ? 'Desa+ Service Logs' : 'Application Activity Logs'}</Title>
|
<Group justify="space-between" align="center">
|
||||||
<Text size="sm" c="dimmed">Detailed audit trail of all actions performed within the application instances.</Text>
|
<Stack gap={4}>
|
||||||
</Stack>
|
<Group gap="xs">
|
||||||
<Group gap="xs">
|
<ThemeIcon variant="light" color="violet" size="lg" radius="md">
|
||||||
<Button variant="light" leftSection={<TbDownload size={16} />} radius="md">Export XLS</Button>
|
<TbHistory size={22} />
|
||||||
</Group>
|
</ThemeIcon>
|
||||||
</Group>
|
<Title order={3}>Activity Logs</Title>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed" ml={40}>
|
||||||
|
{isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{/* <Button
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<TbDownload size={18} />}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button> */}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
|
||||||
<Group mb="md" grow>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search activity, village, or operator..."
|
placeholder="Search action or village..."
|
||||||
leftSection={<TbSearch size={16} />}
|
leftSection={<TbSearch size={18} />}
|
||||||
|
size="md"
|
||||||
|
rightSection={
|
||||||
|
search ? (
|
||||||
|
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="md">
|
||||||
|
<TbX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.currentTarget.value)}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
style={{ maxWidth: 500 }}
|
||||||
|
ml={40}
|
||||||
/>
|
/>
|
||||||
<Select
|
</Stack>
|
||||||
placeholder="All Service Types"
|
</Paper>
|
||||||
data={['DOCUMENT', 'FINANCE', 'SYNC', 'SECURITY']}
|
|
||||||
leftSection={<TbFilter size={16} />}
|
{isLoading ? (
|
||||||
|
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<Text c="dimmed">Fetching activity logs...</Text>
|
||||||
|
</Paper>
|
||||||
|
) : error ? (
|
||||||
|
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<Text c="red">Failed to load logs from API.</Text>
|
||||||
|
</Paper>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<TbHistory size={40} color="gray" opacity={0.4} />
|
||||||
|
<Text c="dimmed" mt="md">No activity found for this search.</Text>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
|
||||||
|
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
|
||||||
|
<Table
|
||||||
|
verticalSpacing="lg"
|
||||||
|
horizontalSpacing="xl"
|
||||||
|
highlightOnHover
|
||||||
|
withColumnBorders={false}
|
||||||
|
style={{
|
||||||
|
tableLayout: isMobile ? 'auto' : 'fixed',
|
||||||
|
width: '100%',
|
||||||
|
minWidth: isMobile ? 900 : 'unset'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Thead bg="rgba(0,0,0,0.05)">
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '15%' }}>Timestamp</Table.Th>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>User & Village</Table.Th>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '15%' }}>Action</Table.Th>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '40%' }}>Description</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<Table.Tr key={log.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||||
|
<ThemeIcon variant="transparent" color="gray" size="sm">
|
||||||
|
<TbCalendar size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" fw={700}>
|
||||||
|
{log.createdAt.split(' ').slice(1).join(' ')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{log.createdAt.split(' ')[0]}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={4} style={{ overflow: 'hidden' }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Avatar size="xs" radius="xl" color="brand-blue" variant="light">
|
||||||
|
{log.username.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<Text size="xs" fw={700} truncate="end">{log.username}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<TbHome2 size={12} color="gray" />
|
||||||
|
<Text size="xs" c="dimmed" truncate="end">{log.village}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
variant="dot"
|
||||||
|
color={getActionColor(log.action)}
|
||||||
|
radius="sm"
|
||||||
|
size="xs"
|
||||||
|
styles={{
|
||||||
|
root: { fontWeight: 800 },
|
||||||
|
label: { textOverflow: 'clip', overflow: 'visible' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{log.action}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Code color="brand-blue" bg="rgba(37, 99, 235, 0.05)" fw={600} style={{ fontSize: '11px', display: 'block', whiteSpace: 'normal' }}>
|
||||||
|
{log.desc}
|
||||||
|
</Code>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && response?.data?.totalPage > 0 && (
|
||||||
|
<Group justify="center" mt="xl">
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
total={response.data.totalPage}
|
||||||
radius="md"
|
radius="md"
|
||||||
clearable
|
withEdges={false}
|
||||||
|
siblings={1}
|
||||||
|
boundaries={1}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
<Table verticalSpacing="sm" highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Type</Table.Th>
|
|
||||||
<Table.Th>Village / Instance</Table.Th>
|
|
||||||
<Table.Th>Activity Name</Table.Th>
|
|
||||||
<Table.Th>Operator</Table.Th>
|
|
||||||
<Table.Th>Timestamp</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{mockLogs.map((log) => (
|
|
||||||
<Table.Tr key={log.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color={
|
|
||||||
log.type === 'DOCUMENT' ? 'blue' :
|
|
||||||
log.type === 'FINANCE' ? 'teal' :
|
|
||||||
log.type === 'SYNC' ? 'orange' : 'gray'
|
|
||||||
}
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{log.type}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" fw={600}>{log.village}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Code color="brand-blue" bg="transparent" fw={800} style={{ fontSize: '11px' }}>{log.activity}</Code>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Avatar size="xs" radius="xl" color="brand-blue">{log.operator[0]}</Avatar>
|
|
||||||
<Text size="xs" fw={500}>{log.operator}</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{log.time}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
variant="dot"
|
|
||||||
color={log.status === 'SUCCESS' ? 'teal' : 'orange'}
|
|
||||||
>
|
|
||||||
{log.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
782
src/frontend/routes/apps.$appId.users.index.tsx
Normal file
782
src/frontend/routes/apps.$appId.users.index.tsx
Normal file
@@ -0,0 +1,782 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Switch,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
TbBriefcase,
|
||||||
|
TbCircleCheck,
|
||||||
|
TbCircleX,
|
||||||
|
TbEdit,
|
||||||
|
TbHome2,
|
||||||
|
TbId,
|
||||||
|
TbMail,
|
||||||
|
TbPhone,
|
||||||
|
TbPlus,
|
||||||
|
TbSearch,
|
||||||
|
TbUsers,
|
||||||
|
TbX,
|
||||||
|
} from 'react-icons/tb'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { API_URLS } from '../config/api'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/apps/$appId/users/')({
|
||||||
|
component: UsersIndexPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface APIUser {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nik: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
gender: string
|
||||||
|
isWithoutOTP: boolean
|
||||||
|
isActive: boolean
|
||||||
|
role: string
|
||||||
|
village: string
|
||||||
|
group: string
|
||||||
|
position?: string
|
||||||
|
idUserRole: string
|
||||||
|
idVillage: string
|
||||||
|
idGroup: string
|
||||||
|
idPosition: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
|
|
||||||
|
function UsersIndexPage() {
|
||||||
|
const { appId } = useParams({ from: '/apps/$appId/users/' })
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
const isDesaPlus = appId === 'desa-plus'
|
||||||
|
const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
|
||||||
|
|
||||||
|
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
|
||||||
|
const users: APIUser[] = response?.data?.user || []
|
||||||
|
|
||||||
|
const handleSearchChange = (val: string) => {
|
||||||
|
setSearch(val)
|
||||||
|
if (val.length >= 3 || val.length === 0) {
|
||||||
|
setSearchQuery(val)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ADD USER LOGIC ---
|
||||||
|
const [opened, { open, close }] = useDisclosure(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [villageSearch, setVillageSearch] = useState('')
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
nik: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
gender: '',
|
||||||
|
idUserRole: '',
|
||||||
|
idVillage: '',
|
||||||
|
idGroup: '',
|
||||||
|
idPosition: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
nik: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
gender: '',
|
||||||
|
idUserRole: '',
|
||||||
|
idVillage: '',
|
||||||
|
idGroup: '',
|
||||||
|
idPosition: '',
|
||||||
|
isActive: true,
|
||||||
|
isWithoutOTP: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Options Data (Shared for both Add and Edit modals)
|
||||||
|
const isAnyModalOpened = opened || editOpened
|
||||||
|
const targetVillageId = opened ? form.idVillage : editForm.idVillage
|
||||||
|
const targetGroupId = opened ? form.idGroup : editForm.idGroup
|
||||||
|
|
||||||
|
const { data: rolesResp } = useSWR(isAnyModalOpened ? API_URLS.listRole() : null, fetcher)
|
||||||
|
const { data: villagesResp } = useSWR(
|
||||||
|
isAnyModalOpened && villageSearch.length >= 1 ? API_URLS.getVillages(1, villageSearch) : null,
|
||||||
|
fetcher
|
||||||
|
)
|
||||||
|
const { data: groupsResp } = useSWR(
|
||||||
|
isAnyModalOpened && targetVillageId ? API_URLS.listGroup(targetVillageId) : null,
|
||||||
|
fetcher
|
||||||
|
)
|
||||||
|
const { data: positionsResp } = useSWR(
|
||||||
|
isAnyModalOpened && targetGroupId ? API_URLS.listPosition(targetGroupId) : null,
|
||||||
|
fetcher
|
||||||
|
)
|
||||||
|
|
||||||
|
const rolesOptions = (rolesResp?.data || []).map((r: any) => ({ value: r.id, label: r.name }))
|
||||||
|
const villagesOptions = (villagesResp?.data || []).map((v: any) => ({ value: v.id, label: v.name }))
|
||||||
|
const groupsOptions = (groupsResp?.data || []).map((g: any) => ({ value: g.id, label: g.name }))
|
||||||
|
const positionsOptions = (positionsResp?.data || []).map((p: any) => ({ value: p.id, label: p.name }))
|
||||||
|
|
||||||
|
const handleCreateUser = async () => {
|
||||||
|
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
|
||||||
|
const missing = requiredFields.filter(f => !form[f as keyof typeof form])
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Validation Error',
|
||||||
|
message: `Please fill in all required fields: ${missing.join(', ')}`,
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.createUser(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(form)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await fetch(API_URLS.createLog(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'CREATE', message: `Didaftarkan user (${appId}) baru: ${form.name}-${form.nik}` })
|
||||||
|
}).catch(console.error)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'User has been created successfully.',
|
||||||
|
color: 'teal',
|
||||||
|
icon: <TbCircleCheck size={18} />
|
||||||
|
})
|
||||||
|
mutate() // Refresh user list
|
||||||
|
close()
|
||||||
|
setForm({
|
||||||
|
name: '',
|
||||||
|
nik: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
gender: '',
|
||||||
|
idUserRole: '',
|
||||||
|
idVillage: '',
|
||||||
|
idGroup: '',
|
||||||
|
idPosition: ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: result.message || 'Failed to create user.',
|
||||||
|
color: 'red',
|
||||||
|
icon: <TbCircleX size={18} />
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Network Error',
|
||||||
|
message: 'Unable to connect to the server.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditOpen = (user: APIUser) => {
|
||||||
|
setEditForm({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
nik: user.nik,
|
||||||
|
phone: user.phone,
|
||||||
|
email: user.email,
|
||||||
|
gender: user.gender,
|
||||||
|
idUserRole: user.idUserRole,
|
||||||
|
idVillage: user.idVillage,
|
||||||
|
idGroup: user.idGroup,
|
||||||
|
idPosition: user.idPosition,
|
||||||
|
isActive: user.isActive,
|
||||||
|
isWithoutOTP: user.isWithoutOTP
|
||||||
|
})
|
||||||
|
setVillageSearch(user.village)
|
||||||
|
openEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateUser = async () => {
|
||||||
|
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
|
||||||
|
const missing = requiredFields.filter(f => !editForm[f as keyof typeof editForm])
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Validation Error',
|
||||||
|
message: `Please fill in all required fields: ${missing.join(', ')}`,
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.editUser(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(editForm)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await fetch(API_URLS.createLog(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'UPDATE', message: `Data user (${appId}) diperbarui: ${editForm.name}-${editForm.id}` })
|
||||||
|
}).catch(console.error)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'User has been updated successfully.',
|
||||||
|
color: 'teal',
|
||||||
|
icon: <TbCircleCheck size={18} />
|
||||||
|
})
|
||||||
|
mutate()
|
||||||
|
closeEdit()
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: result.message || 'Failed to update user.',
|
||||||
|
color: 'red',
|
||||||
|
icon: <TbCircleX size={18} />
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Network Error',
|
||||||
|
message: 'Unable to connect to the server.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearch('')
|
||||||
|
setSearchQuery('')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleColor = (role: string) => {
|
||||||
|
const r = role.toLowerCase()
|
||||||
|
if (r.includes('super')) return 'red'
|
||||||
|
if (r.includes('admin')) return 'brand-blue'
|
||||||
|
if (r.includes('developer')) return 'violet'
|
||||||
|
return 'gray'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||||
|
|
||||||
|
if (!isDesaPlus) {
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
||||||
|
<TbUsers size={48} color="gray" opacity={0.5} />
|
||||||
|
<Title order={3} mt="md">User Management</Title>
|
||||||
|
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl" py="md">
|
||||||
|
<Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #2563EB' }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon variant="light" color="brand-blue" size="lg" radius="md">
|
||||||
|
<TbUsers size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={3}>User Management</Title>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed" ml={40}>
|
||||||
|
{isLoading ? 'Loading users...' : `${response?.data?.total || 0} users registered in the Desa+ system`}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
leftSection={<TbPlus size={18} />}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={<Text fw={700} size="lg">Add New User</Text>}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Personal Information
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Full Name"
|
||||||
|
placeholder="Enter full name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="NIK"
|
||||||
|
placeholder="16-digit identity number"
|
||||||
|
required
|
||||||
|
value={form.nik}
|
||||||
|
onChange={(e) => setForm(f => ({ ...f, nik: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||||
|
<TextInput
|
||||||
|
label="Email Address"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Phone Number"
|
||||||
|
placeholder="628xxxxxxxxxx"
|
||||||
|
required
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Gender"
|
||||||
|
placeholder="Select gender"
|
||||||
|
data={[
|
||||||
|
{ value: 'M', label: 'Male' },
|
||||||
|
{ value: 'F', label: 'Female' },
|
||||||
|
]}
|
||||||
|
mt="sm"
|
||||||
|
required
|
||||||
|
value={form.gender}
|
||||||
|
onChange={(v) => setForm(f => ({ ...f, gender: v || '' }))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider label="Role & Organization" labelPosition="center" my="sm" />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Select
|
||||||
|
label="User Role"
|
||||||
|
placeholder="Select user role"
|
||||||
|
data={rolesOptions}
|
||||||
|
required
|
||||||
|
value={form.idUserRole}
|
||||||
|
onChange={(v) => setForm(f => ({ ...f, idUserRole: v || '' }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Village"
|
||||||
|
placeholder="Type to search village..."
|
||||||
|
searchable
|
||||||
|
onSearchChange={setVillageSearch}
|
||||||
|
data={villagesOptions}
|
||||||
|
mt="sm"
|
||||||
|
required
|
||||||
|
value={form.idVillage}
|
||||||
|
onChange={(v) => {
|
||||||
|
setForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||||
|
<Select
|
||||||
|
label="Group"
|
||||||
|
placeholder={form.idVillage ? "Select group" : "Select village first"}
|
||||||
|
data={groupsOptions}
|
||||||
|
disabled={!form.idVillage}
|
||||||
|
required
|
||||||
|
value={form.idGroup}
|
||||||
|
onChange={(v) => {
|
||||||
|
setForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Position"
|
||||||
|
placeholder={form.idGroup ? "Select position" : "Select group first"}
|
||||||
|
data={positionsOptions}
|
||||||
|
disabled={!form.idGroup}
|
||||||
|
value={form.idPosition || ''}
|
||||||
|
onChange={(v) => setForm(f => ({ ...f, idPosition: v || '' }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="lg"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={handleCreateUser}
|
||||||
|
>
|
||||||
|
Register User
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={<Text fw={700} size="lg">Edit User</Text>}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Personal Information
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Full Name"
|
||||||
|
placeholder="Enter full name"
|
||||||
|
required
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="NIK"
|
||||||
|
placeholder="16-digit identity number"
|
||||||
|
required
|
||||||
|
value={editForm.nik}
|
||||||
|
onChange={(e) => setEditForm(f => ({ ...f, nik: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||||
|
<TextInput
|
||||||
|
label="Email Address"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
required
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm(f => ({ ...f, email: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Phone Number"
|
||||||
|
placeholder="628xxxxxxxxxx"
|
||||||
|
required
|
||||||
|
value={editForm.phone}
|
||||||
|
onChange={(e) => setEditForm(f => ({ ...f, phone: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Gender"
|
||||||
|
placeholder="Select gender"
|
||||||
|
data={[
|
||||||
|
{ value: 'M', label: 'Male' },
|
||||||
|
{ value: 'F', label: 'Female' },
|
||||||
|
]}
|
||||||
|
mt="sm"
|
||||||
|
required
|
||||||
|
value={editForm.gender}
|
||||||
|
onChange={(v) => setEditForm(f => ({ ...f, gender: v || '' }))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider label="Role & Organization" labelPosition="center" my="sm" />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Select
|
||||||
|
label="User Role"
|
||||||
|
placeholder="Select user role"
|
||||||
|
data={rolesOptions}
|
||||||
|
required
|
||||||
|
value={editForm.idUserRole}
|
||||||
|
onChange={(v) => setEditForm(f => ({ ...f, idUserRole: v || '' }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Village"
|
||||||
|
placeholder="Type to search village..."
|
||||||
|
searchable
|
||||||
|
onSearchChange={setVillageSearch}
|
||||||
|
data={villagesOptions}
|
||||||
|
mt="sm"
|
||||||
|
required
|
||||||
|
value={editForm.idVillage}
|
||||||
|
onChange={(v) => {
|
||||||
|
setEditForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||||
|
<Select
|
||||||
|
label="Group"
|
||||||
|
placeholder={editForm.idVillage ? "Select group" : "Select village first"}
|
||||||
|
data={groupsOptions}
|
||||||
|
disabled={!editForm.idVillage}
|
||||||
|
required
|
||||||
|
value={editForm.idGroup}
|
||||||
|
onChange={(v) => {
|
||||||
|
setEditForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Position"
|
||||||
|
placeholder={editForm.idGroup ? "Select position" : "Select group first"}
|
||||||
|
data={positionsOptions}
|
||||||
|
disabled={!editForm.idGroup}
|
||||||
|
value={editForm.idPosition || ''}
|
||||||
|
onChange={(v) => setEditForm(f => ({ ...f, idPosition: v || '' }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider label="System Access" labelPosition="center" my="sm" />
|
||||||
|
|
||||||
|
<SimpleGrid cols={2} spacing="xl">
|
||||||
|
<Switch
|
||||||
|
label="Account Active"
|
||||||
|
description="Enable or disable user access"
|
||||||
|
checked={editForm.isActive}
|
||||||
|
onChange={(event) => setEditForm(f => ({ ...f, isActive: event.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label="Without OTP"
|
||||||
|
description="Bypass login OTP verification"
|
||||||
|
checked={editForm.isWithoutOTP}
|
||||||
|
onChange={(event) => setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="lg"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={handleUpdateUser}
|
||||||
|
>
|
||||||
|
Update User
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search name, NIK, or email..."
|
||||||
|
leftSection={<TbSearch size={18} />}
|
||||||
|
size="md"
|
||||||
|
rightSection={
|
||||||
|
search ? (
|
||||||
|
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="md">
|
||||||
|
<TbX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.currentTarget.value)}
|
||||||
|
radius="md"
|
||||||
|
style={{ maxWidth: 500 }}
|
||||||
|
ml={40}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<Text c="dimmed">Loading user data...</Text>
|
||||||
|
</Paper>
|
||||||
|
) : error ? (
|
||||||
|
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<Text c="red">Failed to load data from API.</Text>
|
||||||
|
</Paper>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<TbUsers size={40} color="gray" opacity={0.4} />
|
||||||
|
<Text c="dimmed" mt="md">No users match your criteria.</Text>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
|
||||||
|
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
|
||||||
|
<Table
|
||||||
|
verticalSpacing="md"
|
||||||
|
horizontalSpacing="md"
|
||||||
|
highlightOnHover
|
||||||
|
withColumnBorders={false}
|
||||||
|
style={{
|
||||||
|
tableLayout: isMobile ? 'auto' : 'fixed',
|
||||||
|
width: '100%',
|
||||||
|
minWidth: isMobile ? 900 : 'unset'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Thead bg="rgba(0,0,0,0.05)">
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '22%' }}>Organization</Table.Th>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>Role</Table.Th>
|
||||||
|
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }} onClick={()=>{handleEditOpen(user)}}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Avatar
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color={getRoleColor(user.role)}
|
||||||
|
style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap={2} style={{ overflow: 'hidden' }}>
|
||||||
|
<Text fw={700} size="sm" truncate="end" style={{ color: 'var(--mantine-color-white)' }}>{user.name}</Text>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<TbId size={12} color="var(--mantine-color-dimmed)" />
|
||||||
|
<Text size="xs" c="dimmed" style={{ letterSpacing: '0.5px' }} truncate="end">{user.nik}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={4} style={{ overflow: 'hidden' }}>
|
||||||
|
<Group gap={8} wrap="nowrap" align="center">
|
||||||
|
<ThemeIcon size={18} variant="transparent" color="gray">
|
||||||
|
<TbMail size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" fw={500} truncate="end">{user.email}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap" align="center">
|
||||||
|
<ThemeIcon size={18} variant="transparent" color="gray">
|
||||||
|
<TbPhone size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" truncate="end">{user.phone}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap={8} wrap="nowrap" align="center">
|
||||||
|
<ThemeIcon size={18} variant="light" color="blue" radius="sm">
|
||||||
|
<TbHome2 size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" fw={700} truncate="end">{user.village}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap" align="center">
|
||||||
|
<ThemeIcon size={18} variant="transparent" color="gray">
|
||||||
|
<TbBriefcase size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" truncate="end">{user.group} · {user.position || 'Staff'}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
variant="filled"
|
||||||
|
color={getRoleColor(user.role)}
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
fullWidth={false}
|
||||||
|
styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
{user.isActive ? (
|
||||||
|
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981', boxShadow: '0 0 8px #10b981' }} />
|
||||||
|
) : (
|
||||||
|
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444' }} />
|
||||||
|
)}
|
||||||
|
<Text size="xs" fw={800} c={user.isActive ? 'teal.4' : 'red.5'}>
|
||||||
|
{user.isActive ? 'ACTIVE' : 'INACTIVE'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{user.isWithoutOTP && (
|
||||||
|
<Badge variant="light" color="orange" size="xs" radius="sm">
|
||||||
|
NO OTP
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && response?.data?.totalPage > 0 && (
|
||||||
|
<Group justify="center" mt="xl">
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
total={response.data.totalPage}
|
||||||
|
radius="md"
|
||||||
|
withEdges={false}
|
||||||
|
siblings={1}
|
||||||
|
boundaries={1}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/frontend/routes/apps.$appId.users.tsx
Normal file
9
src/frontend/routes/apps.$appId.users.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/apps/$appId/users')({
|
||||||
|
component: UsersLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
function UsersLayout() {
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import { AreaChart } from '@mantine/charts'
|
import { AreaChart } from '@mantine/charts'
|
||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +25,6 @@ import {
|
|||||||
TbCalendar,
|
TbCalendar,
|
||||||
TbCalendarEvent,
|
TbCalendarEvent,
|
||||||
TbChartBar,
|
TbChartBar,
|
||||||
TbCircleCheck,
|
|
||||||
TbEdit,
|
TbEdit,
|
||||||
TbHome2,
|
TbHome2,
|
||||||
TbLayoutKanban,
|
TbLayoutKanban,
|
||||||
@@ -32,6 +35,11 @@ import {
|
|||||||
TbUsersGroup,
|
TbUsersGroup,
|
||||||
TbWifi
|
TbWifi
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { API_URLS } from '../config/api'
|
||||||
|
import { useSession } from '../hooks/useAuth'
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
|
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
|
||||||
component: VillageDetailPage,
|
component: VillageDetailPage,
|
||||||
@@ -39,165 +47,33 @@ export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
|
|||||||
|
|
||||||
// ── Mock Data ────────────────────────────────────────────────────────────────
|
// ── Mock Data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mockVillages: Record<string, any> = {
|
// Mock data removed as it is replaced by API calls
|
||||||
'sukatani': {
|
|
||||||
id: 'sukatani',
|
|
||||||
name: 'Sukatani',
|
|
||||||
kecamatan: 'Tapos',
|
|
||||||
kabupaten: 'Kota Depok',
|
|
||||||
provinsi: 'Jawa Barat',
|
|
||||||
kodePos: '16455',
|
|
||||||
perbekel: 'H. Suryana, S.Sos',
|
|
||||||
createdAt: '2024-03-12',
|
|
||||||
createdBy: 'Admin Pusat',
|
|
||||||
updatedAt: '2024-04-01',
|
|
||||||
status: 'fully integrated',
|
|
||||||
lastSync: '2 menit lalu',
|
|
||||||
stats: { users: 1240, groups: 34, divisions: 8, activities: 4520 },
|
|
||||||
},
|
|
||||||
'sukamaju': {
|
|
||||||
id: 'sukamaju',
|
|
||||||
name: 'Sukamaju',
|
|
||||||
kecamatan: 'Cilodong',
|
|
||||||
kabupaten: 'Kota Depok',
|
|
||||||
provinsi: 'Jawa Barat',
|
|
||||||
kodePos: '16413',
|
|
||||||
perbekel: 'Drs. H. Mujiono',
|
|
||||||
createdAt: '2024-04-01',
|
|
||||||
createdBy: 'Amel',
|
|
||||||
updatedAt: '2024-04-10',
|
|
||||||
status: 'sync active',
|
|
||||||
lastSync: '15 menit lalu',
|
|
||||||
stats: { users: 980, groups: 28, divisions: 6, activities: 3180 },
|
|
||||||
},
|
|
||||||
'cikini': {
|
|
||||||
id: 'cikini',
|
|
||||||
name: 'Cikini',
|
|
||||||
kecamatan: 'Menteng',
|
|
||||||
kabupaten: 'Jakarta Pusat',
|
|
||||||
provinsi: 'DKI Jakarta',
|
|
||||||
kodePos: '10330',
|
|
||||||
perbekel: 'Ir. Budi Santoso',
|
|
||||||
createdAt: '2024-05-20',
|
|
||||||
createdBy: 'Jane Smith',
|
|
||||||
updatedAt: '2024-05-25',
|
|
||||||
status: 'sync pending',
|
|
||||||
lastSync: 'Belum pernah sync',
|
|
||||||
stats: { users: 420, groups: 12, divisions: 3, activities: 640 },
|
|
||||||
},
|
|
||||||
'bojong-gede': {
|
|
||||||
id: 'bojong-gede',
|
|
||||||
name: 'Bojong Gede',
|
|
||||||
kecamatan: 'Bojong Gede',
|
|
||||||
kabupaten: 'Kabupaten Bogor',
|
|
||||||
provinsi: 'Jawa Barat',
|
|
||||||
kodePos: '16920',
|
|
||||||
perbekel: 'H. Rahmat Hidayat, M.Si',
|
|
||||||
createdAt: '2024-02-15',
|
|
||||||
createdBy: 'Rahmat',
|
|
||||||
updatedAt: '2024-04-02',
|
|
||||||
status: 'fully integrated',
|
|
||||||
lastSync: '1 jam lalu',
|
|
||||||
stats: { users: 1890, groups: 51, divisions: 12, activities: 7340 },
|
|
||||||
},
|
|
||||||
'ciputat': {
|
|
||||||
id: 'ciputat',
|
|
||||||
name: 'Ciputat',
|
|
||||||
kecamatan: 'Ciputat',
|
|
||||||
kabupaten: 'Tangerang Selatan',
|
|
||||||
provinsi: 'Banten',
|
|
||||||
kodePos: '15411',
|
|
||||||
perbekel: 'Drs. Ahmad Fauzi',
|
|
||||||
createdAt: '2024-06-10',
|
|
||||||
createdBy: 'Admin Pusat',
|
|
||||||
updatedAt: '2024-06-15',
|
|
||||||
status: 'sync active',
|
|
||||||
lastSync: '30 menit lalu',
|
|
||||||
stats: { users: 1120, groups: 30, divisions: 7, activities: 3860 },
|
|
||||||
},
|
|
||||||
'serpong': {
|
|
||||||
id: 'serpong',
|
|
||||||
name: 'Serpong',
|
|
||||||
kecamatan: 'Serpong',
|
|
||||||
kabupaten: 'Tangerang Selatan',
|
|
||||||
provinsi: 'Banten',
|
|
||||||
kodePos: '15310',
|
|
||||||
perbekel: 'H. Bambang Wijaya',
|
|
||||||
createdAt: '2024-07-05',
|
|
||||||
createdBy: 'Amel',
|
|
||||||
updatedAt: '2024-07-10',
|
|
||||||
status: 'sync pending',
|
|
||||||
lastSync: 'Belum tersinkronisasi',
|
|
||||||
stats: { users: 280, groups: 8, divisions: 2, activities: 310 },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chart Data Generators ─────────────────────────────────────────────────────
|
// Remove chart data generators as they are replaced by API calls
|
||||||
|
|
||||||
function generateDailyData() {
|
|
||||||
const days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']
|
|
||||||
const today = new Date()
|
|
||||||
return Array.from({ length: 14 }, (_, i) => {
|
|
||||||
const d = new Date(today)
|
|
||||||
d.setDate(today.getDate() - (13 - i))
|
|
||||||
const dayName = days[d.getDay() === 0 ? 6 : d.getDay() - 1]
|
|
||||||
const dateStr = `${dayName} ${d.getDate()}/${d.getMonth() + 1}`
|
|
||||||
return {
|
|
||||||
label: dateStr,
|
|
||||||
aktivitas: Math.floor(Math.random() * 300 + 60),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMonthlyData() {
|
|
||||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des']
|
|
||||||
return months.map((m) => ({
|
|
||||||
label: m,
|
|
||||||
aktivitas: Math.floor(Math.random() * 2000 + 800),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateYearlyData() {
|
|
||||||
return ['2021', '2022', '2023', '2024'].map((y) => ({
|
|
||||||
label: y,
|
|
||||||
aktivitas: Math.floor(Math.random() * 15000 + 5000),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
'fully integrated': { color: 'teal', label: 'Terintegrasi Penuh' },
|
|
||||||
'sync active': { color: 'blue', label: 'Sync Aktif' },
|
|
||||||
'sync pending': { color: 'orange', label: 'Menunggu Sync' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
|
||||||
return new Date(dateStr).toLocaleDateString('id-ID', {
|
|
||||||
day: 'numeric', month: 'long', year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Activity Chart ────────────────────────────────────────────────────────────
|
// ── Activity Chart ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
|
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
|
||||||
|
|
||||||
function ActivityChart() {
|
function ActivityChart({ villageId }: { villageId: string }) {
|
||||||
const [period, setPeriod] = useState<ChartPeriod>('monthly')
|
const [period, setPeriod] = useState<ChartPeriod>('daily')
|
||||||
|
|
||||||
const dataMap: Record<ChartPeriod, any[]> = {
|
const { data: response, isLoading } = useSWR(
|
||||||
daily: generateDailyData(),
|
API_URLS.graphLogVillages(villageId, period),
|
||||||
monthly: generateMonthlyData(),
|
fetcher
|
||||||
yearly: generateYearlyData(),
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const labels: Record<ChartPeriod, string> = {
|
const labels: Record<ChartPeriod, string> = {
|
||||||
daily: 'Harian (14 hari terakhir)',
|
daily: 'Daily (last 14 days)',
|
||||||
monthly: 'Bulanan (tahun ini)',
|
monthly: 'Monthly (this year)',
|
||||||
yearly: 'Tahunan',
|
yearly: 'Yearly',
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = dataMap[period]
|
const data = response?.data || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="xl" p="lg">
|
<Paper withBorder radius="xl" p="lg">
|
||||||
@@ -207,7 +83,7 @@ function ActivityChart() {
|
|||||||
<TbChartBar size={14} />
|
<TbChartBar size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={700} size="sm">Log Aktivitas Desa</Text>
|
<Text fw={700} size="sm">Village Activity Log</Text>
|
||||||
<Text size="xs" c="dimmed">{labels[period]}</Text>
|
<Text size="xs" c="dimmed">{labels[period]}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -218,46 +94,37 @@ function ActivityChart() {
|
|||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
data={[
|
data={[
|
||||||
{ value: 'daily', label: 'Harian' },
|
{ value: 'daily', label: 'Daily' },
|
||||||
{ value: 'monthly', label: 'Bulanan' },
|
{ value: 'monthly', label: 'Monthly' },
|
||||||
{ value: 'yearly', label: 'Tahunan' },
|
{ value: 'yearly', label: 'Yearly' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<AreaChart
|
{isLoading ? (
|
||||||
h={280}
|
<Stack h={280} align="center" justify="center">
|
||||||
data={data}
|
<Text size="sm" c="dimmed">Loading chart data...</Text>
|
||||||
dataKey="label"
|
</Stack>
|
||||||
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Aktivitas' }]}
|
) : (
|
||||||
curveType="monotone"
|
<AreaChart
|
||||||
withTooltip
|
h={280}
|
||||||
withDots
|
data={data}
|
||||||
tickLine="none"
|
dataKey="label"
|
||||||
gridAxis="x"
|
series={[{ name: 'activity', color: '#2563EB' }]}
|
||||||
tooltipAnimationDuration={150}
|
curveType="monotone"
|
||||||
fillOpacity={1}
|
withTooltip={true}
|
||||||
areaProps={{
|
withDots={true}
|
||||||
strokeWidth: 2.5,
|
withPointLabels={false}
|
||||||
fill: 'url(#villageAreaGrad)',
|
tooltipAnimationDuration={150}
|
||||||
stroke: '#2563EB',
|
tooltipProps={{
|
||||||
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
|
allowEscapeViewBox: { x: true, y: false },
|
||||||
}}
|
}}
|
||||||
dotProps={{
|
activeDotProps={{
|
||||||
r: 4,
|
r: 6,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
stroke: '#2563EB',
|
}}
|
||||||
fill: 'white',
|
/>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} />
|
|
||||||
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} />
|
|
||||||
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</AreaChart>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -267,26 +134,159 @@ function ActivityChart() {
|
|||||||
function VillageDetailPage() {
|
function VillageDetailPage() {
|
||||||
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
|
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const village = mockVillages[villageId]
|
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const isDeveloper = session?.user?.role === 'DEVELOPER'
|
||||||
|
|
||||||
|
const { data: infoRes, isLoading: infoLoading, mutate } = useSWR(API_URLS.infoVillages(villageId), fetcher)
|
||||||
|
const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
|
||||||
|
|
||||||
|
const [confirmModalOpened, { open: openConfirmModal, close: closeConfirmModal }] = useDisclosure(false)
|
||||||
|
const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false)
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false)
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [editForm, setEditForm] = useState({ name: '', desc: '' })
|
||||||
|
|
||||||
|
const village = infoRes?.data
|
||||||
|
const stats = gridRes?.data
|
||||||
|
|
||||||
|
const openEdit = () => {
|
||||||
|
setEditForm({
|
||||||
|
name: village?.name || '',
|
||||||
|
desc: village?.desc || ''
|
||||||
|
})
|
||||||
|
openEditModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditVillage = async () => {
|
||||||
|
if (!village) return
|
||||||
|
|
||||||
|
if (!editForm.name.trim() || !editForm.desc.trim()) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Validation Error',
|
||||||
|
message: 'All fields are required.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditing(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.editVillages(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: village.id,
|
||||||
|
name: editForm.name,
|
||||||
|
desc: editForm.desc
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await fetch(API_URLS.createLog(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'UPDATE', message: `Data desa (${appId}) diperbarui: ${editForm.name}-${village.id}` })
|
||||||
|
}).catch(console.error)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Village data has been updated successfully.',
|
||||||
|
color: 'teal'
|
||||||
|
})
|
||||||
|
mutate()
|
||||||
|
closeEditModal()
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to update village data.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'A network error occurred.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmToggle = async () => {
|
||||||
|
if (!village) return
|
||||||
|
|
||||||
|
setIsUpdating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.updateStatusVillages(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: village.id,
|
||||||
|
active: !village.isActive
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await fetch(API_URLS.createLog(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'UPDATE', message: `Status desa (${appId}) diperbarui (${!village.isActive ? 'activated' : 'deactivated'}): ${village.name}-${village.id}` })
|
||||||
|
}).catch(console.error)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: `Village status has been ${!village.isActive ? 'activated' : 'deactivated'}.`,
|
||||||
|
color: 'teal'
|
||||||
|
})
|
||||||
|
mutate()
|
||||||
|
closeConfirmModal()
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to update village status.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'A network error occurred.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
|
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
|
||||||
|
|
||||||
|
if (infoLoading || gridLoading) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" py="xl" gap="md">
|
||||||
|
<Text c="dimmed">Loading village data...</Text>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!village) {
|
if (!village) {
|
||||||
return (
|
return (
|
||||||
<Stack align="center" py="xl" gap="md">
|
<Stack align="center" py="xl" gap="md">
|
||||||
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
|
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
|
||||||
<Title order={4}>Desa tidak ditemukan</Title>
|
<Title order={4}>Village not found</Title>
|
||||||
<Text c="dimmed">ID desa "{villageId}" tidak terdaftar dalam sistem.</Text>
|
<Text c="dimmed">Village ID "{villageId}" is not registered in the system.</Text>
|
||||||
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
|
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
|
||||||
Kembali ke Daftar
|
Back to List
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
|
||||||
const { stats } = village
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
|
|
||||||
@@ -300,28 +300,30 @@ function VillageDetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
onClick={goBack}
|
onClick={goBack}
|
||||||
>
|
>
|
||||||
Daftar Desa
|
Village List
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Button
|
<Button
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color={village.status === 'fully integrated' || village.status === 'sync active' ? 'red' : 'green'}
|
color={village.isActive ? 'red' : 'green'}
|
||||||
leftSection={village.status === 'fully integrated' || village.status === 'sync active' ? <TbPower size={16} /> : <TbPower size={16} />}
|
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
|
||||||
onClick={() => alert(`Toggle status for ${village.name}`)}
|
onClick={openConfirmModal}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
loading={isUpdating}
|
||||||
|
disabled={!isDeveloper}
|
||||||
>
|
>
|
||||||
{village.status === 'fully integrated' || village.status === 'sync active' ? 'Nonaktifkan Desa' : 'Aktifkan Desa'}
|
{village.isActive ? 'Deactivate' : 'Active'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="blue"
|
||||||
leftSection={<TbEdit size={16} />}
|
leftSection={<TbEdit size={16} />}
|
||||||
onClick={() => alert(`Edit settings for ${village.name}`)}
|
onClick={openEdit}
|
||||||
radius="md"
|
radius="md"
|
||||||
>
|
>
|
||||||
Edit Desa
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -356,18 +358,18 @@ function VillageDetailPage() {
|
|||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
|
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
|
||||||
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||||
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
|
Location data not available
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<TbUser size={14} color="rgba(255,255,255,0.8)" />
|
<TbUser size={14} color="rgba(255,255,255,0.8)" />
|
||||||
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||||
Perbekel: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
|
Village Head: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="xs" mt={2}>
|
{/* <Group gap="xs" mt={2}>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
@@ -377,24 +379,16 @@ function VillageDetailPage() {
|
|||||||
>
|
>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
</Group> */}
|
||||||
variant="outline"
|
|
||||||
radius="sm"
|
|
||||||
size="sm"
|
|
||||||
style={{ color: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,255,255,0.25)' }}
|
|
||||||
>
|
|
||||||
Kode Pos: {village.kodePos}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Last Sync block */}
|
{/* Last Sync block */}
|
||||||
<Stack gap={4} align="flex-end">
|
<Stack gap={4} align="flex-end">
|
||||||
<Text size="xs" style={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Last Sync</Text>
|
{/* <Text size="xs" style={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Last Sync</Text> */}
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<TbWifi size={15} color="rgba(255,255,255,0.9)" />
|
<TbWifi size={15} color="rgba(255,255,255,0.9)" />
|
||||||
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.lastSync}</Text>
|
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.isActive ? 'ACTIVE' : 'NON-ACTIVE'}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -403,19 +397,25 @@ function VillageDetailPage() {
|
|||||||
{/* ── Stats Cards ── */}
|
{/* ── Stats Cards ── */}
|
||||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||||
{[
|
{[
|
||||||
{ icon: TbUsers, label: 'Jumlah User', value: stats.users.toLocaleString('id-ID'), color: 'blue' },
|
{ icon: TbUsers, label: 'Total Users', active: stats?.user?.active, nonActive: stats?.user?.nonActive, color: 'blue' },
|
||||||
{ icon: TbUsersGroup, label: 'Jumlah Grup', value: stats.groups.toLocaleString('id-ID'), color: 'violet' },
|
{ icon: TbUsersGroup, label: 'Total Groups', active: stats?.group?.active, nonActive: stats?.group?.nonActive, color: 'violet' },
|
||||||
{ icon: TbLayoutKanban, label: 'Jumlah Divisi', value: stats.divisions.toLocaleString('id-ID'), color: 'teal' },
|
{ icon: TbLayoutKanban, label: 'Total Divisions', active: stats?.division?.active, nonActive: stats?.division?.nonActive, color: 'teal' },
|
||||||
{ icon: TbCalendarEvent, label: 'Jumlah Kegiatan', value: stats.activities.toLocaleString('id-ID'), color: 'orange' },
|
{ icon: TbCalendarEvent, label: 'Total Activities', active: stats?.project?.active, nonActive: stats?.project?.nonActive, color: 'orange' },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
|
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
|
||||||
<ThemeIcon size={36} radius="md" variant="light" color={s.color} mb="xs">
|
<Group justify="space-between" align="flex-start" mb="xs">
|
||||||
<s.icon size={18} />
|
<ThemeIcon size={36} radius="md" variant="light" color={s.color}>
|
||||||
</ThemeIcon>
|
<s.icon size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={0} align="flex-end">
|
||||||
|
<Text size="10px" c="dimmed" fw={700}>NON-ACTIVE</Text>
|
||||||
|
<Text size="xs" fw={700}>{s.nonActive?.toLocaleString('id-ID') || 0}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xl" fw={800} mt={2}>{s.value}</Text>
|
<Text size="xl" fw={800} mt={2}>{s.active?.toLocaleString('id-ID') || 0}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -430,7 +430,7 @@ function VillageDetailPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left (3/4): Activity Chart */}
|
{/* Left (3/4): Activity Chart */}
|
||||||
<ActivityChart />
|
<ActivityChart villageId={villageId} />
|
||||||
|
|
||||||
{/* Right (1/4): Informasi Sistem */}
|
{/* Right (1/4): Informasi Sistem */}
|
||||||
<Paper withBorder radius="xl" p="lg">
|
<Paper withBorder radius="xl" p="lg">
|
||||||
@@ -438,13 +438,13 @@ function VillageDetailPage() {
|
|||||||
<ThemeIcon size={28} radius="md" variant="light" color="teal">
|
<ThemeIcon size={28} radius="md" variant="light" color="teal">
|
||||||
<TbCalendar size={14} />
|
<TbCalendar size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fw={700} size="sm">Informasi Sistem</Text>
|
<Text fw={700} size="sm">System Information</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Tanggal Dibuat', value: formatDate(village.createdAt) },
|
{ label: 'Date Created', value: village.createdAt },
|
||||||
{ label: 'Dibuat Oleh', value: village.createdBy },
|
{ label: 'Created By', value: '-' },
|
||||||
{ label: 'Terakhir Diperbarui', value: formatDate(village.updatedAt) },
|
{ label: 'Last Updated', value: '-' },
|
||||||
].map((item, idx, arr) => (
|
].map((item, idx, arr) => (
|
||||||
<Group
|
<Group
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@@ -463,6 +463,75 @@ function VillageDetailPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Confirmation Modal ── */}
|
||||||
|
<Modal
|
||||||
|
opened={confirmModalOpened}
|
||||||
|
onClose={closeConfirmModal}
|
||||||
|
title={<Text fw={700}>Confirm Status Change</Text>}
|
||||||
|
radius="xl"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Are you sure you want to <strong>{village.isActive ? 'deactivate' : 'activate'}</strong> village <strong>{village.name}</strong>?
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="light" color="gray" onClick={closeConfirmModal} radius="md">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color={village.isActive ? 'red' : 'green'}
|
||||||
|
onClick={handleConfirmToggle}
|
||||||
|
loading={isUpdating}
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Edit Village Modal ── */}
|
||||||
|
<Modal
|
||||||
|
opened={editModalOpened}
|
||||||
|
onClose={closeEditModal}
|
||||||
|
title={<Text fw={700}>Edit Village Details</Text>}
|
||||||
|
radius="xl"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Village Name"
|
||||||
|
placeholder="Enter village name"
|
||||||
|
required
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Enter village description..."
|
||||||
|
minRows={3}
|
||||||
|
required
|
||||||
|
value={editForm.desc}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm" mt="md">
|
||||||
|
<Button variant="light" color="gray" onClick={closeEditModal} radius="md">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
onClick={handleEditVillage}
|
||||||
|
loading={isEditing}
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +1,82 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
Container,
|
Container,
|
||||||
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
|
Modal,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Textarea,
|
||||||
Paper,
|
|
||||||
Button,
|
|
||||||
ActionIcon,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
|
||||||
SimpleGrid,
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
SegmentedControl,
|
|
||||||
Card,
|
|
||||||
Divider,
|
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
import {
|
import {
|
||||||
TbPlus,
|
TbArrowRight,
|
||||||
TbSearch,
|
|
||||||
TbBuildingCommunity,
|
TbBuildingCommunity,
|
||||||
|
TbCalendar,
|
||||||
|
TbChevronRight,
|
||||||
|
TbHome2,
|
||||||
TbLayoutGrid,
|
TbLayoutGrid,
|
||||||
TbList,
|
TbList,
|
||||||
TbMapPin,
|
TbMapPin,
|
||||||
TbCalendar,
|
TbPlus,
|
||||||
|
TbSearch,
|
||||||
TbUser,
|
TbUser,
|
||||||
TbHome2,
|
TbX,
|
||||||
TbArrowRight,
|
|
||||||
TbChevronRight,
|
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { API_URLS } from '../config/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/villages/')({
|
export const Route = createFileRoute('/apps/$appId/villages/')({
|
||||||
component: AppVillagesIndexPage,
|
component: AppVillagesIndexPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockVillages = [
|
interface APIVillage {
|
||||||
{
|
id: string
|
||||||
id: 'sukatani',
|
name: string
|
||||||
name: 'Sukatani',
|
isActive: boolean
|
||||||
kecamatan: 'Tapos',
|
createdAt: string
|
||||||
kabupaten: 'Kota Depok',
|
perbekel: string | null
|
||||||
provinsi: 'Jawa Barat',
|
}
|
||||||
perbekel: 'H. Suryana, S.Sos',
|
|
||||||
createdAt: '2024-03-12',
|
|
||||||
createdBy: 'Admin Pusat',
|
|
||||||
status: 'fully integrated',
|
|
||||||
population: 4500,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sukamaju',
|
|
||||||
name: 'Sukamaju',
|
|
||||||
kecamatan: 'Cilodong',
|
|
||||||
kabupaten: 'Kota Depok',
|
|
||||||
provinsi: 'Jawa Barat',
|
|
||||||
perbekel: 'Drs. H. Mujiono',
|
|
||||||
createdAt: '2024-04-01',
|
|
||||||
createdBy: 'Amel',
|
|
||||||
status: 'sync active',
|
|
||||||
population: 3800,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cikini',
|
|
||||||
name: 'Cikini',
|
|
||||||
kecamatan: 'Menteng',
|
|
||||||
kabupaten: 'Jakarta Pusat',
|
|
||||||
provinsi: 'DKI Jakarta',
|
|
||||||
perbekel: 'Ir. Budi Santoso',
|
|
||||||
createdAt: '2024-05-20',
|
|
||||||
createdBy: 'Jane Smith',
|
|
||||||
status: 'sync pending',
|
|
||||||
population: 2100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bojong-gede',
|
|
||||||
name: 'Bojong Gede',
|
|
||||||
kecamatan: 'Bojong Gede',
|
|
||||||
kabupaten: 'Kabupaten Bogor',
|
|
||||||
provinsi: 'Jawa Barat',
|
|
||||||
perbekel: 'H. Rahmat Hidayat, M.Si',
|
|
||||||
createdAt: '2024-02-15',
|
|
||||||
createdBy: 'Rahmat',
|
|
||||||
status: 'fully integrated',
|
|
||||||
population: 6700,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ciputat',
|
|
||||||
name: 'Ciputat',
|
|
||||||
kecamatan: 'Ciputat',
|
|
||||||
kabupaten: 'Tangerang Selatan',
|
|
||||||
provinsi: 'Banten',
|
|
||||||
perbekel: 'Drs. Ahmad Fauzi',
|
|
||||||
createdAt: '2024-06-10',
|
|
||||||
createdBy: 'Admin Pusat',
|
|
||||||
status: 'sync active',
|
|
||||||
population: 5200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'serpong',
|
|
||||||
name: 'Serpong',
|
|
||||||
kecamatan: 'Serpong',
|
|
||||||
kabupaten: 'Tangerang Selatan',
|
|
||||||
provinsi: 'Banten',
|
|
||||||
perbekel: 'H. Bambang Wijaya',
|
|
||||||
createdAt: '2024-07-05',
|
|
||||||
createdBy: 'Amel',
|
|
||||||
status: 'sync pending',
|
|
||||||
population: 8900,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
'fully integrated': { color: 'teal', label: 'Terintegrasi' },
|
'active': { color: 'teal', label: 'Active' },
|
||||||
'sync active': { color: 'blue', label: 'Sync Aktif' },
|
'inactive': { color: 'orange', label: 'Inactive' },
|
||||||
'sync pending': { color: 'orange', label: 'Sync Pending' },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
function formatDate(dateStr: string) {
|
||||||
return new Date(dateStr).toLocaleDateString('id-ID', {
|
if (!dateStr) return '-'
|
||||||
day: 'numeric',
|
try {
|
||||||
month: 'short',
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
day: 'numeric',
|
||||||
})
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
|
function VillageGridCard({ village, onClick }: { village: APIVillage; onClick: () => void }) {
|
||||||
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
const status = village.isActive ? 'active' : 'inactive'
|
||||||
|
const cfg = statusConfig[status as keyof typeof statusConfig]
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
@@ -158,12 +106,12 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
|
|||||||
<Group gap={4} mb="md">
|
<Group gap={4} mb="md">
|
||||||
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
|
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Kec. {village.kecamatan} · {village.kabupaten}
|
No location details available
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
{village.provinsi}
|
-
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
@@ -171,19 +119,19 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
|
|||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<TbUser size={13} color="var(--mantine-color-dimmed)" />
|
<TbUser size={13} color="var(--mantine-color-dimmed)" />
|
||||||
<Text size="xs" c="dimmed">Perbekel:</Text>
|
<Text size="xs" c="dimmed">Village Head:</Text>
|
||||||
<Text size="xs" fw={600}>{village.perbekel}</Text>
|
<Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<TbCalendar size={13} color="var(--mantine-color-dimmed)" />
|
<TbCalendar size={13} color="var(--mantine-color-dimmed)" />
|
||||||
<Text size="xs" c="dimmed">Dibuat:</Text>
|
<Text size="xs" c="dimmed">Created:</Text>
|
||||||
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
|
<Text size="xs" fw={600}>{village.createdAt}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
{/* <Group gap="xs">
|
||||||
<Avatar size={14} radius="xl" color="brand-blue" src={null} />
|
<Avatar size={14} radius="xl" color="brand-blue" src={null} />
|
||||||
<Text size="xs" c="dimmed">Oleh:</Text>
|
<Text size="xs" c="dimmed">By:</Text>
|
||||||
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
||||||
</Group>
|
</Group> */}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -196,14 +144,15 @@ function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]
|
|||||||
rightSection={<TbArrowRight size={14} />}
|
rightSection={<TbArrowRight size={14} />}
|
||||||
styles={{ root: { fontSize: 12 } }}
|
styles={{ root: { fontSize: 12 } }}
|
||||||
>
|
>
|
||||||
Lihat Detail
|
View Details
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
|
function VillageListRow({ village, onClick }: { village: APIVillage; onClick: () => void }) {
|
||||||
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
const status = village.isActive ? 'active' : 'inactive'
|
||||||
|
const cfg = statusConfig[status as keyof typeof statusConfig]
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
@@ -233,7 +182,7 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
|
|||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
|
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
|
No location details available
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -241,20 +190,20 @@ function VillageListRow({ village, onClick }: { village: typeof mockVillages[0];
|
|||||||
|
|
||||||
<Group gap="xl" visibleFrom="md">
|
<Group gap="xl" visibleFrom="md">
|
||||||
<Stack gap={0} align="center">
|
<Stack gap={0} align="center">
|
||||||
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Perbekel</Text>
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Village Head</Text>
|
||||||
<Text size="xs" fw={600}>{village.perbekel}</Text>
|
<Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0} align="center">
|
<Stack gap={0} align="center">
|
||||||
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Dibuat</Text>
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Created</Text>
|
||||||
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
|
<Text size="xs" fw={600}>{village.createdAt}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0} align="center">
|
{/* <Stack gap={0} align="center">
|
||||||
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Oleh</Text>
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Oleh</Text>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Avatar size={16} radius="xl" color="brand-blue" src={null} />
|
<Avatar size={16} radius="xl" color="brand-blue" src={null} />
|
||||||
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack> */}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ActionIcon variant="light" color="brand-blue" radius="md">
|
<ActionIcon variant="light" color="brand-blue" radius="md">
|
||||||
@@ -269,21 +218,111 @@ function AppVillagesIndexPage() {
|
|||||||
const { appId } = useParams({ from: '/apps/$appId' })
|
const { appId } = useParams({ from: '/apps/$appId' })
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
|
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
desc: '',
|
||||||
|
username: '',
|
||||||
|
phone: '',
|
||||||
|
nik: '',
|
||||||
|
email: '',
|
||||||
|
gender: ''
|
||||||
|
})
|
||||||
|
|
||||||
const isDesaPlus = appId === 'desa-plus'
|
const isDesaPlus = appId === 'desa-plus'
|
||||||
|
const apiUrl = isDesaPlus ? API_URLS.getVillages(page, searchQuery) : null
|
||||||
|
|
||||||
const filtered = mockVillages.filter((v) =>
|
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
|
||||||
[v.name, v.kecamatan, v.kabupaten, v.provinsi, v.perbekel]
|
const villages: APIVillage[] = response?.data || []
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleVillageClick = (villageId: string) => {
|
const handleVillageClick = (villageId: string) => {
|
||||||
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } })
|
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearchChange = (val: string) => {
|
||||||
|
setSearch(val)
|
||||||
|
if (val.length >= 3 || val.length === 0) {
|
||||||
|
setSearchQuery(val)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearch('')
|
||||||
|
setSearchQuery('')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateVillage = async () => {
|
||||||
|
const requiredFields = ['name', 'desc', 'username', 'phone', 'nik', 'email', 'gender'] as const
|
||||||
|
const isFormValid = requiredFields.every(field => !!form[field])
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Validation Error',
|
||||||
|
message: 'All fields are required to register a new village.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.createVillages(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(form)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await fetch(API_URLS.createLog(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'CREATE', message: `Desa baru didaftarkan: ${form.name}` })
|
||||||
|
}).catch(console.error)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Village has been successfully registered.',
|
||||||
|
color: 'teal'
|
||||||
|
})
|
||||||
|
mutate() // Refresh list
|
||||||
|
closeCreateModal()
|
||||||
|
setForm({
|
||||||
|
name: '',
|
||||||
|
desc: '',
|
||||||
|
username: '',
|
||||||
|
phone: '',
|
||||||
|
nik: '',
|
||||||
|
email: '',
|
||||||
|
gender: ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to create village. Please try again.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Network Error',
|
||||||
|
message: 'Unable to reach API server.',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isDesaPlus) {
|
if (!isDesaPlus) {
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Container size="xl" py="xl">
|
||||||
@@ -298,11 +337,105 @@ function AppVillagesIndexPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
|
<Modal
|
||||||
|
opened={createModalOpened}
|
||||||
|
onClose={closeCreateModal}
|
||||||
|
title={<Text fw={700} size="lg">Register New Village</Text>}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Village Data
|
||||||
|
</Text>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
label="Village Name"
|
||||||
|
placeholder="e.g. Darmasaba"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Short description about the village..."
|
||||||
|
minRows={3}
|
||||||
|
required
|
||||||
|
value={form.desc}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider label="Village Head Information" labelPosition="center" my="sm" />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Head Name (Username)"
|
||||||
|
placeholder="Full name of village head"
|
||||||
|
required
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, username: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="NIK"
|
||||||
|
placeholder="16-digit identity number"
|
||||||
|
required
|
||||||
|
value={form.nik}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, nik: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
placeholder="Email address"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, email: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Phone"
|
||||||
|
placeholder="Active WhatsApp number"
|
||||||
|
required
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, phone: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Gender"
|
||||||
|
placeholder="Select gender"
|
||||||
|
data={['Male', 'Female']}
|
||||||
|
mt="sm"
|
||||||
|
required
|
||||||
|
value={form.gender}
|
||||||
|
onChange={(val) => setForm(prev => ({ ...prev, gender: val || '' }))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="lg"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={handleCreateVillage}
|
||||||
|
>
|
||||||
|
Create Village
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Title order={3}>Daftar Desa</Title>
|
<Title order={3}>Village List</Title>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{filtered.length} desa terdaftar dalam platform <strong>Desa+</strong>
|
{isLoading ? 'Loading data...' : `${response?.totalData || 0} villages registered in the Desa+ platform`}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button
|
<Button
|
||||||
@@ -310,17 +443,25 @@ function AppVillagesIndexPage() {
|
|||||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
leftSection={<TbPlus size={18} />}
|
leftSection={<TbPlus size={18} />}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
onClick={openCreateModal}
|
||||||
>
|
>
|
||||||
Tambah Desa
|
Create New Village
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Cari desa, kecamatan, kabupaten..."
|
placeholder="Search village, district, city..."
|
||||||
leftSection={<TbSearch size={16} />}
|
leftSection={<TbSearch size={16} />}
|
||||||
|
rightSection={
|
||||||
|
search ? (
|
||||||
|
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="sm">
|
||||||
|
<TbX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => handleSearchChange(e.currentTarget.value)}
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{ flex: 1, maxWidth: 400 }}
|
style={{ flex: 1, maxWidth: 400 }}
|
||||||
/>
|
/>
|
||||||
@@ -335,14 +476,27 @@ function AppVillagesIndexPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} withBorder radius="xl" padding="lg" style={{ height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Text c="dimmed">Loading...</Text>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : error ? (
|
||||||
|
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
||||||
|
<TbBuildingCommunity size={40} color="red" opacity={0.4} />
|
||||||
|
<Text c="red" mt="md">Failed to load data from API.</Text>
|
||||||
|
</Paper>
|
||||||
|
) : villages.length === 0 ? (
|
||||||
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
||||||
<TbBuildingCommunity size={40} color="gray" opacity={0.4} />
|
<TbBuildingCommunity size={40} color="gray" opacity={0.4} />
|
||||||
<Text c="dimmed" mt="md">Tidak ada desa yang cocok dengan pencarian.</Text>
|
<Text c="dimmed" mt="md">No villages match your search.</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : viewMode === 'grid' ? (
|
) : viewMode === 'grid' ? (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
{filtered.map((village) => (
|
{villages.map((village) => (
|
||||||
<VillageGridCard
|
<VillageGridCard
|
||||||
key={village.id}
|
key={village.id}
|
||||||
village={village}
|
village={village}
|
||||||
@@ -352,7 +506,7 @@ function AppVillagesIndexPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{filtered.map((village) => (
|
{villages.map((village) => (
|
||||||
<VillageListRow
|
<VillageListRow
|
||||||
key={village.id}
|
key={village.id}
|
||||||
village={village}
|
village={village}
|
||||||
@@ -361,6 +515,18 @@ function AppVillagesIndexPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && response?.totalPage > 0 && (
|
||||||
|
<Group justify="center" mt="xl">
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
total={response.totalPage}
|
||||||
|
radius="md"
|
||||||
|
withEdges={false}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core'
|
import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core'
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks'
|
||||||
|
import { useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { TbPlus, TbSearch } from 'react-icons/tb'
|
import { TbPlus, TbSearch } from 'react-icons/tb'
|
||||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||||
@@ -10,9 +12,12 @@ export const Route = createFileRoute('/apps/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function AppsPage() {
|
function AppsPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 300)
|
||||||
|
|
||||||
const { data: apps, isLoading } = useQuery({
|
const { data: apps, isLoading } = useQuery({
|
||||||
queryKey: ['apps'],
|
queryKey: ['apps', debouncedSearch],
|
||||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
queryFn: () => fetch(`/api/apps?search=${encodeURIComponent(debouncedSearch)}`).then((r) => r.json()),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,14 +29,14 @@ function AppsPage() {
|
|||||||
<Title order={2} className="gradient-text">Applications</Title>
|
<Title order={2} className="gradient-text">Applications</Title>
|
||||||
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
|
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button
|
{/* <Button
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
leftSection={<TbPlus size={18} />}
|
leftSection={<TbPlus size={18} />}
|
||||||
radius="md"
|
radius="md"
|
||||||
>
|
>
|
||||||
Add New Application
|
Add New Application
|
||||||
</Button>
|
</Button> */}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
@@ -40,6 +45,8 @@ function AppsPage() {
|
|||||||
leftSection={<TbSearch size={16} />}
|
leftSection={<TbSearch size={16} />}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
701
src/frontend/routes/bug-reports.tsx
Normal file
701
src/frontend/routes/bug-reports.tsx
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||||
|
import { API_URLS } from '@/frontend/config/api'
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Code,
|
||||||
|
Collapse,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
Title,
|
||||||
|
Timeline,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
|
import {
|
||||||
|
TbAlertTriangle,
|
||||||
|
TbBug,
|
||||||
|
TbDeviceDesktop,
|
||||||
|
TbDeviceMobile,
|
||||||
|
TbFilter,
|
||||||
|
TbSearch,
|
||||||
|
TbHistory,
|
||||||
|
TbPhoto,
|
||||||
|
TbPlus,
|
||||||
|
TbCircleCheck,
|
||||||
|
TbCircleX,
|
||||||
|
} from 'react-icons/tb'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/bug-reports')({
|
||||||
|
component: ListErrorsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ListErrorsPage() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [app, setApp] = useState('all')
|
||||||
|
const [status, setStatus] = useState('all')
|
||||||
|
|
||||||
|
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const toggleLogs = (bugId: string) => {
|
||||||
|
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['bugs', { page, search, app, status }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch apps for the dropdown
|
||||||
|
const { data: appsList } = useQuery({
|
||||||
|
queryKey: ['apps-list'],
|
||||||
|
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create Bug Modal Logic
|
||||||
|
const [opened, { open, close }] = useDisclosure(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
description: '',
|
||||||
|
app: 'desa-plus',
|
||||||
|
status: 'OPEN',
|
||||||
|
source: 'USER',
|
||||||
|
affectedVersion: '',
|
||||||
|
device: '',
|
||||||
|
os: '',
|
||||||
|
stackTrace: '',
|
||||||
|
imageUrl: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update Status Modal Logic
|
||||||
|
const [updateModalOpened, { open: openUpdateModal, close: closeUpdateModal }] = useDisclosure(false)
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false)
|
||||||
|
const [selectedBugId, setSelectedBugId] = useState<string | null>(null)
|
||||||
|
const [updateForm, setUpdateForm] = useState({
|
||||||
|
status: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Feedback Modal Logic
|
||||||
|
const [feedbackModalOpened, { open: openFeedbackModal, close: closeFeedbackModal }] = useDisclosure(false)
|
||||||
|
const [isUpdatingFeedback, setIsUpdatingFeedback] = useState(false)
|
||||||
|
const [feedbackForm, setFeedbackForm] = useState({
|
||||||
|
feedBack: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleUpdateFeedback = async () => {
|
||||||
|
if (!selectedBugId || !feedbackForm.feedBack) return
|
||||||
|
|
||||||
|
setIsUpdatingFeedback(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.updateBugFeedback(selectedBugId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(feedbackForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Feedback has been updated.',
|
||||||
|
color: 'teal',
|
||||||
|
icon: <TbCircleCheck size={18} />,
|
||||||
|
})
|
||||||
|
refetch()
|
||||||
|
closeFeedbackModal()
|
||||||
|
setFeedbackForm({ feedBack: '' })
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to update feedback')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
color: 'red',
|
||||||
|
icon: <TbCircleX size={18} />,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingFeedback(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStatus = async () => {
|
||||||
|
if (!selectedBugId || !updateForm.status) return
|
||||||
|
|
||||||
|
setIsUpdating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.updateBugStatus(selectedBugId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updateForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Status has been updated.',
|
||||||
|
color: 'teal',
|
||||||
|
icon: <TbCircleCheck size={18} />,
|
||||||
|
})
|
||||||
|
refetch()
|
||||||
|
closeUpdateModal()
|
||||||
|
setUpdateForm({ status: '', description: '' })
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to update status')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
color: 'red',
|
||||||
|
icon: <TbCircleX size={18} />,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateBug = async () => {
|
||||||
|
if (!createForm.description || !createForm.affectedVersion || !createForm.device || !createForm.os) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Validation Error',
|
||||||
|
message: 'Please fill in all required fields.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.createBug(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(createForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await fetch(API_URLS.createLog(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'CREATE', message: `Report error baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` })
|
||||||
|
}).catch(console.error)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Error report has been created.',
|
||||||
|
color: 'teal',
|
||||||
|
icon: <TbCircleCheck size={18} />,
|
||||||
|
})
|
||||||
|
refetch()
|
||||||
|
close()
|
||||||
|
setCreateForm({
|
||||||
|
description: '',
|
||||||
|
app: 'desa-plus',
|
||||||
|
status: 'OPEN',
|
||||||
|
source: 'USER',
|
||||||
|
affectedVersion: '',
|
||||||
|
device: '',
|
||||||
|
os: '',
|
||||||
|
stackTrace: '',
|
||||||
|
imageUrl: '',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to create error report')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
color: 'red',
|
||||||
|
icon: <TbCircleX size={18} />,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bugs = data?.data || []
|
||||||
|
const totalPages = data?.totalPages || 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container size="xl" py="lg">
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Title order={2} className="gradient-text">
|
||||||
|
Error Reports
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Centralized error tracking and analysis for all applications.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
leftSection={<TbPlus size={18} />}
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
Report Error
|
||||||
|
</Button>
|
||||||
|
{/* <Button variant="light" color="red" leftSection={<TbBug size={16} />}>
|
||||||
|
Generate Report
|
||||||
|
</Button> */}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={updateModalOpened}
|
||||||
|
onClose={closeUpdateModal}
|
||||||
|
title={<Text fw={700} size="lg">Update Bug Status</Text>}
|
||||||
|
radius="xl"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="New Status"
|
||||||
|
placeholder="Select status"
|
||||||
|
required
|
||||||
|
data={[
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
{ value: 'RESOLVED', label: 'Resolved' },
|
||||||
|
{ value: 'RELEASED', label: 'Released' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' },
|
||||||
|
]}
|
||||||
|
value={updateForm.status}
|
||||||
|
onChange={(val) => setUpdateForm({ ...updateForm, status: val || '' })}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Update Note (Optional)"
|
||||||
|
placeholder="E.g. Fixed in commit xxxxx / Assigned to team"
|
||||||
|
minRows={3}
|
||||||
|
value={updateForm.description}
|
||||||
|
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isUpdating}
|
||||||
|
onClick={handleUpdateStatus}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={feedbackModalOpened}
|
||||||
|
onClose={closeFeedbackModal}
|
||||||
|
title={<Text fw={700} size="lg">Developer Feedback</Text>}
|
||||||
|
radius="xl"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Textarea
|
||||||
|
data-autofocus
|
||||||
|
label="Feedback / Note"
|
||||||
|
placeholder="Explain the issue, root cause, or resolution..."
|
||||||
|
required
|
||||||
|
minRows={4}
|
||||||
|
value={feedbackForm.feedBack}
|
||||||
|
onChange={(e) => setFeedbackForm({ ...feedbackForm, feedBack: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isUpdatingFeedback}
|
||||||
|
onClick={handleUpdateFeedback}
|
||||||
|
>
|
||||||
|
Save Feedback
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={<Text fw={700} size="lg">Report New Error</Text>}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="What happened? Describe the error in detail..."
|
||||||
|
required
|
||||||
|
minRows={3}
|
||||||
|
value={createForm.description}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<Select
|
||||||
|
label="Application"
|
||||||
|
data={appsList?.map((a: any) => ({ value: a.id, label: a.name })) || []}
|
||||||
|
value={createForm.app}
|
||||||
|
onChange={(val) => setCreateForm({ ...createForm, app: val as any })}
|
||||||
|
placeholder="Select application"
|
||||||
|
disabled={!appsList}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Source"
|
||||||
|
data={[
|
||||||
|
{ value: 'USER', label: 'User' },
|
||||||
|
{ value: 'QC', label: 'QC' },
|
||||||
|
{ value: 'SYSTEM', label: 'System' },
|
||||||
|
]}
|
||||||
|
value={createForm.source}
|
||||||
|
onChange={(val) => setCreateForm({ ...createForm, source: val as any })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<TextInput
|
||||||
|
label="Version"
|
||||||
|
placeholder="e.g. 2.4.1"
|
||||||
|
required
|
||||||
|
value={createForm.affectedVersion}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Initial Status"
|
||||||
|
data={[
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
]}
|
||||||
|
value={createForm.status}
|
||||||
|
onChange={(val) => setCreateForm({ ...createForm, status: val as any })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<TextInput
|
||||||
|
label="Device"
|
||||||
|
placeholder="e.g. iPhone 13, Windows 11 PC"
|
||||||
|
required
|
||||||
|
value={createForm.device}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, device: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="OS"
|
||||||
|
placeholder="e.g. iOS 15.4, Windows 11"
|
||||||
|
required
|
||||||
|
value={createForm.os}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, os: e.target.value })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Image URL (Optional)"
|
||||||
|
placeholder="https://example.com/screenshot.png"
|
||||||
|
value={createForm.imageUrl}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Stack Trace (Optional)"
|
||||||
|
placeholder="Paste code or error logs here..."
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
minRows={2}
|
||||||
|
value={createForm.stackTrace}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, stackTrace: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={handleCreateBug}
|
||||||
|
>
|
||||||
|
Submit Error Report
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 4 }} mb="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search description, device, os..."
|
||||||
|
leftSection={<TbSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Application"
|
||||||
|
data={[
|
||||||
|
{ value: 'all', label: 'All Applications' },
|
||||||
|
...(appsList?.map((a: any) => ({ value: a.id, label: a.name })) || []),
|
||||||
|
]}
|
||||||
|
value={app}
|
||||||
|
onChange={(val) => setApp(val || 'all')}
|
||||||
|
radius="md"
|
||||||
|
disabled={!appsList}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
data={[
|
||||||
|
{ value: 'all', label: 'All Status' },
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
{ value: 'RESOLVED', label: 'Resolved' },
|
||||||
|
{ value: 'RELEASED', label: 'Released' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' },
|
||||||
|
]}
|
||||||
|
value={status}
|
||||||
|
onChange={(val) => setStatus(val || 'all')}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="subtle" color="gray" leftSection={<TbFilter size={16} />} onClick={() => {setSearch(''); setApp('all'); setStatus('all')}}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack align="center" py="xl">
|
||||||
|
<Loader size="lg" type="dots" />
|
||||||
|
<Text size="sm" c="dimmed">Loading error reports...</Text>
|
||||||
|
</Stack>
|
||||||
|
) : bugs.length === 0 ? (
|
||||||
|
<Paper p="xl" withBorder style={{ borderStyle: 'dashed', textAlign: 'center' }}>
|
||||||
|
<TbBug size={48} color="gray" style={{ marginBottom: 12, opacity: 0.5 }} />
|
||||||
|
<Text fw={600}>No error reports found</Text>
|
||||||
|
<Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Accordion variant="separated" radius="xl">
|
||||||
|
{bugs.map((bug: any) => (
|
||||||
|
<Accordion.Item
|
||||||
|
key={bug.id}
|
||||||
|
value={bug.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--mantine-color-default-border)',
|
||||||
|
background: 'var(--mantine-color-default)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Accordion.Control>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<ThemeIcon
|
||||||
|
color={
|
||||||
|
bug.status === 'OPEN'
|
||||||
|
? 'red'
|
||||||
|
: bug.status === 'IN_PROGRESS'
|
||||||
|
? 'blue'
|
||||||
|
: 'teal'
|
||||||
|
}
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<TbAlertTriangle size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{bug.description}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
color={
|
||||||
|
bug.status === 'OPEN'
|
||||||
|
? 'red'
|
||||||
|
: bug.status === 'IN_PROGRESS'
|
||||||
|
? 'blue'
|
||||||
|
: 'teal'
|
||||||
|
}
|
||||||
|
variant="dot"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{bug.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{new Date(bug.createdAt).toLocaleString()} • {bug.app?.toUpperCase()} • v{bug.affectedVersion}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack gap="lg" py="xs">
|
||||||
|
{/* Device Info */}
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{bug.device.toLowerCase().includes('windows') || bug.device.toLowerCase().includes('mac') || bug.device.toLowerCase().includes('pc') ? (
|
||||||
|
<TbDeviceDesktop size={14} color="gray" />
|
||||||
|
) : (
|
||||||
|
<TbDeviceMobile size={14} color="gray" />
|
||||||
|
)}
|
||||||
|
<Text size="xs" fw={500}>{bug.device} ({bug.os})</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>SOURCE</Text>
|
||||||
|
<Badge variant="light" color="gray" size="sm">{bug.source}</Badge>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Feedback & Reporter Info */}
|
||||||
|
{(bug.user || bug.feedBack) && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
|
{bug.user && (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTED BY</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Avatar src={bug.user.image} radius="xl" size="sm" color="blue">
|
||||||
|
{bug.user.name?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Text size="sm">{bug.user.name}</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{bug.feedBack && (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVELOPER FEEDBACK</Text>
|
||||||
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{bug.feedBack}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stack Trace */}
|
||||||
|
{bug.stackTrace && (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
color="red"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: '11px',
|
||||||
|
border: '1px solid var(--mantine-color-default-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bug.stackTrace}
|
||||||
|
</Code>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{bug.images && bug.images.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs" mb={8}>
|
||||||
|
<TbPhoto size={16} color="gray" />
|
||||||
|
<Text size="xs" fw={700} c="dimmed">ATTACHED IMAGES ({bug.images.length})</Text>
|
||||||
|
</Group>
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
|
||||||
|
{bug.images.map((img: any) => (
|
||||||
|
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
|
||||||
|
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs / History */}
|
||||||
|
{bug.logs && bug.logs.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb={showLogs[bug.id] ? 12 : 0}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<TbHistory size={16} color="gray" />
|
||||||
|
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG ({bug.logs.length})</Text>
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-xs"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => toggleLogs(bug.id)}
|
||||||
|
>
|
||||||
|
{showLogs[bug.id] ? 'Hide' : 'Show'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Collapse in={showLogs[bug.id]}>
|
||||||
|
<Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2} mt="md">
|
||||||
|
{bug.logs.map((log: any) => (
|
||||||
|
<Timeline.Item
|
||||||
|
key={log.id}
|
||||||
|
bullet={
|
||||||
|
<Badge size="xs" circle color={log.status === 'RESOLVED' ? 'teal' : 'blue'}> </Badge>
|
||||||
|
}
|
||||||
|
title={<Text size="sm" fw={600}>{log.status}</Text>}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed" mb={4}>
|
||||||
|
{new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{log.description}</Text>
|
||||||
|
</Timeline.Item>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" pt="sm">
|
||||||
|
<Button variant="light" size="compact-xs" color="blue" onClick={() => {
|
||||||
|
setSelectedBugId(bug.id)
|
||||||
|
setFeedbackForm({ feedBack: bug.feedBack || '' })
|
||||||
|
openFeedbackModal()
|
||||||
|
}}>Developer Feedback</Button>
|
||||||
|
<Button variant="light" size="compact-xs" color="teal" onClick={() => {
|
||||||
|
setSelectedBugId(bug.id)
|
||||||
|
setUpdateForm({ status: bug.status, description: '' })
|
||||||
|
openUpdateModal()
|
||||||
|
}}>Update Status</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Group justify="center" mt="xl">
|
||||||
|
<Pagination total={totalPages} value={page} onChange={setPage} radius="xl" />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { AppCard } from '@/frontend/components/AppCard'
|
||||||
|
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||||
|
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||||
|
import { useSession } from '@/frontend/hooks/useAuth'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
|
Table,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
Paper,
|
|
||||||
Table,
|
|
||||||
Loader,
|
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { createFileRoute, redirect, Link } from '@tanstack/react-router'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { TbActivity, TbApps, TbMessageReport, TbUsers, TbChevronRight } from 'react-icons/tb'
|
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
|
||||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
import { TbApps, TbChevronRight, TbMessageReport, TbUsers } from 'react-icons/tb'
|
||||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
|
||||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
|
||||||
import { AppCard } from '@/frontend/components/AppCard'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/dashboard')({
|
export const Route = createFileRoute('/dashboard')({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
@@ -27,7 +27,6 @@ export const Route = createFileRoute('/dashboard')({
|
|||||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||||
})
|
})
|
||||||
if (!data?.user) throw redirect({ to: '/login' })
|
if (!data?.user) throw redirect({ to: '/login' })
|
||||||
if (data.user.role !== 'SUPER_ADMIN') throw redirect({ to: '/profile' })
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) throw redirect({ to: '/login' })
|
if (e instanceof Error) throw redirect({ to: '/login' })
|
||||||
throw e
|
throw e
|
||||||
@@ -36,12 +35,6 @@ export const Route = createFileRoute('/dashboard')({
|
|||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const recentErrors = [
|
|
||||||
{ id: 1, app: 'Desa+', message: 'NullPointerException at village_sync.dart:45', version: '2.4.1', time: '2 mins ago', severity: 'critical' },
|
|
||||||
{ id: 2, app: 'E-Commerce', message: 'Failed to load checkout session', version: '1.8.0', time: '15 mins ago', severity: 'high' },
|
|
||||||
{ id: 3, app: 'Fitness App', message: 'SocketException: Connection timed out', version: '0.9.5', time: '1 hour ago', severity: 'medium' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const { data: sessionData } = useSession()
|
const { data: sessionData } = useSession()
|
||||||
const user = sessionData?.user
|
const user = sessionData?.user
|
||||||
@@ -56,6 +49,20 @@ function DashboardPage() {
|
|||||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: recentErrors = [], isLoading: recentErrorsLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'recent-errors'],
|
||||||
|
queryFn: () => fetch('/api/dashboard/recent-errors').then((r) => r.json()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatTimeAgo = (dateStr: string) => {
|
||||||
|
const diff = new Date().getTime() - new Date(dateStr).getTime()
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 60) return `${minutes || 1} mins ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} hours ago`
|
||||||
|
return `${Math.floor(hours / 24)} days ago`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container size="xl" py="lg">
|
<Container size="xl" py="lg">
|
||||||
@@ -65,7 +72,7 @@ function DashboardPage() {
|
|||||||
<Title order={2} className="gradient-text">Overview Dashboard</Title>
|
<Title order={2} className="gradient-text">Overview Dashboard</Title>
|
||||||
<Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text>
|
<Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button
|
{/* <Button
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
leftSection={<TbApps size={18} />}
|
leftSection={<TbApps size={18} />}
|
||||||
@@ -74,7 +81,7 @@ function DashboardPage() {
|
|||||||
to="/apps"
|
to="/apps"
|
||||||
>
|
>
|
||||||
Manage All Apps
|
Manage All Apps
|
||||||
</Button>
|
</Button> */}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
@@ -86,29 +93,29 @@ function DashboardPage() {
|
|||||||
value={stats?.totalApps || 0}
|
value={stats?.totalApps || 0}
|
||||||
icon={TbApps}
|
icon={TbApps}
|
||||||
color="brand-blue"
|
color="brand-blue"
|
||||||
trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
|
// trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="New Errors"
|
title="New Errors"
|
||||||
value={stats?.newErrors || 0}
|
value={stats?.newErrors || 0}
|
||||||
icon={TbMessageReport}
|
icon={TbMessageReport}
|
||||||
color="brand-purple"
|
color="brand-purple"
|
||||||
trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
|
// trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Active Users"
|
title="Users"
|
||||||
value={stats?.activeUsers || 0}
|
value={stats?.activeUsers || 0}
|
||||||
icon={TbUsers}
|
icon={TbUsers}
|
||||||
color="teal"
|
color="teal"
|
||||||
trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
|
// trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="space-between" mt="md">
|
<Group justify="space-between" mt="md">
|
||||||
<Title order={3}>Registered Applications</Title>
|
<Title order={3}>Registered Applications</Title>
|
||||||
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
|
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />} component={Link} to="/apps">
|
||||||
View Report
|
View All Apps
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -124,7 +131,7 @@ function DashboardPage() {
|
|||||||
|
|
||||||
<Group justify="space-between" mt="md">
|
<Group justify="space-between" mt="md">
|
||||||
<Title order={3}>Recent Error Reports</Title>
|
<Title order={3}>Recent Error Reports</Title>
|
||||||
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
|
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />} component={Link} to="/bug-reports">
|
||||||
View All Errors
|
View All Errors
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -141,23 +148,35 @@ function DashboardPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{recentErrors.map((error) => (
|
{recentErrorsLoading ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5} align="center" py="xl">
|
||||||
|
<Loader size="sm" type="dots" />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : recentErrors.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5} align="center" py="xl">
|
||||||
|
<Text c="dimmed" size="sm">No recent errors found.</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : recentErrors.map((error: any) => (
|
||||||
<Table.Tr key={error.id}>
|
<Table.Tr key={error.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fw={600} size="sm">{error.app}</Text>
|
<Text fw={600} size="sm" style={{ textTransform: 'uppercase' }}>{error.app}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" c="dimmed">{error.message}</Text>
|
<Text size="sm" c="dimmed" lineClamp={1}>{error.message}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge variant="light" color="gray">v{error.version}</Badge>
|
<Badge variant="light" color="gray">v{error.version}</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" c="dimmed">{error.time}</Text>
|
<Text size="xs" c="dimmed">{formatTimeAgo(error.time)}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
|
color={error.severity === 'OPEN' ? 'red' : error.severity === 'IN_PROGRESS' || error.severity === 'ON_HOLD' ? 'orange' : 'yellow'}
|
||||||
variant="dot"
|
variant="dot"
|
||||||
>
|
>
|
||||||
{error.severity.toUpperCase()}
|
{error.severity.toUpperCase()}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLogin } from '@/frontend/hooks/useAuth'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -13,8 +14,7 @@ import {
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FcGoogle } from 'react-icons/fc'
|
import { FcGoogle } from 'react-icons/fc'
|
||||||
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
|
import { TbAlertCircle, TbLock, TbLogin, TbMail } from 'react-icons/tb'
|
||||||
import { useLogin } from '@/frontend/hooks/useAuth'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/login')({
|
export const Route = createFileRoute('/login')({
|
||||||
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
|
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
|
||||||
@@ -27,7 +27,7 @@ export const Route = createFileRoute('/login')({
|
|||||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||||
})
|
})
|
||||||
if (data?.user) {
|
if (data?.user) {
|
||||||
throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' })
|
throw redirect({ to: '/dashboard' })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) return
|
if (e instanceof Error) return
|
||||||
@@ -57,15 +57,9 @@ function LoginPage() {
|
|||||||
Login
|
Login
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Text c="dimmed" size="sm" ta="center">
|
|
||||||
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
|
|
||||||
<br />
|
|
||||||
atau: <strong>user@example.com</strong> / <strong>user123</strong>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{(login.isError || searchError) && (
|
{(login.isError || searchError) && (
|
||||||
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
||||||
{login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'}
|
{login.isError ? login.error.message : 'Google login failed, please try again.'}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -95,18 +89,6 @@ function LoginPage() {
|
|||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Divider label="atau" labelPosition="center" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href="/api/auth/google"
|
|
||||||
fullWidth
|
|
||||||
variant="default"
|
|
||||||
leftSection={<FcGoogle size={18} />}
|
|
||||||
>
|
|
||||||
Login dengan Google
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -10,67 +10,119 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Divider,
|
Divider,
|
||||||
|
Pagination,
|
||||||
|
Center,
|
||||||
|
Tooltip,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
|
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
|
||||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { API_URLS } from '../config/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/logs')({
|
export const Route = createFileRoute('/logs')({
|
||||||
component: GlobalLogsPage,
|
component: GlobalLogsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const timelineData = [
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
{
|
|
||||||
date: 'TODAY',
|
const typeConfig: Record<string, { color: string; icon?: any }> = {
|
||||||
logs: [
|
CREATE: { color: 'blue', icon: TbCheck },
|
||||||
{ id: 1, time: '12:12 PM', operator: 'Budi Santoso', app: 'Desa+', color: 'blue', content: <>generated document <Badge variant="light" color="gray" radius="sm">Surat Domisili</Badge> for <Badge variant="light" color="blue" radius="sm">Sukatani</Badge></> },
|
UPDATE: { color: 'teal', icon: TbCheck },
|
||||||
{ id: 2, time: '11:42 AM', operator: 'Siti Aminah', app: 'Desa+', color: 'teal', content: <>uploaded financial report <Badge variant="light" color="gray" radius="sm">Realisasi Q1</Badge> for <Badge variant="light" color="teal" radius="sm">Sukamaju</Badge></> },
|
DELETE: { color: 'red', icon: TbX },
|
||||||
{ id: 3, time: '10:12 AM', operator: 'System', app: 'Desa+', color: 'red', icon: TbX, content: <>experienced failure in <Badge variant="light" color="violet" radius="sm">SIAK Sync</Badge> at <Badge variant="light" color="red" radius="sm" leftSection={<TbX size={12}/>}>Cikini</Badge></>, message: { title: 'Sync Operation Failed (NullPointerException)', text: 'NullPointerException at village_sync.dart:45. The server returned a timeout error while waiting for the master database replica connection. Auto-retry scheduled in 15 minutes.' } },
|
LOGIN: { color: 'green', icon: TbClock },
|
||||||
{ id: 4, time: '09:42 AM', operator: 'Jane Smith', app: 'E-Commerce', color: 'orange', icon: TbCheck, content: <>resolved payment gateway issue for <Badge variant="light" color="orange" radius="sm">E-Commerce</Badge> checkout</> },
|
LOGOUT: { color: 'orange', icon: TbClock },
|
||||||
]
|
}
|
||||||
},
|
|
||||||
{
|
const getRoleColor = (role: string) => {
|
||||||
date: 'YESTERDAY',
|
const r = (role || '').toLowerCase()
|
||||||
logs: [
|
if (r.includes('super')) return 'red'
|
||||||
{ id: 5, time: '05:10 AM', operator: 'System', app: 'System', color: 'cyan', content: <>completed automated <Badge variant="light" color="cyan" radius="sm">Nightly Backup</Badge> for all 138 villages</> },
|
if (r.includes('admin')) return 'brand-blue'
|
||||||
{ id: 6, time: '04:50 AM', operator: 'Rahmat Hidayat', app: 'Desa+', color: 'green', content: <>granted Admin access to <Text component="span" fw={600}>Desa Bojong Gede</Text> operator</> },
|
if (r.includes('developer')) return 'violet'
|
||||||
{ id: 7, time: '03:42 AM', operator: 'System', app: 'Fitness App', color: 'red', icon: TbX, content: <>detected SocketException across <Badge variant="light" color="violet" radius="sm">Fitness App</Badge> wearable sync operations.</> },
|
return 'gray'
|
||||||
{ id: 8, time: '02:33 AM', operator: 'Agus Setiawan', app: 'Desa+', color: 'blue', content: <>verified 145 <Badge variant="light" color="gray" radius="sm">Surat Kematian</Badge> entries in batch.</> },
|
}
|
||||||
]
|
|
||||||
},
|
function groupLogsByDate(logs: any[]) {
|
||||||
{
|
const groups: Record<string, any[]> = {}
|
||||||
date: '12 APRIL, 2026',
|
|
||||||
logs: [
|
const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||||
{ id: 9, time: '03:42 AM', operator: 'Amel', app: 'Desa+', color: 'indigo', content: <>changed version configurations rolling out <Badge variant="light" color="gray" radius="sm">Desa+ v2.4.1</Badge></> },
|
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||||
{ id: 10, time: '02:10 AM', operator: 'John Doe', app: 'E-Commerce', color: 'pink', content: <>updated App setting <Badge variant="light" color="gray" radius="sm">Require OTP on Login</Badge> <Text component="span" c="violet" fw={600} size="sm" style={{ cursor: 'pointer' }}>View Details</Text></> },
|
|
||||||
]
|
logs.forEach(log => {
|
||||||
}
|
const dateObj = new Date(log.createdAt)
|
||||||
]
|
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||||
|
|
||||||
|
if (dateStr === today) dateStr = 'TODAY'
|
||||||
|
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
|
||||||
|
|
||||||
|
if (!groups[dateStr]) groups[dateStr] = []
|
||||||
|
|
||||||
|
const timeStr = dateObj.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
groups[dateStr].push({
|
||||||
|
id: log.id,
|
||||||
|
time: timeStr,
|
||||||
|
user: log.user,
|
||||||
|
type: log.type,
|
||||||
|
content: log.message,
|
||||||
|
color: log.user ? getRoleColor(log.user.role) : 'gray',
|
||||||
|
icon: typeConfig[log.type as string]?.icon
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// We want to keep the order as they came from the API (sorted by createdAt desc)
|
||||||
|
// but grouped by date. Object.entries might mess up the order if dates are not sequential.
|
||||||
|
// However, since the source logs are sorted, the first encounter of a date defines the group order.
|
||||||
|
const result: { date: string; logs: any[] }[] = []
|
||||||
|
const seenDates = new Set<string>()
|
||||||
|
|
||||||
|
logs.forEach(log => {
|
||||||
|
const dateObj = new Date(log.createdAt)
|
||||||
|
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
|
||||||
|
if (dateStr === today) dateStr = 'TODAY'
|
||||||
|
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
|
||||||
|
|
||||||
|
if (!seenDates.has(dateStr)) {
|
||||||
|
result.push({ date: dateStr, logs: groups[dateStr] })
|
||||||
|
seenDates.add(dateStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function GlobalLogsPage() {
|
function GlobalLogsPage() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [appFilter, setAppFilter] = useState<string | null>(null)
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
const [operatorFilter, setOperatorFilter] = useState<string | null>(null)
|
const [logType, setLogType] = useState<string | null>('all')
|
||||||
|
const [operatorId, setOperatorId] = useState<string | null>('all')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedSearch(search), 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher)
|
||||||
|
|
||||||
|
const operatorOptions = useMemo(() => {
|
||||||
|
if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All Operators' }]
|
||||||
|
return [
|
||||||
|
{ value: 'all', label: 'All Operators' },
|
||||||
|
...operatorsData.map((op: any) => ({ value: op.id, label: op.name }))
|
||||||
|
]
|
||||||
|
}, [operatorsData])
|
||||||
|
|
||||||
|
const { data: response, isLoading } = useSWR(
|
||||||
|
API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'),
|
||||||
|
fetcher
|
||||||
|
)
|
||||||
|
|
||||||
const filteredTimeline = useMemo(() => {
|
const filteredTimeline = useMemo(() => {
|
||||||
return timelineData
|
if (!response?.data) return []
|
||||||
.map(group => {
|
return groupLogsByDate(response.data)
|
||||||
const filteredLogs = group.logs.filter(log => {
|
}, [response?.data])
|
||||||
if (appFilter && log.app !== appFilter) return false;
|
|
||||||
if (operatorFilter && log.operator !== operatorFilter) return false;
|
|
||||||
if (search) {
|
|
||||||
const lSearch = search.toLowerCase();
|
|
||||||
if (!log.operator.toLowerCase().includes(lSearch) && !log.app.toLowerCase().includes(lSearch)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return { ...group, logs: filteredLogs };
|
|
||||||
})
|
|
||||||
.filter(group => group.logs.length > 0);
|
|
||||||
}, [search, appFilter, operatorFilter]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -79,134 +131,156 @@ function GlobalLogsPage() {
|
|||||||
{/* Header Controls */}
|
{/* Header Controls */}
|
||||||
<Group mb="xl" gap="md">
|
<Group mb="xl" gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search operator or app..."
|
placeholder="Search operator or message..."
|
||||||
leftSection={<TbSearch size={16} />}
|
leftSection={<TbSearch size={16} />}
|
||||||
radius="md"
|
radius="md"
|
||||||
w={220}
|
w={250}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => {
|
||||||
|
setSearch(e.currentTarget.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="All Applications"
|
placeholder="Log Type"
|
||||||
data={['Desa+', 'E-Commerce', 'Fitness App', 'System']}
|
data={[
|
||||||
|
{ value: 'all', label: 'All Types' },
|
||||||
|
{ value: 'CREATE', label: 'Create' },
|
||||||
|
{ value: 'UPDATE', label: 'Update' },
|
||||||
|
{ value: 'DELETE', label: 'Delete' },
|
||||||
|
{ value: 'LOGIN', label: 'Login' },
|
||||||
|
{ value: 'LOGOUT', label: 'Logout' },
|
||||||
|
]}
|
||||||
radius="md"
|
radius="md"
|
||||||
w={160}
|
w={160}
|
||||||
clearable
|
value={logType}
|
||||||
value={appFilter}
|
onChange={(val) => {
|
||||||
onChange={setAppFilter}
|
setLogType(val)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="All Operators"
|
placeholder="Operator"
|
||||||
data={['Agus Setiawan', 'Amel', 'Budi Santoso', 'Jane Smith', 'John Doe', 'Rahmat Hidayat', 'Siti Aminah', 'System']}
|
data={operatorOptions}
|
||||||
|
searchable
|
||||||
radius="md"
|
radius="md"
|
||||||
w={160}
|
w={200}
|
||||||
clearable
|
value={operatorId}
|
||||||
value={operatorFilter}
|
onChange={(val) => {
|
||||||
onChange={setOperatorFilter}
|
setOperatorId(val)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Timeline Content */}
|
{/* Timeline Content */}
|
||||||
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)' }}>
|
<Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
|
||||||
{filteredTimeline.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed">Loading logs...</Text>
|
||||||
|
</Center>
|
||||||
|
) : filteredTimeline.length === 0 ? (
|
||||||
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
|
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
|
||||||
) : filteredTimeline.map((group, groupIndex) => (
|
) : (
|
||||||
<Box key={group.date}>
|
<>
|
||||||
<Text
|
{filteredTimeline.map((group, groupIndex) => (
|
||||||
size="xs"
|
<Box key={group.date}>
|
||||||
fw={700}
|
<Text
|
||||||
c="dimmed"
|
size="xs"
|
||||||
mt={groupIndex > 0 ? "xl" : 0}
|
fw={700}
|
||||||
mb="lg"
|
c="dimmed"
|
||||||
style={{ textTransform: 'uppercase' }}
|
mt={groupIndex > 0 ? "xl" : 0}
|
||||||
>
|
mb="md"
|
||||||
{group.date}
|
style={{ textTransform: 'uppercase' }}
|
||||||
</Text>
|
>
|
||||||
|
{group.date}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Stack gap={0} pl={4}>
|
<Stack gap={0} pl={4}>
|
||||||
{group.logs.map((log, logIndex) => {
|
{group.logs.map((log, logIndex) => {
|
||||||
const isLastLog = logIndex === group.logs.length - 1;
|
const isLastLog = logIndex === group.logs.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
key={log.id}
|
key={log.id}
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
align="flex-start"
|
align="flex-start"
|
||||||
gap="lg"
|
gap="lg"
|
||||||
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
|
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
|
||||||
>
|
>
|
||||||
{/* Left: Time */}
|
{/* Left: Time */}
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
w={70}
|
w={70}
|
||||||
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
|
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
|
||||||
>
|
|
||||||
{log.time}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Middle: Line & Avatar */}
|
|
||||||
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
|
|
||||||
{/* Vertical Line */}
|
|
||||||
{!isLastLog && (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 24,
|
|
||||||
bottom: -8,
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
width: 1,
|
|
||||||
backgroundColor: 'rgba(128,128,128,0.2)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Avatar */}
|
|
||||||
<Box style={{ position: 'relative', zIndex: 2 }}>
|
|
||||||
{log.icon ? (
|
|
||||||
<Avatar size={24} radius="xl" color={log.color} variant="light">
|
|
||||||
<log.icon size={14} />
|
|
||||||
</Avatar>
|
|
||||||
) : (
|
|
||||||
<Avatar size={24} radius="xl" color={log.color}>
|
|
||||||
{log.operator.charAt(0)}
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Right: Content */}
|
|
||||||
<Box style={{ flexGrow: 1, marginTop: 2 }}>
|
|
||||||
<Text size="sm">
|
|
||||||
<Text component="span" fw={600} mr={4}>{log.operator}</Text>
|
|
||||||
{log.content}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{log.message && (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
p="md"
|
|
||||||
radius="md"
|
|
||||||
mt="sm"
|
|
||||||
style={{ maxWidth: 800, backgroundColor: 'transparent' }}
|
|
||||||
>
|
>
|
||||||
<Text size="sm" fw={600} mb={4}>{log.message.title}</Text>
|
{log.time}
|
||||||
<Text size="sm" c="dimmed">
|
</Text>
|
||||||
{log.message.text}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{groupIndex < timelineData.length - 1 && (
|
{/* Middle: Line & Avatar */}
|
||||||
<Divider my="xl" color="rgba(128,128,128,0.1)" />
|
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
|
||||||
|
{/* Vertical Line */}
|
||||||
|
{!isLastLog && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 24,
|
||||||
|
bottom: -8,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: 1,
|
||||||
|
backgroundColor: 'rgba(128,128,128,0.2)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Avatar */}
|
||||||
|
<Box style={{ position: 'relative', zIndex: 2 }}>
|
||||||
|
<Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
|
||||||
|
<Avatar
|
||||||
|
size={24}
|
||||||
|
radius="xl"
|
||||||
|
color={log.color}
|
||||||
|
variant="light"
|
||||||
|
src={log.user?.image}
|
||||||
|
style={{ cursor: 'help' }}
|
||||||
|
>
|
||||||
|
{log.icon ? <log.icon size={14} /> : (log.user?.name?.charAt(0) || '?')}
|
||||||
|
</Avatar>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right: Content */}
|
||||||
|
<Box style={{ flexGrow: 1, marginTop: 2 }}>
|
||||||
|
<Text size="sm">
|
||||||
|
<Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
|
||||||
|
{log.content}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{groupIndex < filteredTimeline.length - 1 && (
|
||||||
|
<Divider my="xl" color="rgba(128,128,128,0.1)" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{response?.totalPages > 1 && (
|
||||||
|
<Center mt="xl">
|
||||||
|
<Pagination
|
||||||
|
total={response.totalPages}
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</>
|
||||||
))}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ export const Route = createFileRoute('/profile')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const roleBadgeColor: Record<string, string> = {
|
const roleBadgeColor: Record<string, string> = {
|
||||||
USER: 'blue',
|
|
||||||
ADMIN: 'violet',
|
ADMIN: 'violet',
|
||||||
SUPER_ADMIN: 'red',
|
DEVELOPER: 'red',
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
|
|||||||
@@ -1,73 +1,212 @@
|
|||||||
|
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||||
|
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Container,
|
Container,
|
||||||
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
|
List,
|
||||||
|
Modal,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
|
||||||
Paper,
|
|
||||||
Tabs,
|
|
||||||
Avatar,
|
|
||||||
SimpleGrid,
|
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
List,
|
Title,
|
||||||
Box,
|
|
||||||
Divider,
|
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
TbPlus,
|
|
||||||
TbSearch,
|
|
||||||
TbPencil,
|
|
||||||
TbTrash,
|
|
||||||
TbUserCheck,
|
|
||||||
TbShieldCheck,
|
|
||||||
TbAccessPoint,
|
TbAccessPoint,
|
||||||
TbCircleCheck,
|
TbCircleCheck,
|
||||||
TbClock,
|
TbCircleX,
|
||||||
TbApps,
|
TbPencil,
|
||||||
|
TbPlus,
|
||||||
|
TbSearch,
|
||||||
|
TbShieldCheck,
|
||||||
|
TbTrash,
|
||||||
|
TbUserCheck
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
import useSWR from 'swr'
|
||||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
import { API_URLS } from '../config/api'
|
||||||
|
import { useSession } from '../hooks/useAuth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/users')({
|
export const Route = createFileRoute('/users')({
|
||||||
component: UsersPage,
|
component: UsersPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockUsers = [
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
{ id: 1, name: 'Amel', email: 'amel@company.com', role: 'SUPER_ADMIN', apps: 'All', status: 'Online', lastActive: 'Now' },
|
|
||||||
{ id: 2, name: 'John Doe', email: 'john@company.com', role: 'DEVELOPER', apps: 'Desa+, Fitness App', status: 'Offline', lastActive: '2h ago' },
|
const getRoleColor = (role: string) => {
|
||||||
{ id: 3, name: 'Jane Smith', email: 'jane@company.com', role: 'QA', apps: 'E-Commerce', status: 'Online', lastActive: '12m ago' },
|
const r = (role || '').toLowerCase()
|
||||||
{ id: 4, name: 'Rahmat Hidayat', email: 'rahmat@company.com', role: 'DEVELOPER', apps: 'Desa+', status: 'Online', lastActive: 'Now' },
|
if (r.includes('super')) return 'red'
|
||||||
]
|
if (r.includes('admin')) return 'brand-blue'
|
||||||
|
if (r.includes('developer')) return 'violet'
|
||||||
|
return 'gray'
|
||||||
|
}
|
||||||
|
|
||||||
const roles = [
|
const roles = [
|
||||||
{
|
|
||||||
name: 'SUPER_ADMIN',
|
|
||||||
count: 2,
|
|
||||||
color: 'red',
|
|
||||||
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors']
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'DEVELOPER',
|
name: 'DEVELOPER',
|
||||||
count: 12,
|
color: 'red',
|
||||||
color: 'brand-blue',
|
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
|
||||||
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'QA',
|
name: 'ADMIN',
|
||||||
count: 5,
|
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
|
permissions: ['View All Apps', 'View Logs', 'Report Errors']
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function UsersPage() {
|
function UsersPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const isDeveloper = session?.user?.role === 'DEVELOPER'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedSearch(search), 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
const { data: stats, mutate: mutateStats } = useSWR(API_URLS.getOperatorStats(), fetcher)
|
||||||
|
const { data: response, isLoading, mutate: mutateOperators } = useSWR(
|
||||||
|
API_URLS.getOperators(page, debouncedSearch),
|
||||||
|
fetcher
|
||||||
|
)
|
||||||
|
|
||||||
|
const operators = response?.data || []
|
||||||
|
|
||||||
|
// ── Create User Modal ──
|
||||||
|
const [createOpened, { open: openCreate, close: closeCreate }] = useDisclosure(false)
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'USER',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateUser = async () => {
|
||||||
|
if (!createForm.name || !createForm.email || !createForm.password) {
|
||||||
|
notifications.show({ title: 'Validation Error', message: 'Please fill in all required fields.', color: 'red' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.createOperator(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(createForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
notifications.show({ title: 'Success', message: 'User has been created.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||||
|
mutateOperators()
|
||||||
|
mutateStats()
|
||||||
|
closeCreate()
|
||||||
|
setCreateForm({ name: '', email: '', password: '', role: 'USER' })
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
throw new Error(err.error || 'Failed to create user')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit User Modal ──
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [editingUserId, setEditingUserId] = useState<string | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOpenEdit = (user: any) => {
|
||||||
|
setEditingUserId(user.id)
|
||||||
|
setEditForm({ name: user.name, email: user.email, role: user.role })
|
||||||
|
openEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditUser = async () => {
|
||||||
|
if (!editingUserId || !editForm.name || !editForm.email) return
|
||||||
|
|
||||||
|
setIsEditing(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.editOperator(editingUserId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(editForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
notifications.show({ title: 'Success', message: 'User has been updated.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||||
|
mutateOperators()
|
||||||
|
closeEdit()
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to update user')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
|
||||||
|
} finally {
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete User ──
|
||||||
|
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [deletingUser, setDeletingUser] = useState<any>(null)
|
||||||
|
|
||||||
|
const handleOpenDelete = (user: any) => {
|
||||||
|
setDeletingUser(user)
|
||||||
|
openDelete()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
if (!deletingUser) return
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URLS.deleteOperator(deletingUser.id), {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
notifications.show({ title: 'Success', message: 'User has been deleted.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||||
|
mutateOperators()
|
||||||
|
mutateStats()
|
||||||
|
closeDelete()
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
throw new Error(err.error || 'Failed to delete user')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container size="xl" py="lg">
|
<Container size="xl" py="lg">
|
||||||
@@ -80,9 +219,9 @@ function UsersPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||||
<StatsCard title="Total Staff" value={24} icon={TbUserCheck} color="brand-blue" />
|
<StatsCard title="Total Staff" value={stats?.totalStaff ?? 0} icon={TbUserCheck} color="brand-blue" />
|
||||||
<StatsCard title="Active Now" value={18} icon={TbAccessPoint} color="teal" />
|
<StatsCard title="Active Now" value={stats?.activeNow ?? 0} icon={TbAccessPoint} color="teal" />
|
||||||
<StatsCard title="Security Roles" value={3} icon={TbShieldCheck} color="purple-primary" />
|
<StatsCard title="Security Roles" value={stats?.rolesCount ?? 0} icon={TbShieldCheck} color="purple-primary" />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
|
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
|
||||||
@@ -100,15 +239,23 @@ function UsersPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
w={350}
|
w={350}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.currentTarget.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
{isDeveloper && (
|
||||||
variant="gradient"
|
<Button
|
||||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
variant="gradient"
|
||||||
leftSection={<TbPlus size={18} />}
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
radius="md"
|
leftSection={<TbPlus size={18} />}
|
||||||
>
|
radius="md"
|
||||||
Add New User
|
onClick={openCreate}
|
||||||
</Button>
|
>
|
||||||
|
Add New User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
|
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
|
||||||
@@ -117,56 +264,72 @@ function UsersPage() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Name & Contact</Table.Th>
|
<Table.Th>Name & Contact</Table.Th>
|
||||||
<Table.Th>Role</Table.Th>
|
<Table.Th>Role</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Joined Date</Table.Th>
|
||||||
<Table.Th>App Access</Table.Th>
|
|
||||||
<Table.Th>Actions</Table.Th>
|
<Table.Th>Actions</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{mockUsers.map((user) => (
|
{isLoading ? (
|
||||||
<Table.Tr key={user.id}>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td colSpan={4} align="center">
|
||||||
<Group gap="sm">
|
<Text size="sm" c="dimmed" py="xl">Loading user data...</Text>
|
||||||
<Avatar size="sm" radius="xl" color="brand-blue">{user.name.charAt(0)}</Avatar>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw={600} size="sm">{user.name}</Text>
|
|
||||||
<Text size="xs" c="dimmed">{user.email}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge variant="light" color={user.role === 'SUPER_ADMIN' ? 'red' : user.role === 'DEVELOPER' ? 'brand-blue' : 'orange'}>
|
|
||||||
{user.role}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap={6}>
|
|
||||||
<Box style={{ width: 6, height: 6, borderRadius: '50%', background: user.status === 'Online' ? '#10b981' : '#94a3b8' }} />
|
|
||||||
<Text size="xs" fw={500}>{user.status}</Text>
|
|
||||||
<Text size="xs" c="dimmed" ml="xs"><TbClock size={10} style={{ marginBottom: -2 }} /> {user.lastActive}</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap={4}>
|
|
||||||
<TbApps size={12} color="gray" />
|
|
||||||
<Text size="xs" fw={500}>{user.apps}</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs">
|
|
||||||
<ActionIcon variant="light" size="sm" color="blue">
|
|
||||||
<TbPencil size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon variant="light" size="sm" color="red">
|
|
||||||
<TbTrash size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
) : operators.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={4} align="center">
|
||||||
|
<Text size="sm" c="dimmed" py="xl">No users found.</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
operators.map((user: any) => (
|
||||||
|
<Table.Tr key={user.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={600} size="sm">{user.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{user.email}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light" color={getRoleColor(user.role)}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs">
|
||||||
|
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
|
||||||
|
<TbPencil size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
|
||||||
|
<TbTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{response?.totalPages > 1 && (
|
||||||
|
<Group justify="center" mt="md">
|
||||||
|
<Pagination
|
||||||
|
total={response.totalPages}
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
@@ -179,7 +342,6 @@ function UsersPage() {
|
|||||||
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
|
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
|
||||||
<TbShieldCheck size={28} />
|
<TbShieldCheck size={28} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Badge variant="default" size="lg" radius="sm">{role.count} Users</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
@@ -216,7 +378,127 @@ function UsersPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={createOpened}
|
||||||
|
onClose={closeCreate}
|
||||||
|
title={<Text fw={700} size="lg">Add New User</Text>}
|
||||||
|
radius="xl"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Full Name"
|
||||||
|
placeholder="Enter full name"
|
||||||
|
required
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
placeholder="Enter email address"
|
||||||
|
required
|
||||||
|
value={createForm.email}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
required
|
||||||
|
value={createForm.password}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
data={[
|
||||||
|
{ value: 'ADMIN', label: 'Admin' },
|
||||||
|
{ value: 'DEVELOPER', label: 'Developer' },
|
||||||
|
]}
|
||||||
|
value={createForm.role}
|
||||||
|
onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isCreating}
|
||||||
|
onClick={handleCreateUser}
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit User Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={<Text fw={700} size="lg">Edit User</Text>}
|
||||||
|
radius="xl"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Full Name"
|
||||||
|
placeholder="Enter full name"
|
||||||
|
required
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
placeholder="Enter email address"
|
||||||
|
required
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
data={[
|
||||||
|
{ value: 'ADMIN', label: 'Admin' },
|
||||||
|
{ value: 'DEVELOPER', label: 'Developer' },
|
||||||
|
]}
|
||||||
|
value={editForm.role}
|
||||||
|
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
loading={isEditing}
|
||||||
|
onClick={handleEditUser}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={deleteOpened}
|
||||||
|
onClose={closeDelete}
|
||||||
|
title={<Text fw={700} size="lg">Delete User</Text>}
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Are you sure you want to delete <Text component="span" fw={700}>{deletingUser?.name}</Text>? This action cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="subtle" color="gray" onClick={closeDelete}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="red" loading={isDeleting} onClick={handleDeleteUser}>
|
||||||
|
Delete User
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
src/lib/logger.ts
Normal file
18
src/lib/logger.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { prisma } from './db'
|
||||||
|
import { LogType } from '../../generated/prisma'
|
||||||
|
|
||||||
|
export async function createSystemLog(userId: string, type: LogType, message: string) {
|
||||||
|
try {
|
||||||
|
return await prisma.log.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Logger Error]', error)
|
||||||
|
// Don't throw, we don't want logging errors to break the main application flow
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export function createTestApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create a test user with hashed password, returns the user record */
|
/** Create a test user with hashed password, returns the user record */
|
||||||
export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'USER' | 'ADMIN' | 'SUPER_ADMIN' = 'USER') {
|
export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'ADMIN' | 'DEVELOPER' = 'DEVELOPER') {
|
||||||
const hashed = await Bun.password.hash(password, { algorithm: 'bcrypt' })
|
const hashed = await Bun.password.hash(password, { algorithm: 'bcrypt' })
|
||||||
return prisma.user.upsert({
|
return prisma.user.upsert({
|
||||||
where: { email },
|
where: { email },
|
||||||
|
|||||||
Reference in New Issue
Block a user