Compare commits
8 Commits
amalia/13-
...
amalia/15-
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ea551b4c | |||
| c67fc9a230 | |||
| c66ce4a39b | |||
| 840a89ea0a | |||
| 24fcc1ee76 | |||
| f38081b1eb | |||
| a0cafbf2e2 | |||
| 14adaa8526 |
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.
|
||||
- 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
|
||||
|
||||
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
|
||||
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
|
||||
@@ -18,7 +18,8 @@
|
||||
"db:seed": "bun run prisma/seed.ts",
|
||||
"db:studio": "bunx prisma studio",
|
||||
"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": {
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
@@ -43,11 +44,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@playwright/mcp": "^0.0.70",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tanstack/router-vite-plugin": "^1.166.27",
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"playwright": "^1.59.1",
|
||||
"prisma": "6",
|
||||
"puppeteer-core": "^24.40.0",
|
||||
"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,
|
||||
},
|
||||
});
|
||||
@@ -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,9 +9,7 @@ datasource db {
|
||||
}
|
||||
|
||||
enum Role {
|
||||
USER
|
||||
ADMIN
|
||||
SUPER_ADMIN
|
||||
DEVELOPER
|
||||
}
|
||||
|
||||
@@ -44,7 +42,7 @@ model User {
|
||||
name String
|
||||
email String @unique
|
||||
password String
|
||||
role Role @default(USER)
|
||||
role Role @default(ADMIN)
|
||||
active Boolean @default(true)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -71,6 +69,19 @@ model 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 {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
@@ -86,12 +97,12 @@ model Log {
|
||||
model Bug {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
app String?
|
||||
appId String?
|
||||
affectedVersion String
|
||||
device String
|
||||
os String
|
||||
status BugStatus
|
||||
source BugSource
|
||||
source BugSource
|
||||
description String
|
||||
stackTrace String?
|
||||
fixedVersion String?
|
||||
@@ -100,6 +111,7 @@ model Bug {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
app App? @relation(fields: [appId], references: [id])
|
||||
|
||||
images BugImage[]
|
||||
logs BugLog[]
|
||||
|
||||
@@ -6,9 +6,7 @@ const SUPER_ADMIN_EMAILS = (process.env.SUPER_ADMIN_EMAIL ?? '').split(',').map(
|
||||
|
||||
async function main() {
|
||||
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: 'User', email: 'user@example.com', password: 'user123', role: 'USER' as const },
|
||||
]
|
||||
|
||||
for (const u of users) {
|
||||
@@ -21,13 +19,28 @@ async function main() {
|
||||
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) {
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
if (user && user.role !== 'SUPER_ADMIN') {
|
||||
await prisma.user.update({ where: { email }, data: { role: 'SUPER_ADMIN' } })
|
||||
console.log(`Promoted to SUPER_ADMIN: ${email}`)
|
||||
}
|
||||
const password = await Bun.password.hash('developer123', { algorithm: 'bcrypt' })
|
||||
await prisma.user.upsert({
|
||||
where: { 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
363
src/app.ts
363
src/app.ts
@@ -37,8 +37,8 @@ export function createApp() {
|
||||
return { error: 'Email atau password salah' }
|
||||
}
|
||||
// Auto-promote super admin from env
|
||||
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') {
|
||||
user = await prisma.user.update({ where: { id: user.id }, data: { 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: 'DEVELOPER' } })
|
||||
}
|
||||
const token = crypto.randomUUID()
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
|
||||
@@ -78,100 +78,85 @@ export function createApp() {
|
||||
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 } })
|
||||
|
||||
await createSystemLog(user.id, 'LOGIN', 'Logged in via Google')
|
||||
|
||||
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 ────────────────────────────────
|
||||
.get('/api/dashboard/stats', () => ({
|
||||
totalApps: 3,
|
||||
newErrors: 185,
|
||||
activeUsers: '24.5k',
|
||||
trends: { totalApps: 1, newErrors: 12, activeUsers: 5.2 }
|
||||
}))
|
||||
|
||||
.get('/api/apps', () => [
|
||||
{ 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/apps/:appId', ({ params: { appId } }) => {
|
||||
const apps = {
|
||||
'desa-plus': { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
|
||||
.get('/api/dashboard/stats', async () => {
|
||||
const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } })
|
||||
const users = await prisma.user.count()
|
||||
return {
|
||||
totalApps: 1,
|
||||
newErrors: newErrors,
|
||||
activeUsers: users,
|
||||
trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 }
|
||||
}
|
||||
})
|
||||
|
||||
.get('/api/dashboard/recent-errors', async () => {
|
||||
const bugs = await prisma.bug.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
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,
|
||||
}
|
||||
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
|
||||
})
|
||||
|
||||
.get('/api/logs', async ({ query }) => {
|
||||
@@ -226,7 +211,7 @@ export function createApp() {
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { type: string, message: string }
|
||||
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }))?.id || ''
|
||||
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || ''
|
||||
|
||||
await createSystemLog(actingUserId, body.type as any, body.message)
|
||||
return { ok: true }
|
||||
@@ -265,7 +250,7 @@ export function createApp() {
|
||||
|
||||
.get('/api/operators/stats', async () => {
|
||||
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({where: {active: true}}),
|
||||
prisma.session.count({
|
||||
where: { expiresAt: { gte: new Date() } },
|
||||
}),
|
||||
@@ -282,6 +267,99 @@ export function createApp() {
|
||||
}
|
||||
})
|
||||
|
||||
.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 },
|
||||
@@ -306,7 +384,7 @@ export function createApp() {
|
||||
]
|
||||
}
|
||||
if (app && app !== 'all') {
|
||||
where.app = app
|
||||
where.appId = app
|
||||
}
|
||||
if (status && status !== 'all') {
|
||||
where.status = status
|
||||
@@ -350,12 +428,12 @@ export function createApp() {
|
||||
}
|
||||
|
||||
const body = (await request.json()) as any
|
||||
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
|
||||
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||
const actingUserId = userId || defaultAdmin?.id || ''
|
||||
|
||||
const bug = await prisma.bug.create({
|
||||
data: {
|
||||
app: body.app,
|
||||
appId: body.app,
|
||||
affectedVersion: body.affectedVersion,
|
||||
device: body.device,
|
||||
os: body.os,
|
||||
@@ -382,6 +460,97 @@ export function createApp() {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Example API ───────────────────────────────────
|
||||
.get('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
|
||||
@@ -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 { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
|
||||
import { TbChevronRight, TbDeviceMobile } from 'react-icons/tb'
|
||||
|
||||
interface AppCardProps {
|
||||
id: string
|
||||
name: string
|
||||
status: 'active' | 'warning' | 'error'
|
||||
users: number
|
||||
users?: number
|
||||
errors: number
|
||||
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 scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||
|
||||
@@ -46,12 +47,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
|
||||
</Avatar>
|
||||
<Stack gap={0}>
|
||||
<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>
|
||||
</Group>
|
||||
<Badge color={statusColor} variant="dot" size="sm">
|
||||
{/* <Badge color={statusColor} variant="dot" size="sm">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
</Badge> */}
|
||||
</Group>
|
||||
|
||||
{/* <Stack gap="md" mt="sm">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
@@ -7,30 +8,33 @@ import {
|
||||
Burger,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
NavLink,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon
|
||||
ThemeIcon,
|
||||
useComputedColorScheme,
|
||||
useMantineColorScheme
|
||||
} from '@mantine/core'
|
||||
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 {
|
||||
TbAlertTriangle,
|
||||
TbApps,
|
||||
TbArrowLeft,
|
||||
TbChevronRight,
|
||||
TbDashboard,
|
||||
TbDeviceMobile,
|
||||
TbLogout,
|
||||
TbSettings,
|
||||
TbUserCircle,
|
||||
TbSun,
|
||||
TbMoon,
|
||||
TbUser,
|
||||
TbHistory,
|
||||
TbBug
|
||||
TbLogout,
|
||||
TbMoon,
|
||||
TbSettings,
|
||||
TbSun,
|
||||
TbUser,
|
||||
TbUserCircle
|
||||
} from 'react-icons/tb'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
@@ -49,17 +53,52 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const matches = useMatches()
|
||||
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 = [
|
||||
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
|
||||
{ label: 'Applications', icon: TbApps, to: '/apps' },
|
||||
{ label: 'Log Activity', icon: TbHistory, to: '/logs' },
|
||||
{ label: 'Bug Reports', icon: TbBug, to: '/bug-reports' },
|
||||
{ label: 'Error Reports', icon: TbAlertTriangle, to: '/bug-reports' },
|
||||
{ label: 'Users', icon: TbUser, to: '/users' },
|
||||
]
|
||||
|
||||
const activeApp = appId ? APP_CONFIGS[appId] : null
|
||||
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 (
|
||||
<AppShell
|
||||
header={{ height: 70 }}
|
||||
@@ -114,21 +153,47 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
<Menu.Target>
|
||||
<Avatar
|
||||
src={undefined}
|
||||
alt="User"
|
||||
alt={user?.name || 'User'}
|
||||
color="brand-blue"
|
||||
radius="xl"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
>
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Menu.Target>
|
||||
|
||||
<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.Item leftSection={<TbUserCircle size={16} />}>Profile</Menu.Item>
|
||||
<Menu.Item leftSection={<TbSettings size={16} />}>Settings</Menu.Item>
|
||||
<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.Label>Danger Zone</Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<TbLogout size={16} />}>
|
||||
Logout
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<TbLogout size={16} />}
|
||||
onClick={handleLogout}
|
||||
disabled={logout.isPending}
|
||||
>
|
||||
{logout.isPending ? 'Logging out...' : 'Logout'}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
@@ -159,10 +224,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
<Select
|
||||
label="Selected Application"
|
||||
value={appId}
|
||||
data={[
|
||||
data={appSelectData.length > 0 ? appSelectData : [
|
||||
{ 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 } })}
|
||||
radius="md"
|
||||
@@ -224,19 +287,26 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
>
|
||||
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
|
||||
<Group gap="xs">
|
||||
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981' }} />
|
||||
<Text size="sm" fw={500}>All Systems Operational</Text>
|
||||
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, boxShadow: `0 0 6px ${statusColor}` }} />
|
||||
<Text size="sm" fw={500}>{statusText}</Text>
|
||||
</Group>
|
||||
{systemStatus && (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{systemStatus.activeSessions} active session{systemStatus.activeSessions !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
fullWidth
|
||||
leftSection={<TbLogout size={16} />}
|
||||
leftSection={logout.isPending ? <Loader size={16} color="red" /> : <TbLogout size={16} />}
|
||||
mt="md"
|
||||
onClick={handleLogout}
|
||||
disabled={logout.isPending}
|
||||
>
|
||||
Log out
|
||||
{logout.isPending ? 'Logging out...' : 'Log out'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -15,62 +15,35 @@ import {
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
|
||||
|
||||
const mockErrors = [
|
||||
{
|
||||
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 interface ErrorDataTableProps {
|
||||
appId?: string
|
||||
}
|
||||
|
||||
export function ErrorDataTable() {
|
||||
export function ErrorDataTable({ appId }: ErrorDataTableProps) {
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
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) => {
|
||||
setSelectedError(error)
|
||||
open()
|
||||
}
|
||||
|
||||
const getSeverityColor = (sev: string) => {
|
||||
switch(sev) {
|
||||
case 'critical': return 'red'
|
||||
case 'high': return 'orange'
|
||||
case 'medium': return 'yellow'
|
||||
switch(sev?.toUpperCase()) {
|
||||
case 'OPEN': return 'red'
|
||||
case 'IN_PROGRESS': return 'orange'
|
||||
case 'ON_HOLD': return 'yellow'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
@@ -86,7 +59,7 @@ export function ErrorDataTable() {
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>LATEST ERROR REPORTS</Text>
|
||||
</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
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -97,37 +70,49 @@ export function ErrorDataTable() {
|
||||
<Table.Thead bg="rgba(0,0,0,0.1)">
|
||||
<Table.Tr>
|
||||
<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>Timestamp</Table.Th>
|
||||
<Table.Th pr="xl">Severity</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<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
|
||||
key={error.id}
|
||||
onClick={() => handleRowClick(error)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<Group gap={6}>
|
||||
<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>
|
||||
</Table.Td>
|
||||
<Table.Td pr="xl">
|
||||
<Badge color={getSeverityColor(error.severity)} variant="light" size="sm">
|
||||
{error.severity.toUpperCase()}
|
||||
<Badge color={getSeverityColor(error.status)} variant="light" size="sm">
|
||||
{(error.status || '').toUpperCase()}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -156,17 +141,17 @@ export function ErrorDataTable() {
|
||||
<Stack p="lg" gap="xl">
|
||||
<Box>
|
||||
<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>
|
||||
|
||||
<SimpleGrid cols={2} spacing="lg">
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>VILLAGE</Text>
|
||||
<Text fw={600}>{selectedError.village}</Text>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTER</Text>
|
||||
<Text fw={600}>{selectedError.user?.name || selectedError.userId || 'System'}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<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>
|
||||
</SimpleGrid>
|
||||
|
||||
|
||||
@@ -31,8 +31,13 @@ export const API_URLS = {
|
||||
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,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
export type Role = 'USER' | 'ADMIN' | 'SUPER_ADMIN'
|
||||
export type Role = | 'ADMIN' | 'DEVELOPER'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
@@ -41,12 +41,7 @@ export function useLogin() {
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['auth', 'session'], data)
|
||||
// Super admin → dashboard, others → profile
|
||||
if (data.user.role === 'SUPER_ADMIN') {
|
||||
navigate({ to: '/dashboard' })
|
||||
} else {
|
||||
navigate({ to: '/profile' })
|
||||
}
|
||||
navigate({ to: '/dashboard' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,78 +1,244 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Accordion,
|
||||
ThemeIcon,
|
||||
TextInput,
|
||||
Select,
|
||||
Code,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Collapse,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title
|
||||
} 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 {
|
||||
TbAlertTriangle,
|
||||
TbBug,
|
||||
TbDeviceDesktop,
|
||||
TbDeviceMobile,
|
||||
TbSearch,
|
||||
TbFilter,
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
TbAlertTriangle,
|
||||
TbBug,
|
||||
TbCircleCheck,
|
||||
TbUserCheck
|
||||
TbCircleX,
|
||||
TbDeviceDesktop,
|
||||
TbDeviceMobile,
|
||||
TbFilter,
|
||||
TbHistory,
|
||||
TbPhoto,
|
||||
TbPlus,
|
||||
TbSearch
|
||||
} from 'react-icons/tb'
|
||||
import { API_URLS } from '../config/api'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/errors')({
|
||||
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() {
|
||||
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 (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
@@ -80,93 +246,433 @@ function AppErrorsPage() {
|
||||
<Title order={3}>Error Reporting Center</Title>
|
||||
<Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text>
|
||||
</Stack>
|
||||
<Button variant="light" color="red" leftSection={<TbBug size={16} />}>
|
||||
Export Logs
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
onClick={open}
|
||||
>
|
||||
Report Error
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group mb="md" grow>
|
||||
<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
|
||||
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} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Severity"
|
||||
data={['Critical', 'High', 'Medium', 'Low']}
|
||||
leftSection={<TbFilter size={16} />}
|
||||
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"
|
||||
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">
|
||||
{mockErrors.map((error) => (
|
||||
<Accordion.Item
|
||||
key={error.id}
|
||||
value={error.id.toString()}
|
||||
style={{ border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-default)', marginBottom: '12px' }}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap">
|
||||
<ThemeIcon
|
||||
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
|
||||
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}>{error.title}</Text>
|
||||
<Badge color={error.severity === 'critical' ? 'red' : 'orange'} variant="dot" size="xs">
|
||||
{error.severity.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">{error.time} • v{error.version}</Text>
|
||||
<Group gap={4} visibleFrom="sm">
|
||||
<TbUserCheck size={12} color="gray" />
|
||||
<Text size="xs" c="dimmed">{error.users} Users Affected</Text>
|
||||
{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>
|
||||
</Box>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<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 gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(bug.createdAt).toLocaleString()} • {bug.app?.toUpperCase()} • v{bug.affectedVersion}
|
||||
</Text>
|
||||
</Group>
|
||||
</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>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
|
||||
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
|
||||
import { SummaryCard } from '@/frontend/components/SummaryCard'
|
||||
@@ -51,6 +52,11 @@ function AppOverviewPage() {
|
||||
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 || []
|
||||
@@ -209,12 +215,11 @@ function AppOverviewPage() {
|
||||
</SummaryCard>
|
||||
|
||||
<SummaryCard
|
||||
title="Errors Today"
|
||||
value="12"
|
||||
title="Errors Open"
|
||||
value={appLoading ? '...' : (appData?.errors || '0')}
|
||||
icon={TbAlertTriangle}
|
||||
color="red"
|
||||
isError={true}
|
||||
trend={{ value: '4.8%', positive: false }}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -223,7 +228,7 @@ function AppOverviewPage() {
|
||||
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} />
|
||||
</SimpleGrid>
|
||||
|
||||
<ErrorDataTable />
|
||||
<ErrorDataTable appId={appId} />
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 { TbPlus, TbSearch } from 'react-icons/tb'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
@@ -10,9 +12,12 @@ export const Route = createFileRoute('/apps/')({
|
||||
})
|
||||
|
||||
function AppsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300)
|
||||
|
||||
const { data: apps, isLoading } = useQuery({
|
||||
queryKey: ['apps'],
|
||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||
queryKey: ['apps', debouncedSearch],
|
||||
queryFn: () => fetch(`/api/apps?search=${encodeURIComponent(debouncedSearch)}`).then((r) => r.json()),
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -24,14 +29,14 @@ function AppsPage() {
|
||||
<Title order={2} className="gradient-text">Applications</Title>
|
||||
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
>
|
||||
Add New Application
|
||||
</Button>
|
||||
</Button> */}
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
@@ -40,6 +45,8 @@ function AppsPage() {
|
||||
leftSection={<TbSearch size={16} />}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { API_URLS } from '@/frontend/config/api'
|
||||
import {
|
||||
Accordion,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Collapse,
|
||||
Container,
|
||||
Group,
|
||||
Image,
|
||||
@@ -52,18 +54,30 @@ function ListErrorsPage() {
|
||||
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',
|
||||
app: 'desa-plus',
|
||||
status: 'OPEN',
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
@@ -73,6 +87,94 @@ function ListErrorsPage() {
|
||||
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({
|
||||
@@ -95,12 +197,12 @@ function ListErrorsPage() {
|
||||
await fetch(API_URLS.createLog(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'CREATE', message: `Report bug baru ditambahkan: ${createForm.description.substring(0, 50)}${createForm.description.length > 50 ? '...' : ''}` })
|
||||
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: 'Bug report has been created.',
|
||||
message: 'Error report has been created.',
|
||||
color: 'teal',
|
||||
icon: <TbCircleCheck size={18} />,
|
||||
})
|
||||
@@ -108,7 +210,7 @@ function ListErrorsPage() {
|
||||
close()
|
||||
setCreateForm({
|
||||
description: '',
|
||||
app: 'desa_plus',
|
||||
app: 'desa-plus',
|
||||
status: 'OPEN',
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
@@ -118,7 +220,7 @@ function ListErrorsPage() {
|
||||
imageUrl: '',
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to create bug report')
|
||||
throw new Error('Failed to create error report')
|
||||
}
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
@@ -142,10 +244,10 @@ function ListErrorsPage() {
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text">
|
||||
Bug Reports
|
||||
Error Reports
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Centralized bug tracking and analysis for all applications.
|
||||
Centralized error tracking and analysis for all applications.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group>
|
||||
@@ -155,7 +257,7 @@ function ListErrorsPage() {
|
||||
leftSection={<TbPlus size={18} />}
|
||||
onClick={open}
|
||||
>
|
||||
Report Bug
|
||||
Report Error
|
||||
</Button>
|
||||
{/* <Button variant="light" color="red" leftSection={<TbBug size={16} />}>
|
||||
Generate Report
|
||||
@@ -163,10 +265,83 @@ function ListErrorsPage() {
|
||||
</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 Bug</Text>}
|
||||
title={<Text fw={700} size="lg">Report New Error</Text>}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
@@ -174,7 +349,7 @@ function ListErrorsPage() {
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="What happened? Describe the bug in detail..."
|
||||
placeholder="What happened? Describe the error in detail..."
|
||||
required
|
||||
minRows={3}
|
||||
value={createForm.description}
|
||||
@@ -184,12 +359,11 @@ function ListErrorsPage() {
|
||||
<SimpleGrid cols={2}>
|
||||
<Select
|
||||
label="Application"
|
||||
data={[
|
||||
{ value: 'desa-plus', label: 'Desa+' },
|
||||
{ value: 'hipmi', label: 'Hipmi' },
|
||||
]}
|
||||
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"
|
||||
@@ -264,7 +438,7 @@ function ListErrorsPage() {
|
||||
loading={isSubmitting}
|
||||
onClick={handleCreateBug}
|
||||
>
|
||||
Submit Bug Report
|
||||
Submit Error Report
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
@@ -282,17 +456,17 @@ function ListErrorsPage() {
|
||||
placeholder="Application"
|
||||
data={[
|
||||
{ value: 'all', label: 'All Applications' },
|
||||
{ value: 'desa-plus', label: 'Desa+' },
|
||||
{ value: 'hipmi', label: 'Hipmi' },
|
||||
...(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 Statuses' },
|
||||
{ value: 'all', label: 'All Status' },
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||
@@ -319,7 +493,7 @@ function ListErrorsPage() {
|
||||
) : 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 bugs found</Text>
|
||||
<Text fw={600}>No error reports found</Text>
|
||||
<Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
@@ -398,6 +572,29 @@ function ListErrorsPage() {
|
||||
</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>
|
||||
@@ -427,7 +624,7 @@ function ListErrorsPage() {
|
||||
<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="Bug screenshot" height={100} fit="cover" />
|
||||
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -437,32 +634,52 @@ function ListErrorsPage() {
|
||||
{/* Logs / History */}
|
||||
{bug.logs && bug.logs.length > 0 && (
|
||||
<Box>
|
||||
<Group gap="xs" mb={12}>
|
||||
<TbHistory size={16} color="gray" />
|
||||
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG</Text>
|
||||
<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>
|
||||
<Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2}>
|
||||
{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 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">Assign Developer</Button>
|
||||
<Button variant="light" size="compact-xs" color="teal">Update Status</Button>
|
||||
<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>
|
||||
|
||||
@@ -27,7 +27,6 @@ export const Route = createFileRoute('/dashboard')({
|
||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||
})
|
||||
if (!data?.user) throw redirect({ to: '/login' })
|
||||
if (data.user.role !== 'SUPER_ADMIN') throw redirect({ to: '/profile' })
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw redirect({ to: '/login' })
|
||||
throw e
|
||||
@@ -36,12 +35,6 @@ export const Route = createFileRoute('/dashboard')({
|
||||
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() {
|
||||
const { data: sessionData } = useSession()
|
||||
const user = sessionData?.user
|
||||
@@ -56,6 +49,20 @@ function DashboardPage() {
|
||||
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 (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
@@ -86,21 +93,21 @@ function DashboardPage() {
|
||||
value={stats?.totalApps || 0}
|
||||
icon={TbApps}
|
||||
color="brand-blue"
|
||||
trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
|
||||
// trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
|
||||
/>
|
||||
<StatsCard
|
||||
title="New Errors"
|
||||
value={stats?.newErrors || 0}
|
||||
icon={TbMessageReport}
|
||||
color="brand-purple"
|
||||
trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
|
||||
// trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Users"
|
||||
title="Users"
|
||||
value={stats?.activeUsers || 0}
|
||||
icon={TbUsers}
|
||||
color="teal"
|
||||
trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
|
||||
// trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
@@ -124,7 +131,7 @@ function DashboardPage() {
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<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
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -141,23 +148,35 @@ function DashboardPage() {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<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.Td>
|
||||
<Text fw={600} size="sm">{error.app}</Text>
|
||||
<Text fw={600} size="sm" style={{ textTransform: 'uppercase' }}>{error.app}</Text>
|
||||
</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>
|
||||
<Badge variant="light" color="gray">v{error.version}</Badge>
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
{error.severity.toUpperCase()}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useLogin } from '@/frontend/hooks/useAuth'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -13,8 +14,7 @@ import {
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { FcGoogle } from 'react-icons/fc'
|
||||
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
|
||||
import { useLogin } from '@/frontend/hooks/useAuth'
|
||||
import { TbAlertCircle, TbLock, TbLogin, TbMail } from 'react-icons/tb'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
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()),
|
||||
})
|
||||
if (data?.user) {
|
||||
throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' })
|
||||
throw redirect({ to: '/dashboard' })
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) return
|
||||
@@ -57,12 +57,6 @@ function LoginPage() {
|
||||
Login
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
|
||||
<br />
|
||||
or: <strong>user@example.com</strong> / <strong>user123</strong>
|
||||
</Text>
|
||||
|
||||
{(login.isError || searchError) && (
|
||||
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
||||
{login.isError ? login.error.message : 'Google login failed, please try again.'}
|
||||
@@ -95,18 +89,6 @@ function LoginPage() {
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<Divider label="or" labelPosition="center" />
|
||||
|
||||
<Button
|
||||
component="a"
|
||||
href="/api/auth/google"
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<FcGoogle size={18} />}
|
||||
>
|
||||
Login with Google
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
@@ -174,7 +174,7 @@ function GlobalLogsPage() {
|
||||
</Group>
|
||||
|
||||
{/* Timeline Content */}
|
||||
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
|
||||
<Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed">Loading logs...</Text>
|
||||
@@ -190,7 +190,7 @@ function GlobalLogsPage() {
|
||||
fw={700}
|
||||
c="dimmed"
|
||||
mt={groupIndex > 0 ? "xl" : 0}
|
||||
mb="lg"
|
||||
mb="md"
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{group.date}
|
||||
|
||||
@@ -30,9 +30,8 @@ export const Route = createFileRoute('/profile')({
|
||||
})
|
||||
|
||||
const roleBadgeColor: Record<string, string> = {
|
||||
USER: 'blue',
|
||||
ADMIN: 'violet',
|
||||
SUPER_ADMIN: 'red',
|
||||
DEVELOPER: 'red',
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
|
||||
@@ -18,9 +18,14 @@ import {
|
||||
List,
|
||||
Divider,
|
||||
Pagination,
|
||||
Modal,
|
||||
Select,
|
||||
PasswordInput,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import {
|
||||
TbPlus,
|
||||
TbSearch,
|
||||
@@ -30,6 +35,7 @@ import {
|
||||
TbShieldCheck,
|
||||
TbAccessPoint,
|
||||
TbCircleCheck,
|
||||
TbCircleX,
|
||||
TbClock,
|
||||
TbApps,
|
||||
} from 'react-icons/tb'
|
||||
@@ -53,20 +59,15 @@ const getRoleColor = (role: string) => {
|
||||
}
|
||||
|
||||
const roles = [
|
||||
{
|
||||
name: 'SUPER_ADMIN',
|
||||
color: 'red',
|
||||
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors']
|
||||
},
|
||||
{
|
||||
name: 'DEVELOPER',
|
||||
color: 'brand-blue',
|
||||
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
|
||||
color: 'red',
|
||||
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
|
||||
},
|
||||
{
|
||||
name: 'QA',
|
||||
name: 'ADMIN',
|
||||
color: 'orange',
|
||||
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
|
||||
permissions: ['View All Apps', 'View Logs', 'Report Errors']
|
||||
},
|
||||
]
|
||||
|
||||
@@ -80,14 +81,131 @@ function UsersPage() {
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const { data: stats } = useSWR(API_URLS.getOperatorStats(), fetcher)
|
||||
const { data: response, isLoading } = useSWR(
|
||||
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 (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
@@ -131,6 +249,7 @@ function UsersPage() {
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
onClick={openCreate}
|
||||
>
|
||||
Add New User
|
||||
</Button>
|
||||
@@ -183,10 +302,10 @@ function UsersPage() {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<ActionIcon variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="light" size="sm" color="red">
|
||||
<ActionIcon variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
@@ -256,7 +375,127 @@ function UsersPage() {
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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 */
|
||||
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' })
|
||||
return prisma.user.upsert({
|
||||
where: { email },
|
||||
|
||||
Reference in New Issue
Block a user