25 Commits

Author SHA1 Message Date
d09a702d64 upd: swagger docs, api key auth, bug fixes
- tambah Elysia Swagger di /docs dengan deskripsi lengkap semua endpoint
- tambah API key auth (X-API-Key) untuk klien eksternal di POST /api/bugs
- tambah normalisasi BugSource: SYSTEM/USER untuk eksternal, QC/SYSTEM/USER untuk dashboard
- perbaiki source schema jadi optional string agar tidak reject nilai unknown dari klien lama
- hapus field status dari form create bug (selalu OPEN)
- perbaiki typo desa_plus → appId di apps.$appId.errors.tsx
- tambah toggle hide/show stack trace di bug-reports.tsx dan apps.$appId.errors.tsx
- perbaiki grafik desa (width(-1)/height(-1)) dengan minWidth: 0 pada grid item
- perbaiki error &[data-active] inline style di DashboardLayout → pindah ke CSS class
- update CLAUDE.md dengan arsitektur lengkap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:30:07 +08:00
cd295abf2c Merge pull request 'amalia/16-apr-26' (#10) from amalia/16-apr-26 into main
Reviewed-on: #10
2026-04-16 14:10:26 +08:00
e6dfac1ffe upd: fix graphic data 2026-04-16 11:47:19 +08:00
6ce7c93106 upd: grafik 2026-04-16 10:19:51 +08:00
f446aec734 upd: role akses 2026-04-16 09:52:17 +08:00
08d67a304a Merge pull request 'amalia/15-apr-26' (#9) from amalia/15-apr-26 into main
Reviewed-on: #9
2026-04-15 15:25:55 +08:00
16ea551b4c upd: list app 2026-04-15 14:19:10 +08:00
c67fc9a230 upd: overview desa 2026-04-15 14:14:18 +08:00
c66ce4a39b upd: auth
Deskripsi:
-update login
- update struktur database

No Issues
2026-04-15 11:17:04 +08:00
840a89ea0a upd: qwen 2026-04-15 08:45:04 +08:00
2e51c01c7a Merge pull request 'amalia/14-apr-26' (#8) from amalia/14-apr-26 into main
Reviewed-on: #8
2026-04-14 17:23:38 +08:00
24fcc1ee76 upd: user staff
Deskripsi:
- connected to database pada halaman user
- tambah user
- delete user
- update user

No
Issues
2026-04-14 16:41:03 +08:00
f38081b1eb upd: menu dashboard
Deskripsi:
- connected to database

No Issues
2026-04-14 16:24:17 +08:00
a0cafbf2e2 upd: connected ke db
Deskripi:
- list error report general dan per apps
- update status
- update feedback

No Issues
2026-04-14 12:05:34 +08:00
14adaa8526 Merge pull request 'amalia/13-apr-26' (#7) from amalia/13-apr-26 into main
Reviewed-on: #7
2026-04-13 17:19:36 +08:00
65e9ed5ce6 upd: connected api 2026-04-13 17:15:41 +08:00
2cf96135f9 upd: menerapkan log pada semua aksi 2026-04-13 16:42:36 +08:00
14a9e2c687 upd: bug list
Deskripsi:
- tampilan list bug error
- tampilan tambah bug
- connected to database; list and create

No Issues
2026-04-13 15:17:46 +08:00
c0205ce2bf upd: user dan log activity 2026-04-13 14:48:49 +08:00
315ecc565e upd: api monitoring
Deskripsi :
- api deactivate or active desa
- api edit desa

No Issues
2026-04-13 11:21:25 +08:00
8c50768c98 upd: tampilan mode dark and light'; 2026-04-13 11:00:40 +08:00
5cc73d2290 Merge pull request 'upd: api monitoring user' (#6) from amalia/10-apr-26 into main
Reviewed-on: #6
2026-04-10 13:43:01 +08:00
ac3c673500 upd: api monitoring user 2026-04-10 13:41:38 +08:00
e1b9241c35 Merge pull request 'amalia/09-apr-26' (#5) from amalia/09-apr-26 into main
Reviewed-on: #5
2026-04-09 17:34:49 +08:00
7b23192121 Merge pull request 'upd: database' (#4) from amalia/06-apr-26 into main
Reviewed-on: #4
2026-04-06 17:25:21 +08:00
56 changed files with 4670 additions and 838 deletions

View File

@@ -16,6 +16,9 @@ GOOGLE_CLIENT_SECRET=
# Role # Role
SUPER_ADMIN_EMAIL=admin@example.com SUPER_ADMIN_EMAIL=admin@example.com
# API Key for external clients (e.g. mobile apps)
API_KEY=your-secret-api-key-here
# Telegram Notification (optional) # Telegram Notification (optional)
TELEGRAM_NOTIFY_TOKEN= TELEGRAM_NOTIFY_TOKEN=
TELEGRAM_NOTIFY_CHAT_ID= TELEGRAM_NOTIFY_CHAT_ID=

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,4 @@
- generic [active]:
- generic:
- generic:
- generic: Loading...

View 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

View 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

View File

@@ -0,0 +1,4 @@
- generic [active]:
- generic:
- generic:
- generic: Loading...

View 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
View 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
View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"],
"timeout": 30000
}
}
}

154
CLAUDE.md
View File

@@ -1,73 +1,121 @@
Default to using Bun instead of Node.js. # CLAUDE.md
- Use `bun <file>` instead of `node <file>` or `ts-node <file>` This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Use `bun test` instead of `jest` or `vitest`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## Server ## Runtime
Elysia.js as the HTTP framework, running on Bun. API routes are in `src/app.ts` (exported as `createApp()`), frontend serving and dev tools are in `src/index.tsx`. Default to Bun instead of Node.js everywhere:
- `src/app.ts` — Elysia app factory with all API routes (auth, hello, health, Google OAuth). Testable via `app.handle(request)`. - `bun <file>` not `node` / `ts-node`
- `src/index.tsx` — Server entry. Adds Vite middleware (dev) or static file serving (prod), click-to-source editor integration, and `.listen()`. - `bun test` not `jest` / `vitest`
- `src/serve.ts` — Dev entry (`bun --watch src/serve.ts`). Dynamic import workaround for Bun EADDRINUSE race. - `bun install` not `npm install` / `yarn` / `pnpm`
- `bun run <script>` not `npm run`
- `bunx <pkg>` not `npx`
- Bun auto-loads `.env` — never use dotenv.
## Database ## Common Commands
PostgreSQL via Prisma v6. Client generated to `./generated/prisma` (gitignored). ```bash
bun run dev # dev server with hot reload (bun --watch src/serve.ts)
bun run build # Vite production build
bun run start # production server (NODE_ENV=production)
bun run typecheck # tsc --noEmit
bun run lint # biome check src/
bun run lint:fix # biome check --write src/
- Schema: `prisma/schema.prisma` — User (id, name, email, password, timestamps) + Session (id, token, userId, expiresAt) # Database
- Client singleton: `src/lib/db.ts` — import `{ prisma }` from here bun run db:migrate # prisma migrate dev
- Seed: `prisma/seed.ts` — demo users with `Bun.password.hash` bcrypt bun run db:seed # seed demo data
- Commands: `bun run db:migrate`, `bun run db:seed`, `bun run db:generate` bun run db:generate # regenerate prisma client
bun run db:studio # Prisma Studio GUI
bun run db:push # push schema without migration
## Auth # Tests
bun run test # all tests
bun run test:unit # tests/unit/
bun run test:integration # tests/integration/ — no server needed
bun run test:e2e # tests/e2e/ — requires Lightpanda Docker
```
Session-based auth with HttpOnly cookies stored in DB. Run a single test file: `bun test tests/integration/auth.test.ts`
- Login: `POST /api/auth/login` — finds user by email, verifies password with `Bun.password.verify`, creates Session record ## Architecture
- Google OAuth: `GET /api/auth/google` → Google → `GET /api/auth/callback/google` — upserts user, creates session
- Session: `GET /api/auth/session` — looks up session by cookie token, returns user or 401, auto-deletes expired
- Logout: `POST /api/auth/logout` — deletes session from DB, clears cookie
## Frontend ### Server
React 19 + Vite 8 (middleware mode in dev). File-based routing with TanStack Router. Elysia.js on Bun. All API routes are in `src/app.ts` as `createApp()` — testable via `app.handle(request)` without starting a server. `src/index.tsx` adds Vite middleware (dev) or static serving (prod) and calls `.listen()`. `src/serve.ts` is the dev entry point (dynamic import workaround for Bun EADDRINUSE race).
- Entry: `src/frontend.tsx` — renders App, removes splash screen, DevInspector in dev ### Database
- App: `src/frontend/App.tsx` — MantineProvider (dark, forced), QueryClientProvider, RouterProvider
- Routes: `src/frontend/routes/``__root.tsx`, `index.tsx`, `login.tsx`, `dashboard.tsx`
- Auth hooks: `src/frontend/hooks/useAuth.ts``useSession()`, `useLogin()`, `useLogout()`
- UI: Mantine v8 (dark theme `#242424`), react-icons
- Splash: `index.html` has inline dark CSS + spinner, removed on React mount
## Dev Tools PostgreSQL via Prisma v6. Client generated to `./generated/prisma` (gitignored — run `bun run db:generate` after checkout or schema changes).
- Click-to-source: `Ctrl+Shift+Cmd+C` toggles inspector. Custom Vite plugin (`inspectorPlugin` in `src/vite.ts`) injects `data-inspector-*` attributes. Reads original file from disk for accurate line numbers. **Schema models:** `User`, `Session`, `App`, `Log`, `Bug`, `BugImage`, `BugLog`
- 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`. **Enums:** `Role` (ADMIN, DEVELOPER), `BugStatus` (OPEN, ON_HOLD, IN_PROGRESS, RESOLVED, RELEASED, CLOSED), `BugSource` (QC, SYSTEM, USER), `LogType` (CREATE, UPDATE, DELETE, LOGIN, LOGOUT)
Import the singleton: `import { prisma } from './lib/db'`
### Auth & Roles
Session-based auth with HttpOnly cookies stored in the DB (24h expiry). Two roles: `DEVELOPER` (super admin) and `ADMIN`. Users listed in `SUPER_ADMIN_EMAIL` env var are auto-promoted to DEVELOPER on login.
Endpoints: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/session`
Auth state on the frontend is managed via `useSession()` / `useLogin()` / `useLogout()` in `src/frontend/hooks/useAuth.ts` (TanStack Query).
### Frontend
React 19 + Vite 8 (middleware mode in dev). TanStack Router with file-based routing in `src/frontend/routes/`. All routes are wrapped in `DashboardLayout` from `src/frontend/components/DashboardLayout.tsx`.
**Route structure:**
- `/` → redirect
- `/login` → login page
- `/dashboard` → stats overview
- `/apps` → app list
- `/apps/$appId` → per-app layout with nested routes: `index`, `errors`, `logs`, `users`, `villages`, `orders`, `products`, `payments`
- `/users` → operator management
- `/logs` → system activity log
- `/bug-reports` → cross-app bug reports
- `/profile` → user profile
**App configs** are defined in `src/frontend/config/appMenus.ts` — each app has an ID and a menu list. Currently active: `desa-plus`. Add new app entries here to register them.
**routeTree.gen.ts** is auto-generated by the TanStack Router Vite plugin — never edit it manually.
**UI:** Mantine v8, dark theme forced (`#242424`). Charts use `@mantine/charts` (recharts under the hood). Icons from `react-icons/tb`.
### API Structure
All API routes live in `src/app.ts`. Key groups:
- `/api/auth/*` — authentication
- `/api/dashboard/*` — stats and recent errors
- `/api/apps`, `/api/apps/:appId` — app listing and detail
- `/api/bugs`, `/api/bugs/:id/status`, `/api/bugs/:id/feedback` — bug report CRUD
- `/api/operators`, `/api/operators/:id` — user management
- `/api/logs` — system activity log
- `/api/system/status` — health check with DB connectivity
### Logging
`createSystemLog(userId, type, message)` from `src/lib/logger.ts` writes to the `Log` model. Call it for any significant user action (login/logout/CRUD). Logging errors are swallowed so they never break the main flow.
### Environment Variables
Required: `DATABASE_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
Optional: `PORT` (default 3000), `NODE_ENV`, `REACT_EDITOR`, `SUPER_ADMIN_EMAIL` (comma-separated)
Validated at startup in `src/lib/env.ts` — missing required vars throw immediately.
## Testing ## Testing
Tests use `bun:test`. Three levels: - Unit tests: env, DB connection, bcrypt — in `tests/unit/`
- Integration tests: `createApp().handle(new Request(...))` — no running server needed, use these for mutations
- E2E tests: Lightpanda browser via CDP (`ws://127.0.0.1:9222`). App URLs use `host.docker.internal` from inside Docker. Lightpanda executes JS but POST fetch returns 407 — use integration tests for anything that writes data.
- Helpers: `tests/helpers.ts``createTestApp()`, `seedTestUser()`, `createTestSession()`, `cleanupTestData()`
```bash ## Dev Tools
bun run test # All tests
bun run test:unit # tests/unit/ — env, db connection, bcrypt
bun run test:integration # tests/integration/ — API endpoints via app.handle()
bun run test:e2e # tests/e2e/ — browser tests via Lightpanda CDP
```
- `tests/helpers.ts``createTestApp()`, `seedTestUser()`, `createTestSession()`, `cleanupTestData()` - **Click-to-source:** `Ctrl+Shift+Cmd+C` toggles inspector. Custom Vite plugin in `src/vite.ts` injects `data-inspector-*` attributes; reads original source from disk for accurate line numbers.
- Integration tests use `createApp().handle(new Request(...))` — no server needed - **HMR:** Vite 8 + `@vitejs/plugin-react` v6. `dedupeRefreshPlugin` in `src/vite.ts` prevents double React Refresh injection.
- E2E tests use Lightpanda browser (Docker, `ws://127.0.0.1:9222`). App URLs use `host.docker.internal` from container. Lightpanda executes JS but POST fetch returns 407 — use integration tests for mutations. - **Editor:** Set `REACT_EDITOR` env var. `zed`/`subl` use `file:line:col`; others get `--goto file:line:col`.
- **Playwright MCP:** `bun run mcp:playwright` starts headless browser MCP server (config in `.qwen/settings.json`).
## APIs
- `Bun.password.hash()` / `Bun.password.verify()` for bcrypt
- `Bun.file()` for static file serving in production
- `Bun.which()` / `Bun.spawn()` for editor integration
- `crypto.randomUUID()` for session tokens

100
PLAYWRIGHT_MCP.md Normal file
View 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

View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "bun-react-template", "name": "bun-react-template",
@@ -8,9 +7,11 @@
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9", "@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6", "@prisma/client": "6",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
@@ -26,11 +27,14 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.10",
"@playwright/mcp": "^0.0.70",
"@playwright/test": "^1.59.1",
"@tanstack/router-vite-plugin": "^1.166.27", "@tanstack/router-vite-plugin": "^1.166.27",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"playwright": "^1.59.1",
"prisma": "6", "prisma": "6",
"puppeteer-core": "^24.40.0", "puppeteer-core": "^24.40.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
@@ -105,6 +109,8 @@
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="], "@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
"@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-LcbLHa0zE6FJKWPWKsIC/f+62wbDv3aXydqcNPVPyqNcaUgwvCajIi+5kHEU6GO3oXUCpzKaMsb3gsjt8sLzFQ=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
@@ -193,10 +199,18 @@
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="], "@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
"@mantine/notifications": ["@mantine/notifications@8.3.18", "", { "dependencies": { "@mantine/store": "8.3.18", "react-transition-group": "4.4.5" }, "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw=="],
"@mantine/store": ["@mantine/store@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@playwright/mcp": ["@playwright/mcp@0.0.70", "", { "dependencies": { "playwright": "1.60.0-alpha-1774999321000", "playwright-core": "1.60.0-alpha-1774999321000" }, "bin": { "playwright-mcp": "cli.js" } }, "sha512-Kl0a6l9VL8rvT1oBou3hS5yArjwWV9UlwAkq+0skfK1YVg8XfmmNaAmwZhMeNx/ZhGiWXfCllo6rD/jvZz+WuA=="],
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
"@prisma/client": ["@prisma/client@6.19.2", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg=="], "@prisma/client": ["@prisma/client@6.19.2", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg=="],
"@prisma/config": ["@prisma/config@6.19.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ=="], "@prisma/config": ["@prisma/config@6.19.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ=="],
@@ -247,6 +261,12 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
"@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="],
"@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -315,6 +335,8 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -443,6 +465,8 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
@@ -495,7 +519,7 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -515,6 +539,8 @@
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -571,6 +597,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
@@ -591,6 +619,8 @@
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -601,7 +631,7 @@
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
@@ -613,6 +643,10 @@
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.60.0-alpha-1774999321000", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-ams3Zo4VXxeOg5ZTTh16GkE8g48Bmxo/9pg9gXl9SVKlVohCU7Jaog7XntY8yFuzENA6dJc1Fz7Z/NNTm9nGEw=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
@@ -633,6 +667,8 @@
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
@@ -665,6 +701,8 @@
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
@@ -789,6 +827,8 @@
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -799,30 +839,58 @@
"@kitajs/ts-html-plugin/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "@kitajs/ts-html-plugin/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"@playwright/mcp/playwright": ["playwright@1.60.0-alpha-1774999321000", "", { "dependencies": { "playwright-core": "1.60.0-alpha-1774999321000" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Bd5DkzYKG+2g1jLO6NeTXmGLbBYSFffJIOsR4l4hUBkJvzvGGdLZ7jZb2tOtb0WIoWXQKdQj3Ap6WthV4DBS8w=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
"@tanstack/router-utils/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"playwright/playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"@kitajs/ts-html-plugin/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "@kitajs/ts-html-plugin/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"@kitajs/ts-html-plugin/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "@kitajs/ts-html-plugin/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"@kitajs/ts-html-plugin/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "@kitajs/ts-html-plugin/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw=="],
"c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"@kitajs/ts-html-plugin/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@kitajs/ts-html-plugin/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],

BIN
bun.lockb

Binary file not shown.

35
login-snapshot.yml Normal file
View 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

View File

@@ -18,12 +18,14 @@
"db:seed": "bun run prisma/seed.ts", "db:seed": "bun run prisma/seed.ts",
"db:studio": "bunx prisma studio", "db:studio": "bunx prisma studio",
"db:generate": "bunx prisma generate", "db:generate": "bunx prisma generate",
"db:push": "bunx prisma db push" "db:push": "bunx prisma db push",
"mcp:playwright": "playwright-mcp --headless --port 3000"
}, },
"dependencies": { "dependencies": {
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9", "@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.18",
@@ -43,11 +45,14 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.10",
"@playwright/mcp": "^0.0.70",
"@playwright/test": "^1.59.1",
"@tanstack/router-vite-plugin": "^1.166.27", "@tanstack/router-vite-plugin": "^1.166.27",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"playwright": "^1.59.1",
"prisma": "6", "prisma": "6",
"puppeteer-core": "^24.40.0", "puppeteer-core": "^24.40.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",

File diff suppressed because one or more lines are too long

27
playwright.config.ts Normal file
View 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,
},
});

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- Changed the type of `type` on the `log` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- CreateEnum
CREATE TYPE "LogType" AS ENUM ('CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT');
-- AlterTable
ALTER TABLE "log" DROP COLUMN "type",
ADD COLUMN "type" "LogType" NOT NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "image" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "bug_log" ALTER COLUMN "userId" DROP NOT NULL;

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- The `app` column on the `bug` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "bug" DROP COLUMN "app",
ADD COLUMN "app" TEXT;
-- DropEnum
DROP TYPE "App";

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "App" ALTER COLUMN "version" DROP NOT NULL,
ALTER COLUMN "minVersion" DROP NOT NULL;

View File

@@ -9,16 +9,10 @@ datasource db {
} }
enum Role { enum Role {
USER
ADMIN ADMIN
SUPER_ADMIN
DEVELOPER DEVELOPER
} }
enum App{
desa_plus
hipmi
}
enum BugSource{ enum BugSource{
QC QC
@@ -35,13 +29,22 @@ enum BugStatus{
CLOSED CLOSED
} }
enum LogType{
CREATE
UPDATE
DELETE
LOGIN
LOGOUT
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
email String @unique email String @unique
password String password String
role Role @default(USER) role Role @default(ADMIN)
active Boolean @default(true) active Boolean @default(true)
image String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -66,10 +69,23 @@ model Session {
@@map("session") @@map("session")
} }
model App {
id String @id @default(uuid())
name String
version String?
minVersion String?
maintenance Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bugs Bug[]
}
model Log { model Log {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
type String type LogType
message String message String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -81,12 +97,12 @@ model Log {
model Bug { model Bug {
id String @id @default(uuid()) id String @id @default(uuid())
userId String? userId String?
app App appId String?
affectedVersion String affectedVersion String
device String device String
os String os String
status BugStatus status BugStatus
source BugSource source BugSource
description String description String
stackTrace String? stackTrace String?
fixedVersion String? fixedVersion String?
@@ -95,6 +111,7 @@ model Bug {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
app App? @relation(fields: [appId], references: [id])
images BugImage[] images BugImage[]
logs BugLog[] logs BugLog[]
@@ -116,13 +133,13 @@ model BugImage {
model BugLog { model BugLog {
id String @id @default(uuid()) id String @id @default(uuid())
bugId String bugId String
userId String userId String?
status BugStatus status BugStatus
description String description String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade) bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("bug_log") @@map("bug_log")
} }

View File

@@ -6,9 +6,7 @@ const SUPER_ADMIN_EMAILS = (process.env.SUPER_ADMIN_EMAIL ?? '').split(',').map(
async function main() { async function main() {
const users = [ const users = [
{ name: 'Super Admin', email: 'superadmin@example.com', password: 'superadmin123', role: 'SUPER_ADMIN' as const },
{ name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const }, { name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const },
{ name: 'User', email: 'user@example.com', password: 'user123', role: 'USER' as const },
] ]
for (const u of users) { for (const u of users) {
@@ -21,13 +19,28 @@ async function main() {
console.log(`Seeded: ${u.email} (${u.role})`) console.log(`Seeded: ${u.email} (${u.role})`)
} }
// Promote super admin emails from env // Promote DEVELOPER emails from env
for (const email of SUPER_ADMIN_EMAILS) { for (const email of SUPER_ADMIN_EMAILS) {
const user = await prisma.user.findUnique({ where: { email } }) const password = await Bun.password.hash('developer123', { algorithm: 'bcrypt' })
if (user && user.role !== 'SUPER_ADMIN') { await prisma.user.upsert({
await prisma.user.update({ where: { email }, data: { role: 'SUPER_ADMIN' } }) where: { email },
console.log(`Promoted to SUPER_ADMIN: ${email}`) update: { role: 'DEVELOPER', password },
} create: { name: email.split('@')[0].toUpperCase(), email, password, role: 'DEVELOPER' },
})
console.log(`Promoted to DEVELOPER: ${email}`)
}
const apps = [
{ id: 'desa-plus', name: 'Desa+' },
]
for (const a of apps) {
await prisma.app.upsert({
where: { id: a.id },
update: { name: a.name },
create: { id: a.id, name: a.name },
})
console.log(`Seeded: ${a.name}`)
} }
} }

View File

@@ -1,11 +1,56 @@
import { cors } from '@elysiajs/cors' import { cors } from '@elysiajs/cors'
import { html } from '@elysiajs/html' import { html } from '@elysiajs/html'
import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger'
import { Elysia, t } from 'elysia'
import { BugSource } from '../generated/prisma'
import { prisma } from './lib/db' import { prisma } from './lib/db'
import { env } from './lib/env' import { env } from './lib/env'
import { createSystemLog } from './lib/logger'
interface AuthResult {
actingUserId: string
reporterUserId: string | null // null jika via API key (tidak ada user spesifik)
isApiKey: boolean // true = dari klien eksternal (mobile app)
}
// Validates session cookie OR X-API-Key header. Returns null if neither is valid.
async function checkAuth(request: Request): Promise<AuthResult | null> {
const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1]
if (token) {
const session = await prisma.session.findUnique({ where: { token } })
if (session && session.expiresAt > new Date()) {
return { actingUserId: session.userId, reporterUserId: session.userId, isApiKey: false }
}
}
const apiKey = request.headers.get('x-api-key')
if (apiKey && apiKey === env.API_KEY) {
const developer = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
if (!developer) return null
return { actingUserId: developer.id, reporterUserId: null, isApiKey: true }
}
return null
}
export function createApp() { export function createApp() {
return new Elysia() return new Elysia()
.use(swagger({
path: '/docs',
documentation: {
info: { title: 'Monitoring App API', version: '0.1.0' },
tags: [
{ name: 'Auth', description: 'Autentikasi dan manajemen sesi' },
{ name: 'Dashboard', description: 'Statistik dan ringkasan monitoring' },
{ name: 'Apps', description: 'Manajemen aplikasi yang dimonitor' },
{ name: 'Bugs', description: 'Manajemen laporan bug' },
{ name: 'Logs', description: 'Log aktivitas sistem' },
{ name: 'Operators', description: 'Manajemen operator / pengguna sistem' },
{ name: 'System', description: 'Status dan kesehatan sistem' },
],
},
}))
.use(cors()) .use(cors())
.use(html()) .use(html())
@@ -24,34 +69,63 @@ export function createApp() {
}) })
}) })
// API routes // ─── Health ───────────────────────────────────────
.get('/health', () => ({ status: 'ok' })) .get('/health', () => ({ status: 'ok' }), {
detail: {
summary: 'Health Check',
description: 'Memeriksa apakah server sedang berjalan.',
tags: ['System'],
},
})
// ─── Auth API ────────────────────────────────────── // ─── Auth API ──────────────────────────────────────
.post('/api/auth/login', async ({ request, set }) => { .post('/api/auth/login', async ({ body, set }) => {
const { email, password } = (await request.json()) as { email: string; password: string } const { email, password } = body
let user = await prisma.user.findUnique({ where: { email } }) let user = await prisma.user.findUnique({ where: { email } })
if (!user || !(await Bun.password.verify(password, user.password))) { if (!user || !(await Bun.password.verify(password, user.password))) {
set.status = 401 set.status = 401
return { error: 'Email atau password salah' } return { error: 'Email atau password salah' }
} }
// Auto-promote super admin from env // Auto-promote super admin from env
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') { if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } }) user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
} }
const token = crypto.randomUUID() const token = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
}, {
body: t.Object({
email: t.String({ format: 'email', description: 'Email pengguna' }),
password: t.String({ minLength: 1, description: 'Password pengguna' }),
}),
detail: {
summary: 'Login',
description: 'Login dengan email dan password. Mengembalikan data user dan set session cookie (HttpOnly, 24 jam). Jika email terdaftar di SUPER_ADMIN_EMAIL, role otomatis di-promote ke DEVELOPER.',
tags: ['Auth'],
},
}) })
.post('/api/auth/logout', async ({ request, set }) => { .post('/api/auth/logout', async ({ request, set }) => {
const cookie = request.headers.get('cookie') ?? '' const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1] const token = cookie.match(/session=([^;]+)/)?.[1]
if (token) await prisma.session.deleteMany({ where: { token } }) if (token) {
const sessionObj = await prisma.session.findUnique({ where: { token } })
if (sessionObj) {
await createSystemLog(sessionObj.userId, 'LOGOUT', 'Logged out successfully')
await prisma.session.deleteMany({ where: { token } })
}
}
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0' set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
return { ok: true } return { ok: true }
}, {
detail: {
summary: 'Logout',
description: 'Menghapus sesi aktif dari database dan membersihkan session cookie.',
tags: ['Auth'],
},
}) })
.get('/api/auth/session', async ({ request, set }) => { .get('/api/auth/session', async ({ request, set }) => {
@@ -68,112 +142,669 @@ export function createApp() {
return { user: null } return { user: null }
} }
return { user: session.user } return { user: session.user }
}, {
detail: {
summary: 'Get Current Session',
description: 'Mengembalikan data user dari sesi aktif berdasarkan session cookie. Mengembalikan 401 jika tidak ada sesi atau sudah kadaluarsa.',
tags: ['Auth'],
},
}) })
// ─── Google OAuth ────────────────────────────────── // ─── Dashboard API ─────────────────────────────────
.get('/api/auth/google', ({ request, set }) => { .get('/api/dashboard/stats', async () => {
const origin = new URL(request.url).origin const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } })
const params = new URLSearchParams({ const users = await prisma.user.count()
client_id: env.GOOGLE_CLIENT_ID, return {
redirect_uri: `${origin}/api/auth/callback/google`, totalApps: 1,
response_type: 'code', newErrors: newErrors,
scope: 'openid email profile', activeUsers: users,
access_type: 'offline', trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 }
prompt: 'consent', }
}, {
detail: {
summary: 'Dashboard Stats',
description: 'Mengembalikan statistik utama dashboard: total aplikasi, jumlah error baru (status OPEN), total pengguna, dan data tren.',
tags: ['Dashboard'],
},
})
.get('/api/dashboard/recent-errors', async () => {
const bugs = await prisma.bug.findMany({
take: 5,
orderBy: { createdAt: 'desc' }
}) })
set.status = 302; set.headers['location'] =`https://accounts.google.com/o/oauth2/v2/auth?${params}` return bugs.map(b => ({
id: b.id,
app: b.appId,
message: b.description,
version: b.affectedVersion,
time: b.createdAt.toISOString(),
severity: b.status
}))
}, {
detail: {
summary: 'Recent Errors',
description: 'Mengembalikan 5 bug report terbaru (diurutkan dari yang terbaru) untuk ditampilkan di dashboard.',
tags: ['Dashboard'],
},
}) })
.get('/api/auth/callback/google', async ({ request, set }) => { // ─── Apps API ──────────────────────────────────────
const url = new URL(request.url) .get('/api/apps', async ({ query }) => {
const code = url.searchParams.get('code') const search = query.search || ''
const origin = url.origin const where: any = {}
if (search) {
if (!code) { where.name = { contains: search, mode: 'insensitive' }
set.status = 302; set.headers['location'] ='/login?error=google_failed'
return
} }
// Exchange code for tokens const apps = await prisma.app.findMany({
const tokenRes = await fetch('https://oauth2.googleapis.com/token', { where,
method: 'POST', include: {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, _count: { select: { bugs: true } },
body: new URLSearchParams({ bugs: { where: { status: 'OPEN' }, select: { id: true } },
code, },
client_id: env.GOOGLE_CLIENT_ID, orderBy: { name: 'asc' },
client_secret: env.GOOGLE_CLIENT_SECRET, })
redirect_uri: `${origin}/api/auth/callback/google`,
grant_type: 'authorization_code', 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,
}))
}, {
query: t.Object({
search: t.Optional(t.String({ description: 'Filter berdasarkan nama aplikasi' })),
}),
detail: {
summary: 'List Apps',
description: 'Mengembalikan semua aplikasi yang dimonitor beserta status (active/warning/error), jumlah bug OPEN, versi, dan mode maintenance.',
tags: ['Apps'],
},
})
.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,
}
}, {
params: t.Object({
appId: t.String({ description: 'ID aplikasi (contoh: desa-plus)' }),
}),
detail: {
summary: 'Get App Detail',
description: 'Mengembalikan detail satu aplikasi berdasarkan ID, termasuk status, versi minimum, mode maintenance, dan total semua bug.',
tags: ['Apps'],
},
})
// ─── Logs API ──────────────────────────────────────
.get('/api/logs', async ({ query }) => {
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const search = query.search || ''
const type = query.type as any
const userId = query.userId
const where: any = {}
if (search) {
where.OR = [
{ message: { contains: search, mode: 'insensitive' } },
{ user: { name: { contains: search, mode: 'insensitive' } } }
]
}
if (type && type !== 'all') {
where.type = type
}
if (userId && userId !== 'all') {
where.userId = userId
}
const [logs, total] = await Promise.all([
prisma.log.findMany({
where,
include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}), }),
}) prisma.log.count({ where })
])
if (!tokenRes.ok) { return {
set.status = 302; set.headers['location'] ='/login?error=google_failed' data: logs,
return totalPages: Math.ceil(total / limit),
totalItems: total
} }
}, {
const tokens = (await tokenRes.json()) as { access_token: string } query: t.Object({
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
// Get user info limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })),
headers: { Authorization: `Bearer ${tokens.access_token}` }, type: t.Optional(t.String({ description: 'Filter tipe: CREATE | UPDATE | DELETE | LOGIN | LOGOUT | all' })),
}) userId: t.Optional(t.String({ description: 'Filter berdasarkan ID pengguna, atau "all"' })),
}),
if (!userInfoRes.ok) { detail: {
set.status = 302; set.headers['location'] ='/login?error=google_failed' summary: 'List Activity Logs',
return description: 'Mengembalikan log aktivitas sistem dengan pagination. Mendukung filter berdasarkan tipe log (CREATE, UPDATE, DELETE, LOGIN, LOGOUT) dan pengguna.',
} tags: ['Logs'],
},
const googleUser = (await userInfoRes.json()) as { email: string; name: string }
// Upsert user (no password for Google users)
const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email)
const user = await prisma.user.upsert({
where: { email: googleUser.email },
update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) },
create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' },
})
// Create session
const token = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
}) })
// ─── Monitoring API ──────────────────────────────── .post('/api/logs', async ({ body, request }) => {
.get('/api/dashboard/stats', () => ({ const cookie = request.headers.get('cookie') ?? ''
totalApps: 3, const token = cookie.match(/session=([^;]+)/)?.[1]
newErrors: 185, let userId: string | undefined
activeUsers: '24.5k', if (token) {
trends: { totalApps: 1, newErrors: 12, activeUsers: 5.2 } const session = await prisma.session.findUnique({ where: { token } })
})) if (session && session.expiresAt > new Date()) userId = session.userId
.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' },
} }
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || ''
await createSystemLog(actingUserId, body.type as any, body.message)
return { ok: true }
}, {
body: t.Object({
type: t.String({ description: 'Tipe log: CREATE | UPDATE | DELETE | LOGIN | LOGOUT' }),
message: t.String({ description: 'Pesan log yang akan dicatat' }),
}),
detail: {
summary: 'Create Log',
description: 'Mencatat log aktivitas sistem. Jika ada session cookie yang valid, log dikaitkan ke pengguna yang sedang login. Jika tidak, log dikaitkan ke akun DEVELOPER pertama.',
tags: ['Logs'],
},
})
.get('/api/logs/operators', async () => {
return await prisma.user.findMany({
select: { id: true, name: true, image: true },
orderBy: { name: 'asc' }
})
}, {
detail: {
summary: 'List Operators for Log Filter',
description: 'Mengembalikan daftar semua pengguna (id, name, image) sebagai opsi filter pada halaman log aktivitas.',
tags: ['Logs'],
},
})
// ─── Operators API ─────────────────────────────────
.get('/api/operators', async ({ query }) => {
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const search = query.search || ''
const where: any = {}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } }
]
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true },
orderBy: { name: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.user.count({ where })
])
return {
data: users,
totalPages: Math.ceil(total / limit),
totalItems: total
}
}, {
query: t.Object({
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
search: t.Optional(t.String({ description: 'Cari berdasarkan nama atau email' })),
}),
detail: {
summary: 'List Operators',
description: 'Mengembalikan daftar operator/pengguna sistem dengan pagination. Mendukung pencarian berdasarkan nama dan email.',
tags: ['Operators'],
},
})
.get('/api/operators/stats', async () => {
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
prisma.user.count({ where: { active: true } }),
prisma.session.count({
where: { expiresAt: { gte: new Date() } },
}),
prisma.user.groupBy({
by: ['role'],
_count: true
})
])
return {
totalStaff,
activeNow,
rolesCount: rolesGroup.length
}
}, {
detail: {
summary: 'Operator Stats',
description: 'Mengembalikan statistik operator: total staf aktif, jumlah sesi yang sedang aktif saat ini, dan jumlah role yang ada.',
tags: ['Operators'],
},
})
.post('/api/operators', async ({ body, 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 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 }
}, {
body: t.Object({
name: t.String({ minLength: 1, description: 'Nama lengkap operator' }),
email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }),
password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }),
role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role: ADMIN atau DEVELOPER' }),
}),
detail: {
summary: 'Create Operator',
description: 'Membuat akun operator baru. Password di-hash dengan bcrypt sebelum disimpan. Gagal jika email sudah terdaftar.',
tags: ['Operators'],
},
})
.patch('/api/operators/:id', async ({ params: { id }, body, 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 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 }
}, {
params: t.Object({
id: t.String({ description: 'ID operator yang akan diupdate' }),
}),
body: t.Object({
name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })),
email: t.Optional(t.String({ format: 'email', description: 'Email baru' })),
role: t.Optional(t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })),
active: t.Optional(t.Boolean({ description: 'Status aktif operator' })),
}),
detail: {
summary: 'Update Operator',
description: 'Mengupdate data operator secara parsial. Semua field bersifat opsional — hanya field yang dikirim yang akan diupdate.',
tags: ['Operators'],
},
})
.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 }
}, {
params: t.Object({
id: t.String({ description: 'ID operator yang akan dideactivate' }),
}),
detail: {
summary: 'Deactivate Operator',
description: 'Menonaktifkan akun operator (soft delete: set active=false). Semua sesi aktif operator tersebut ikut dihapus. Tidak bisa menghapus akun sendiri.',
tags: ['Operators'],
},
})
// ─── Bugs API ──────────────────────────────────────
.get('/api/bugs', async ({ query }) => {
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const search = query.search || ''
const app = query.app as any
const status = query.status as any
const where: any = {}
if (search) {
where.OR = [
{ description: { contains: search, mode: 'insensitive' } },
{ device: { contains: search, mode: 'insensitive' } },
{ os: { contains: search, mode: 'insensitive' } },
{ affectedVersion: { contains: search, mode: 'insensitive' } },
]
}
if (app && app !== 'all') {
where.appId = app
}
if (status && status !== 'all') {
where.status = status
}
const [bugs, total] = await Promise.all([
prisma.bug.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true, image: true } },
images: true,
logs: {
include: { user: { select: { id: true, name: true, image: true } } },
orderBy: { createdAt: 'desc' },
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.bug.count({ where }),
])
return {
data: bugs,
totalPages: Math.ceil(total / limit),
totalItems: total,
}
}, {
query: t.Object({
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
search: t.Optional(t.String({ description: 'Cari berdasarkan deskripsi, device, OS, atau versi' })),
app: t.Optional(t.String({ description: 'Filter berdasarkan ID aplikasi, atau "all"' })),
status: t.Optional(t.String({ description: 'Filter status: OPEN | ON_HOLD | IN_PROGRESS | RESOLVED | RELEASED | CLOSED | all' })),
}),
detail: {
summary: 'List Bug Reports',
description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi dan status.',
tags: ['Bugs'],
},
})
.post('/api/bugs', async ({ body, request, set }) => {
const auth = await checkAuth(request)
if (!auth) {
set.status = 401
return { error: 'Unauthorized: sertakan session cookie atau header X-API-Key' }
}
const { actingUserId, reporterUserId, isApiKey } = auth
const bug = await prisma.bug.create({
data: {
appId: body.app,
affectedVersion: body.affectedVersion,
device: body.device,
os: body.os,
status: 'OPEN',
source: body.source as BugSource,
description: body.description,
stackTrace: body.stackTrace,
userId: reporterUserId,
images: body.imageUrl ? {
create: { imageUrl: body.imageUrl }
} : undefined,
logs: {
create: {
userId: actingUserId,
status: 'OPEN',
description: 'Bug reported initially.',
},
},
},
})
return bug
}, {
body: t.Object({
app: t.Optional(t.String({ description: 'ID aplikasi terkait (contoh: desa-plus)' })),
affectedVersion: t.String({ description: 'Versi aplikasi yang terdampak bug' }),
device: t.String({ description: 'Tipe/model perangkat pengguna' }),
os: t.String({ description: 'Sistem operasi perangkat (contoh: Android 13, iOS 17)' }),
description: t.String({ minLength: 1, description: 'Deskripsi bug yang ditemukan' }),
stackTrace: t.Optional(t.String({ description: 'Stack trace error (opsional)' })),
source: t.Optional(t.String({
description: 'Sumber laporan: QC | SYSTEM | USER',
})),
imageUrl: t.Optional(t.String({ description: 'URL gambar screenshot bug (opsional)' })),
}),
detail: {
summary: 'Create Bug Report',
description: 'Membuat laporan bug baru dengan status awal OPEN. Bisa diakses via session cookie (frontend) atau X-API-Key (klien eksternal seperti Desa+). Jika via API key, userId pelapor null dan source default USER.',
tags: ['Bugs'],
},
})
.patch('/api/bugs/:id/feedback', async ({ params: { id }, body, 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 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
}, {
params: t.Object({
id: t.String({ description: 'ID bug report' }),
}),
body: t.Object({
feedBack: t.String({ description: 'Feedback atau catatan developer untuk bug ini' }),
}),
detail: {
summary: 'Update Bug Feedback',
description: 'Menambahkan atau mengupdate feedback/catatan developer pada sebuah bug report.',
tags: ['Bugs'],
},
})
.patch('/api/bugs/:id/status', async ({ params: { id }, body, 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 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
}, {
params: t.Object({
id: t.String({ description: 'ID bug report' }),
}),
body: t.Object({
status: t.Union(
[
t.Literal('OPEN'),
t.Literal('ON_HOLD'),
t.Literal('IN_PROGRESS'),
t.Literal('RESOLVED'),
t.Literal('RELEASED'),
t.Literal('CLOSED'),
],
{ description: 'Status baru bug' }
),
description: t.Optional(t.String({ description: 'Catatan perubahan status (opsional)' })),
}),
detail: {
summary: 'Update Bug Status',
description: 'Mengubah status bug dan otomatis membuat entri BugLog baru sebagai riwayat perubahan status.',
tags: ['Bugs'],
},
})
// ─── 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(),
}
}
}, {
detail: {
summary: 'System Status',
description: 'Memeriksa status operasional sistem: koneksi database dan jumlah sesi aktif. Mengembalikan status "degraded" jika database tidak dapat dijangkau.',
tags: ['System'],
},
}) })
// ─── Example API ─────────────────────────────────── // ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({ .get('/api/hello', () => ({
message: 'Hello, world!', message: 'Hello, world!',
method: 'GET', method: 'GET',
})) }), {
detail: { summary: 'Hello GET', tags: ['System'] },
})
.put('/api/hello', () => ({ .put('/api/hello', () => ({
message: 'Hello, world!', message: 'Hello, world!',
method: 'PUT', method: 'PUT',
})) }), {
detail: { summary: 'Hello PUT', tags: ['System'] },
})
.get('/api/hello/:name', ({ params }) => ({ .get('/api/hello/:name', ({ params }) => ({
message: `Hello, ${params.name}!`, message: `Hello, ${params.name}!`,
})) }), {
params: t.Object({
name: t.String({ description: 'Nama yang akan disapa' }),
}),
detail: { summary: 'Hello by Name', tags: ['System'] },
})
} }

View File

@@ -1,17 +1,18 @@
import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box, useComputedColorScheme } from '@mantine/core' import { Avatar, Button, Card, Group, Stack, Text, useComputedColorScheme } from '@mantine/core'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb' import { TbChevronRight, TbDeviceMobile } from 'react-icons/tb'
interface AppCardProps { interface AppCardProps {
id: string id: string
name: string name: string
status: 'active' | 'warning' | 'error' status: 'active' | 'warning' | 'error'
users: number users?: number
errors: number errors: number
version: string version: string
maintenance?: boolean
} }
export function AppCard({ id, name, status, users, errors, version }: AppCardProps) { export function AppCard({ id, name, status, errors, version }: AppCardProps) {
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red' const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true }) const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
@@ -46,12 +47,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
</Avatar> </Avatar>
<Stack gap={0}> <Stack gap={0}>
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text> <Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
<Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text> {/* <Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text> */}
</Stack> </Stack>
</Group> </Group>
<Badge color={statusColor} variant="dot" size="sm"> {/* <Badge color={statusColor} variant="dot" size="sm">
{status.toUpperCase()} {status.toUpperCase()}
</Badge> </Badge> */}
</Group> </Group>
{/* <Stack gap="md" mt="sm"> {/* <Stack gap="md" mt="sm">

View File

@@ -1,15 +1,15 @@
import { import { BarChart, LineChart } from '@mantine/charts'
Paper, import {
Stack,
Text,
Group,
ThemeIcon,
Box,
Badge, Badge,
Box,
Group,
Paper,
Stack,
Text,
ThemeIcon,
useMantineTheme useMantineTheme
} from '@mantine/core' } from '@mantine/core'
import { LineChart, BarChart } from '@mantine/charts' import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb'
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
interface ChartProps { interface ChartProps {
data?: any[] data?: any[]
@@ -18,7 +18,7 @@ interface ChartProps {
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) { export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme() const theme = useMantineTheme()
return ( return (
<Paper withBorder p="xl" radius="2xl" className="glass h-full"> <Paper withBorder p="xl" radius="2xl" className="glass h-full">
<Stack gap="md" h="100%"> <Stack gap="md" h="100%">
@@ -32,9 +32,14 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
<Text size="xs" c="dimmed">Trend over the last 7 days</Text> <Text size="xs" c="dimmed">Trend over the last 7 days</Text>
</Box> </Box>
</Group> </Group>
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}> {
{isLoading ? '...' : 'Live'} isLoading && (
</Badge> <Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
...
</Badge>
)
}
</Group> </Group>
<Box h={300} mt="lg"> <Box h={300} mt="lg">
@@ -48,6 +53,9 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
gridAxis="x" gridAxis="x"
withTooltip withTooltip
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
tooltipProps={{
allowEscapeViewBox: { x: true, y: false },
}}
styles={{ styles={{
root: { root: {
'.recharts-line-curve': { '.recharts-line-curve': {
@@ -86,17 +94,38 @@ export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps)
h={300} h={300}
data={data} data={data}
dataKey="village" dataKey="village"
series={[{ name: 'activity', color: 'indigo.6' }]} series={[{ name: 'activity', color: 'blue.6' }]} // Menggunakan warna dari theme
withTooltip withTooltip
tickLine="none"
gridAxis="y"
barProps={{ barProps={{
radius: [8, 8, 4, 4], radius: [8, 8, 0, 0],
fill: 'url(#barGradient)', // Menggunakan gradient yang Anda buat
}} }}
styles={{ tooltipProps={{
bar: { cursor: { fill: '#373A40', opacity: 0.4 },
fill: 'url(#barGradient)', allowEscapeViewBox: { x: false, y: false },
content: ({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
pointerEvents: 'none', // Sangat penting agar tidak mengganggu hover
whiteSpace: 'nowrap' // Mencegah teks turun ke bawah
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#fff', marginBottom: '4px' }}>
{payload[0].payload.village}
</div>
<div style={{ fontSize: '11px', color: '#2563EB' }}>
Activity: <span style={{ fontWeight: 700 }}>{payload[0].value}</span>
</div>
</div>
);
} }
return null;
},
}} }}
> >
<defs> <defs>

View File

@@ -1,4 +1,5 @@
import { APP_CONFIGS } from '@/frontend/config/appMenus' import { APP_CONFIGS } from '@/frontend/config/appMenus'
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
import { import {
ActionIcon, ActionIcon,
AppShell, AppShell,
@@ -7,29 +8,33 @@ import {
Burger, Burger,
Button, Button,
Group, Group,
Loader,
Menu, Menu,
NavLink, NavLink,
Select, Select,
Stack, Stack,
Text, Text,
ThemeIcon ThemeIcon,
useComputedColorScheme,
useMantineColorScheme
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core' import { useQuery } from '@tanstack/react-query'
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
import { import {
TbAlertTriangle,
TbApps, TbApps,
TbArrowLeft, TbArrowLeft,
TbChevronRight, TbChevronRight,
TbDashboard, TbDashboard,
TbDeviceMobile, TbDeviceMobile,
TbHistory,
TbLogout, TbLogout,
TbSettings,
TbUserCircle,
TbSun,
TbMoon, TbMoon,
TbSettings,
TbSun,
TbUser, TbUser,
TbHistory TbUserCircle
} from 'react-icons/tb' } from 'react-icons/tb'
interface DashboardLayoutProps { interface DashboardLayoutProps {
@@ -48,16 +53,52 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const matches = useMatches() const matches = useMatches()
const currentPath = matches[matches.length - 1]?.pathname const currentPath = matches[matches.length - 1]?.pathname
// ─── Connect to auth system ──────────────────────────
const { data: sessionData } = useSession()
const user = sessionData?.user
const logout = useLogout()
// ─── Fetch registered apps from database ─────────────
const { data: appsData } = useQuery({
queryKey: ['apps'],
queryFn: () => fetch('/api/apps', { credentials: 'include' }).then((r) => r.json()),
staleTime: 60_000,
})
// ─── Fetch system status from database ───────────────
const { data: systemStatus } = useQuery({
queryKey: ['system', 'status'],
queryFn: () => fetch('/api/system/status', { credentials: 'include' }).then((r) => r.json()),
refetchInterval: 30_000, // refresh every 30 seconds
staleTime: 15_000,
})
const globalNav = [ const globalNav = [
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' }, { label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
{ label: 'Applications', icon: TbApps, to: '/apps' }, { label: 'Applications', icon: TbApps, to: '/apps' },
{ label: 'Log Activity', icon: TbHistory, to: '/logs' }, { label: 'Log Activity', icon: TbHistory, to: '/logs' },
{ label: 'Error Reports', icon: TbAlertTriangle, to: '/bug-reports' },
{ label: 'Users', icon: TbUser, to: '/users' }, { label: 'Users', icon: TbUser, to: '/users' },
] ]
const activeApp = appId ? APP_CONFIGS[appId] : null const activeApp = appId ? APP_CONFIGS[appId] : null
const navLinks = activeApp ? activeApp.menus : globalNav const navLinks = activeApp ? activeApp.menus : globalNav
// Build app selector data from API
const appSelectData = (appsData || []).map((app: any) => ({
value: app.id,
label: app.name,
}))
// System status indicator
const isOperational = systemStatus?.status === 'operational'
const statusColor = isOperational ? '#10b981' : '#f59e0b'
const statusText = isOperational ? 'All Systems Operational' : 'System Degraded'
const handleLogout = () => {
logout.mutate()
}
return ( return (
<AppShell <AppShell
header={{ height: 70 }} header={{ height: 70 }}
@@ -112,21 +153,47 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<Menu.Target> <Menu.Target>
<Avatar <Avatar
src={undefined} src={undefined}
alt="User" alt={user?.name || 'User'}
color="brand-blue" color="brand-blue"
radius="xl" radius="xl"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> >
{user?.name?.charAt(0).toUpperCase()}
</Avatar>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{user && (
<>
<Menu.Label>
<Text size="sm" fw={600} truncate>{user.name}</Text>
<Text size="xs" c="dimmed" truncate>{user.email}</Text>
</Menu.Label>
<Menu.Divider />
</>
)}
<Menu.Label>Application</Menu.Label> <Menu.Label>Application</Menu.Label>
<Menu.Item leftSection={<TbUserCircle size={16} />}>Profile</Menu.Item> <Menu.Item
<Menu.Item leftSection={<TbSettings size={16} />}>Settings</Menu.Item> leftSection={<TbUserCircle size={16} />}
onClick={() => navigate({ to: '/profile' })}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<TbSettings size={16} />}
onClick={() => navigate({ to: '/dashboard' })}
>
Settings
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Label>Danger Zone</Menu.Label> <Menu.Label>Danger Zone</Menu.Label>
<Menu.Item color="red" leftSection={<TbLogout size={16} />}> <Menu.Item
Logout color="red"
leftSection={<TbLogout size={16} />}
onClick={handleLogout}
disabled={logout.isPending}
>
{logout.isPending ? 'Logging out...' : 'Logout'}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
@@ -157,10 +224,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<Select <Select
label="Selected Application" label="Selected Application"
value={appId} value={appId}
data={[ data={appSelectData.length > 0 ? appSelectData : [
{ value: 'desa-plus', label: 'Desa+' }, { value: 'desa-plus', label: 'Desa+' },
{ value: 'e-commerce', label: 'E-Commerce' },
{ value: 'fitness-app', label: 'Fitness App' },
]} ]}
onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })} onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })}
radius="md" radius="md"
@@ -198,16 +263,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
variant="filled" variant="filled"
color="brand-blue" color="brand-blue"
className="sidebar-nav-item" className="sidebar-nav-item"
styles={(theme) => ({
root: {
borderRadius: theme.radius.md,
transition: 'all 0.2s ease',
'&[data-active]': {
background: 'var(--gradient-blue-purple)',
fontWeight: 600,
},
},
})}
/> />
) )
})} })}
@@ -222,19 +277,26 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
> >
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text> <Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
<Group gap="xs"> <Group gap="xs">
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981' }} /> <Box style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, boxShadow: `0 0 6px ${statusColor}` }} />
<Text size="sm" fw={500}>All Systems Operational</Text> <Text size="sm" fw={500}>{statusText}</Text>
</Group> </Group>
{systemStatus && (
<Text size="xs" c="dimmed" mt={4}>
{systemStatus.activeSessions} active session{systemStatus.activeSessions !== 1 ? 's' : ''}
</Text>
)}
</Box> </Box>
<Button <Button
variant="light" variant="light"
color="red" color="red"
fullWidth fullWidth
leftSection={<TbLogout size={16} />} leftSection={logout.isPending ? <Loader size={16} color="red" /> : <TbLogout size={16} />}
mt="md" mt="md"
onClick={handleLogout}
disabled={logout.isPending}
> >
Log out {logout.isPending ? 'Logging out...' : 'Log out'}
</Button> </Button>
</Stack> </Stack>
</Box> </Box>

View File

@@ -15,62 +15,35 @@ import {
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb' import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
const mockErrors = [ export interface ErrorDataTableProps {
{ appId?: string
id: 1, }
message: 'NullPointerException at village_sync.dart:45',
village: 'Sukatani',
version: 'v1.2.0',
timestamp: '2026-04-01 14:30:15',
severity: 'critical',
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)'
},
{
id: 2,
message: 'Failed to load citizen record session',
village: 'Sukamaju',
version: 'v1.1.8',
timestamp: '2026-04-01 14:15:22',
severity: 'high',
stackTrace: 'Error: Connection timeout reaching upstream citizen-db\n at HttpClient.get (network.dart:88)'
},
{
id: 3,
message: 'SocketException: Connection timed out',
village: 'Cikini',
version: 'v1.2.0',
timestamp: '2026-04-01 13:55:10',
severity: 'medium',
stackTrace: 'SocketException: OS Error: Connection timed out, errno = 110, address = 10.0.2.2, port = 54332'
},
{
id: 4,
message: 'UI Thread blocking > 500ms',
village: 'Beji',
version: 'v1.1.2',
timestamp: '2026-04-01 13:40:00',
severity: 'low',
stackTrace: 'ANR (Application Not Responding) detected in main thread.'
},
]
export function ErrorDataTable() { export function ErrorDataTable({ appId }: ErrorDataTableProps) {
const [opened, { open, close }] = useDisclosure(false) const [opened, { open, close }] = useDisclosure(false)
const [selectedError, setSelectedError] = useState<any>(null) const [selectedError, setSelectedError] = useState<any>(null)
const { data: bugsData, isLoading } = useQuery({
queryKey: ['bugs', appId],
queryFn: () => fetch(`/api/bugs?app=${appId || 'all'}&limit=10`).then((r) => r.json()),
})
const bugs = bugsData?.data || []
const handleRowClick = (error: any) => { const handleRowClick = (error: any) => {
setSelectedError(error) setSelectedError(error)
open() open()
} }
const getSeverityColor = (sev: string) => { const getSeverityColor = (sev: string) => {
switch(sev) { switch(sev?.toUpperCase()) {
case 'critical': return 'red' case 'OPEN': return 'red'
case 'high': return 'orange' case 'IN_PROGRESS': return 'orange'
case 'medium': return 'yellow' case 'ON_HOLD': return 'yellow'
default: return 'gray' default: return 'gray'
} }
} }
@@ -86,7 +59,7 @@ export function ErrorDataTable() {
</ThemeIcon> </ThemeIcon>
<Text fw={700}>LATEST ERROR REPORTS</Text> <Text fw={700}>LATEST ERROR REPORTS</Text>
</Group> </Group>
<Button component={Link} to='/apps/desa-plus/errors' variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}> <Button component={Link} to={appId ? `/apps/${appId}/errors` : '/bug-reports'} variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}>
View All Reports View All Reports
</Button> </Button>
</Group> </Group>
@@ -97,37 +70,49 @@ export function ErrorDataTable() {
<Table.Thead bg="rgba(0,0,0,0.1)"> <Table.Thead bg="rgba(0,0,0,0.1)">
<Table.Tr> <Table.Tr>
<Table.Th px="xl">Error Message</Table.Th> <Table.Th px="xl">Error Message</Table.Th>
<Table.Th>Village</Table.Th> <Table.Th>Reporter</Table.Th>
<Table.Th>App Version</Table.Th> <Table.Th>App Version</Table.Th>
<Table.Th>Timestamp</Table.Th> <Table.Th>Timestamp</Table.Th>
<Table.Th pr="xl">Severity</Table.Th> <Table.Th pr="xl">Severity</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{mockErrors.map((error) => ( {isLoading ? (
<Table.Tr>
<Table.Td colSpan={5} align="center" py="xl">
Loading errors...
</Table.Td>
</Table.Tr>
) : bugs.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5} align="center" py="xl">
No errors found.
</Table.Td>
</Table.Tr>
) : bugs.map((error: any) => (
<Table.Tr <Table.Tr
key={error.id} key={error.id}
onClick={() => handleRowClick(error)} onClick={() => handleRowClick(error)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<Table.Td px="xl"> <Table.Td px="xl">
<Text size="sm" fw={600} lineClamp={1}>{error.message}</Text> <Text size="sm" fw={600} lineClamp={1}>{error.description}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge variant="dot" color="brand-blue" radius="sm">{error.village}</Badge> <Badge variant="dot" color="brand-blue" radius="sm">{error.user?.name || error.userId || 'System'}</Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs" fw={700} c="dimmed">{error.version}</Text> <Text size="xs" fw={700} c="dimmed">{error.affectedVersion || 'N/A'}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={6}> <Group gap={6}>
<TbHistory size={12} color="gray" /> <TbHistory size={12} color="gray" />
<Text size="xs" c="dimmed">{error.timestamp}</Text> <Text size="xs" c="dimmed">{new Date(error.createdAt).toLocaleString()}</Text>
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td pr="xl"> <Table.Td pr="xl">
<Badge color={getSeverityColor(error.severity)} variant="light" size="sm"> <Badge color={getSeverityColor(error.status)} variant="light" size="sm">
{error.severity.toUpperCase()} {(error.status || '').toUpperCase()}
</Badge> </Badge>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -156,17 +141,17 @@ export function ErrorDataTable() {
<Stack p="lg" gap="xl"> <Stack p="lg" gap="xl">
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text> <Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
<Text fw={700} size="lg" color="red">{selectedError.message}</Text> <Text fw={700} size="lg" color="red">{selectedError.description}</Text>
</Box> </Box>
<SimpleGrid cols={2} spacing="lg"> <SimpleGrid cols={2} spacing="lg">
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>VILLAGE</Text> <Text size="xs" fw={700} c="dimmed" mb={4}>REPORTER</Text>
<Text fw={600}>{selectedError.village}</Text> <Text fw={600}>{selectedError.user?.name || selectedError.userId || 'System'}</Text>
</Box> </Box>
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text> <Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text>
<Badge variant="outline">{selectedError.version}</Badge> <Badge variant="outline">{selectedError.affectedVersion || 'N/A'}</Badge>
</Box> </Box>
</SimpleGrid> </SimpleGrid>

View File

@@ -18,4 +18,26 @@ export const API_URLS = {
getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`, getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`,
postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`, postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`,
createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`, createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`,
createUser: () => `${API_BASE_URL}/api/monitoring/create-user`,
listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`,
listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`,
listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`,
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`,
getGlobalLogs: (page: number, search: string, type: string, userId: string) =>
`/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`,
getLogOperators: () => `/api/logs/operators`,
getOperators: (page: number, search: string) =>
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
getOperatorStats: () => `/api/operators/stats`,
createOperator: () => `/api/operators`,
editOperator: (id: string) => `/api/operators/${id}`,
deleteOperator: (id: string) => `/api/operators/${id}`,
getBugs: (page: number, search: string, app: string, status: string) =>
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
createBug: () => `/api/bugs`,
updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,
createLog: () => `/api/logs`,
} }

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
export type Role = 'USER' | 'ADMIN' | 'SUPER_ADMIN' export type Role = | 'ADMIN' | 'DEVELOPER'
export interface User { export interface User {
id: string id: string
@@ -41,12 +41,7 @@ export function useLogin() {
}), }),
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(['auth', 'session'], data) queryClient.setQueryData(['auth', 'session'], data)
// Super admin → dashboard, others → profile navigate({ to: '/dashboard' })
if (data.user.role === 'SUPER_ADMIN') {
navigate({ to: '/dashboard' })
} else {
navigate({ to: '/profile' })
}
}, },
}) })
} }

View File

@@ -1,78 +1,242 @@
import { import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Accordion, Accordion,
ThemeIcon, Avatar,
TextInput, Badge,
Select,
Code,
Box, Box,
Button, Button,
Code,
Collapse,
Group,
Image,
Loader,
Modal,
Pagination,
Paper,
Select,
SimpleGrid, SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Timeline,
Title
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, useParams } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { import { useState } from 'react'
TbAlertTriangle, import {
TbBug, TbAlertTriangle,
TbDeviceDesktop, TbBug,
TbDeviceMobile,
TbSearch,
TbFilter,
TbCircleCheck, TbCircleCheck,
TbUserCheck TbCircleX,
TbDeviceDesktop,
TbDeviceMobile,
TbFilter,
TbHistory,
TbPhoto,
TbPlus,
TbSearch
} from 'react-icons/tb' } from 'react-icons/tb'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/errors')({ export const Route = createFileRoute('/apps/$appId/errors')({
component: AppErrorsPage, component: AppErrorsPage,
}) })
const mockErrors = [
{
id: 1,
title: 'NullPointerException: village_id is null',
message: 'Occurred during background sync with central server.',
version: '2.4.1',
device: 'PC Admin (Windows 10)',
time: '2 mins ago',
severity: 'critical',
users: 24,
frequency: 145,
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)\nat android.app.IntentService$ServiceHandler.handleMessage(IntentService.java:78)'
},
{
id: 2,
title: 'SocketTimeoutException: Connection reset by peer',
message: 'Failed to upload document: surat_kematian_01.pdf',
version: '2.4.0',
device: 'Android Tablet (Samsung Tab A8)',
time: '15 mins ago',
severity: 'high',
users: 5,
frequency: 12,
stackTrace: 'java.net.SocketTimeoutException: timeout\nat okio.Okio$4.newTimeoutException(Okio.java:232)\nat okio.AsyncTimeout.exit(AsyncTimeout.java:285)'
},
{
id: 3,
title: 'SQLiteException: no such column: village_id',
message: 'Failed to query local village profile database.',
version: '2.4.1',
device: 'PC Admin (Windows 7)',
time: '1 hour ago',
severity: 'medium',
users: 2,
frequency: 4,
stackTrace: 'java.io.IOException: No space left on device\nat java.io.FileOutputStream.writeBytes(Native Method)'
},
]
function AppErrorsPage() { function AppErrorsPage() {
const { appId } = useParams({ from: '/apps/$appId/errors' }) const { appId } = useParams({ from: '/apps/$appId/errors' })
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [app, setApp] = useState(appId)
const [status, setStatus] = useState('all')
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
const toggleLogs = (bugId: string) => {
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
}
const { data, isLoading, refetch } = useQuery({
queryKey: ['bugs', { page, search, app, status }],
queryFn: () =>
fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()),
})
// Fetch apps for the dropdown
const { data: appsList } = useQuery({
queryKey: ['apps-list'],
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
// Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [createForm, setCreateForm] = useState({
description: '',
app: appId,
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: appId,
source: 'USER',
affectedVersion: '',
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
} else {
throw new Error('Failed to create error report')
}
} catch (e) {
notifications.show({
title: 'Error',
message: 'Something went wrong.',
color: 'red',
icon: <TbCircleX size={18} />,
})
} finally {
setIsSubmitting(false)
}
}
const bugs = data?.data || []
const totalPages = data?.totalPages || 1
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
@@ -80,93 +244,421 @@ function AppErrorsPage() {
<Title order={3}>Error Reporting Center</Title> <Title order={3}>Error Reporting Center</Title>
<Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text> <Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text>
</Stack> </Stack>
<Button variant="light" color="red" leftSection={<TbBug size={16} />}> <Button
Export Logs variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
onClick={open}
>
Report Error
</Button> </Button>
</Group> </Group>
<Paper withBorder radius="2xl" className="glass" p="md"> <Modal
<Group mb="md" grow> opened={updateModalOpened}
onClose={closeUpdateModal}
title={<Text fw={700} size="lg">Update Bug Status</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
required
data={[
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'RELEASED', label: 'Released' },
{ value: 'CLOSED', label: 'Closed' },
]}
value={updateForm.status}
onChange={(val) => setUpdateForm({ ...updateForm, status: val || '' })}
/>
<Textarea
label="Update Note (Optional)"
placeholder="E.g. Fixed in commit xxxxx / Assigned to team"
minRows={3}
value={updateForm.description}
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isUpdating}
onClick={handleUpdateStatus}
>
Save Changes
</Button>
</Stack>
</Modal>
<Modal
opened={feedbackModalOpened}
onClose={closeFeedbackModal}
title={<Text fw={700} size="lg">Developer Feedback</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Textarea
data-autofocus
label="Feedback / Note"
placeholder="Explain the issue, root cause, or resolution..."
required
minRows={4}
value={feedbackForm.feedBack}
onChange={(e) => setFeedbackForm({ ...feedbackForm, feedBack: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isUpdatingFeedback}
onClick={handleUpdateFeedback}
>
Save Feedback
</Button>
</Stack>
</Modal>
<Modal
opened={opened}
onClose={close}
title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Textarea
label="Description"
placeholder="What happened? Describe the error in detail..."
required
minRows={3}
value={createForm.description}
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
/>
<SimpleGrid cols={2}>
<Select
label="Application"
data={appsList?.map((a: any) => ({ value: a.id, label: a.name })) || []}
value={createForm.app}
onChange={(val) => setCreateForm({ ...createForm, app: val as any })}
placeholder="Select application"
disabled={!appsList}
/>
<Select
label="Source"
data={[
{ value: 'USER', label: 'User' },
{ value: 'QC', label: 'QC' },
{ value: 'SYSTEM', label: 'System' },
]}
value={createForm.source}
onChange={(val) => setCreateForm({ ...createForm, source: val as any })}
/>
</SimpleGrid>
<TextInput <TextInput
placeholder="Search error message, village, or stack trace..." label="Version"
placeholder="e.g. 2.4.1"
required
value={createForm.affectedVersion}
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
/>
<SimpleGrid cols={2}>
<TextInput
label="Device"
placeholder="e.g. iPhone 13, Windows 11 PC"
required
value={createForm.device}
onChange={(e) => setCreateForm({ ...createForm, device: e.target.value })}
/>
<TextInput
label="OS"
placeholder="e.g. iOS 15.4, Windows 11"
required
value={createForm.os}
onChange={(e) => setCreateForm({ ...createForm, os: e.target.value })}
/>
</SimpleGrid>
<TextInput
label="Image URL (Optional)"
placeholder="https://example.com/screenshot.png"
value={createForm.imageUrl}
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
/>
<Textarea
label="Stack Trace (Optional)"
placeholder="Paste code or error logs here..."
style={{ fontFamily: 'monospace' }}
minRows={2}
value={createForm.stackTrace}
onChange={(e) => setCreateForm({ ...createForm, stackTrace: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateBug}
>
Submit Error Report
</Button>
</Stack>
</Modal>
<Paper withBorder radius="2xl" className="glass" p="md">
<SimpleGrid cols={{ base: 1, sm: 3 }} mb="md">
<TextInput
placeholder="Search description, device, os..."
leftSection={<TbSearch size={16} />} leftSection={<TbSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md" radius="md"
/> />
<Select <Select
placeholder="Severity" placeholder="Status"
data={['Critical', 'High', 'Medium', 'Low']} data={[
leftSection={<TbFilter size={16} />} { value: 'all', label: 'All Status' },
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'RELEASED', label: 'Released' },
{ value: 'CLOSED', label: 'Closed' },
]}
value={status}
onChange={(val) => setStatus(val || 'all')}
radius="md" radius="md"
clearable
/> />
</Group> <Group justify="flex-end">
<Button variant="subtle" color="gray" leftSection={<TbFilter size={16} />} onClick={() => { setSearch(''); setStatus('all') }}>
Reset
</Button>
</Group>
</SimpleGrid>
<Accordion variant="separated" radius="xl"> {isLoading ? (
{mockErrors.map((error) => ( <Stack align="center" py="xl">
<Accordion.Item <Loader size="lg" type="dots" />
key={error.id} <Text size="sm" c="dimmed">Loading error reports...</Text>
value={error.id.toString()} </Stack>
style={{ border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-default)', marginBottom: '12px' }} ) : bugs.length === 0 ? (
> <Paper p="xl" withBorder style={{ borderStyle: 'dashed', textAlign: 'center' }}>
<Accordion.Control> <TbBug size={48} color="gray" style={{ marginBottom: 12, opacity: 0.5 }} />
<Group wrap="nowrap"> <Text fw={600}>No error reports found</Text>
<ThemeIcon <Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'} </Paper>
variant="light" ) : (
size="lg" <Accordion variant="separated" radius="xl">
radius="md" {bugs.map((bug: any) => (
> <Accordion.Item
<TbAlertTriangle size={20} /> key={bug.id}
</ThemeIcon> value={bug.id}
<Box style={{ flex: 1 }}> style={{
<Group justify="space-between"> border: '1px solid var(--mantine-color-default-border)',
<Text size="sm" fw={600} lineClamp={1}>{error.title}</Text> background: 'var(--mantine-color-default)',
<Badge color={error.severity === 'critical' ? 'red' : 'orange'} variant="dot" size="xs"> marginBottom: '12px',
{error.severity.toUpperCase()} }}
</Badge> >
</Group> <Accordion.Control>
<Group gap="md"> <Group wrap="nowrap">
<Text size="xs" c="dimmed">{error.time} v{error.version}</Text> <ThemeIcon
<Group gap={4} visibleFrom="sm"> color={
<TbUserCheck size={12} color="gray" /> bug.status === 'OPEN'
<Text size="xs" c="dimmed">{error.users} Users Affected</Text> ? 'red'
: bug.status === 'IN_PROGRESS'
? 'blue'
: 'teal'
}
variant="light"
size="lg"
radius="md"
>
<TbAlertTriangle size={20} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Group justify="space-between">
<Text size="sm" fw={600} lineClamp={1}>
{bug.description}
</Text>
<Badge
color={
bug.status === 'OPEN'
? 'red'
: bug.status === 'IN_PROGRESS'
? 'blue'
: 'teal'
}
variant="dot"
size="xs"
>
{bug.status}
</Badge>
</Group> </Group>
</Group> <Group gap="md">
</Box> <Text size="xs" c="dimmed">
</Group> {new Date(bug.createdAt).toLocaleString()} {bug.appId?.toUpperCase()} v{bug.affectedVersion}
</Accordion.Control> </Text>
<Accordion.Panel>
<Stack gap="md" py="xs">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
<Text size="sm" fw={500}>{error.message}</Text>
</Box>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
<Group gap="xs">
{error.device.includes('PC') ? <TbDeviceDesktop size={14} color="gray" /> : <TbDeviceMobile size={14} color="gray" />}
<Text size="xs" fw={500}>{error.device}</Text>
</Group> </Group>
</Box> </Box>
</SimpleGrid>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
<Code block color="red" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px', border: '1px solid var(--mantine-color-default-border)' }}>
{error.stackTrace}
</Code>
</Box>
<Group justify="flex-end" pt="sm">
<Button variant="light" size="compact-xs" color="blue">Assign Developer</Button>
<Button variant="light" size="compact-xs" color="teal" leftSection={<TbCircleCheck size={14} />}>Mark as Fixed</Button>
</Group> </Group>
</Stack> </Accordion.Control>
</Accordion.Panel> <Accordion.Panel>
</Accordion.Item> <Stack gap="lg" py="xs">
))} {/* Device Info */}
</Accordion> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
<Group gap="xs">
{bug.device.toLowerCase().includes('windows') || bug.device.toLowerCase().includes('mac') || bug.device.toLowerCase().includes('pc') ? (
<TbDeviceDesktop size={14} color="gray" />
) : (
<TbDeviceMobile size={14} color="gray" />
)}
<Text size="xs" fw={500}>{bug.device} ({bug.os})</Text>
</Group>
</Box>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>SOURCE</Text>
<Badge variant="light" color="gray" size="sm">{bug.source}</Badge>
</Box>
</SimpleGrid>
{/* Feedback & Reporter Info */}
{(bug.user || bug.feedBack) && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{bug.user && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTED BY</Text>
<Group gap="xs">
<Avatar src={bug.user.image} radius="xl" size="sm" color="blue">
{bug.user.name?.charAt(0).toUpperCase()}
</Avatar>
<Text size="sm">{bug.user.name}</Text>
</Group>
</Box>
)}
{bug.feedBack && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVELOPER FEEDBACK</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{bug.feedBack}</Text>
</Box>
)}
</SimpleGrid>
)}
{/* Stack Trace */}
{bug.stackTrace && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
<Code
block
color="red"
style={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
fontSize: '11px',
border: '1px solid var(--mantine-color-default-border)',
}}
>
{bug.stackTrace}
</Code>
</Box>
)}
{/* Images */}
{bug.images && bug.images.length > 0 && (
<Box>
<Group gap="xs" mb={8}>
<TbPhoto size={16} color="gray" />
<Text size="xs" fw={700} c="dimmed">ATTACHED IMAGES ({bug.images.length})</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper>
))}
</SimpleGrid>
</Box>
)}
{/* Logs / History */}
{bug.logs && bug.logs.length > 0 && (
<Box>
<Group justify="space-between" mb={showLogs[bug.id] ? 12 : 0}>
<Group gap="xs">
<TbHistory size={16} color="gray" />
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG ({bug.logs.length})</Text>
</Group>
<Button
variant="subtle"
size="compact-xs"
color="gray"
onClick={() => toggleLogs(bug.id)}
>
{showLogs[bug.id] ? 'Hide' : 'Show'}
</Button>
</Group>
<Collapse in={showLogs[bug.id]}>
<Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2} mt="md">
{bug.logs.map((log: any) => (
<Timeline.Item
key={log.id}
bullet={
<Badge size="xs" circle color={log.status === 'RESOLVED' ? 'teal' : 'blue'}> </Badge>
}
title={<Text size="sm" fw={600}>{log.status}</Text>}
>
<Text size="xs" c="dimmed" mb={4}>
{new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
</Text>
<Text size="sm">{log.description}</Text>
</Timeline.Item>
))}
</Timeline>
</Collapse>
</Box>
)}
<Group justify="flex-end" pt="sm">
<Button variant="light" size="compact-xs" color="blue" onClick={() => {
setSelectedBugId(bug.id)
setFeedbackForm({ feedBack: bug.feedBack || '' })
openFeedbackModal()
}}>Developer Feedback</Button>
<Button variant="light" size="compact-xs" color="teal" onClick={() => {
setSelectedBugId(bug.id)
setUpdateForm({ status: bug.status, description: '' })
openUpdateModal()
}}>Update Status</Button>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
)}
{totalPages > 1 && (
<Group justify="center" mt="xl">
<Pagination total={totalPages} value={page} onChange={setPage} radius="xl" />
</Group>
)}
</Paper> </Paper>
</Stack> </Stack>
) )

View File

@@ -1,33 +1,32 @@
import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts' import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable' import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard' import { SummaryCard } from '@/frontend/components/SummaryCard'
import { useSession } from '@/frontend/hooks/useAuth'
import { import {
ActionIcon, Badge,
Button,
Group, Group,
Modal,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text,
Title,
Modal,
Button,
TextInput,
Switch, Switch,
Badge, Text,
Textarea, Textarea,
Skeleton TextInput,
Title
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router' import { notifications } from '@mantine/notifications'
import useSWR from 'swr' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { import {
TbActivity, TbActivity,
TbAlertTriangle, TbAlertTriangle,
TbBuildingCommunity, TbBuildingCommunity,
TbRefresh,
TbVersions TbVersions
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/')({ export const Route = createFileRoute('/apps/$appId/')({
@@ -41,6 +40,8 @@ function AppOverviewPage() {
const navigate = useNavigate() const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false) const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
// Form State // Form State
const [latestVersion, setLatestVersion] = useState('') const [latestVersion, setLatestVersion] = useState('')
@@ -54,6 +55,11 @@ function AppOverviewPage() {
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher) const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : null, fetcher) const { data: 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 grid = gridRes?.data
const dailyData = dailyRes?.data || [] const dailyData = dailyRes?.data || []
const comparisonData = comparisonRes?.data || [] const comparisonData = comparisonRes?.data || []
@@ -89,6 +95,12 @@ function AppOverviewPage() {
}) })
if (response.ok) { if (response.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Update version information: ${JSON.stringify({ latestVersion, minVersion, maintenance, messageUpdate })}` })
}).catch(console.error)
notifications.show({ notifications.show({
title: 'Update Successful', title: 'Update Successful',
message: 'Application version information has been updated.', message: 'Application version information has been updated.',
@@ -118,29 +130,29 @@ function AppOverviewPage() {
<> <>
<Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md"> <Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label="Active Version" label="Active Version"
placeholder="e.g. 2.0.5" placeholder="e.g. 2.0.5"
value={latestVersion} value={latestVersion}
onChange={(e) => setLatestVersion(e.currentTarget.value)} onChange={(e) => setLatestVersion(e.currentTarget.value)}
/> />
<TextInput <TextInput
label="Minimum Version" label="Minimum Version"
placeholder="e.g. 2.0.0" placeholder="e.g. 2.0.0"
value={minVersion} value={minVersion}
onChange={(e) => setMinVersion(e.currentTarget.value)} onChange={(e) => setMinVersion(e.currentTarget.value)}
/> />
<Textarea <Textarea
label="Update Message" label="Update Message"
placeholder="Enter release notes or update message..." placeholder="Enter release notes or update message..."
value={messageUpdate} value={messageUpdate}
onChange={(e) => setMessageUpdate(e.currentTarget.value)} onChange={(e) => setMessageUpdate(e.currentTarget.value)}
minRows={3} minRows={3}
autosize autosize
/> />
<Switch <Switch
label="Maintenance Mode" label="Maintenance Mode"
description="Enable to put the app in maintenance mode for users." description="Enable to put the app in maintenance mode for users."
checked={maintenance} checked={maintenance}
onChange={(e) => setMaintenance(e.currentTarget.checked)} onChange={(e) => setMaintenance(e.currentTarget.checked)}
/> />
@@ -168,7 +180,7 @@ function AppOverviewPage() {
value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')} value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
icon={TbVersions} icon={TbVersions}
color="brand-blue" color="brand-blue"
onClick={openVersionModal} onClick={isDeveloper ? openVersionModal : undefined}
> >
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Stack gap={0}> <Stack gap={0}>
@@ -206,12 +218,12 @@ function AppOverviewPage() {
</SummaryCard> </SummaryCard>
<SummaryCard <SummaryCard
title="Errors Today" title="Errors Open"
value="12" value={appLoading ? '...' : (appData?.errors || '0')}
icon={TbAlertTriangle} icon={TbAlertTriangle}
color="red" color="red"
isError={true} isError={true}
trend={{ value: '4.8%', positive: false }} onClick={() => navigate({ to: `/apps/${appId}/errors` })}
/> />
</SimpleGrid> </SimpleGrid>
@@ -220,7 +232,7 @@ function AppOverviewPage() {
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} /> <VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} />
</SimpleGrid> </SimpleGrid>
<ErrorDataTable /> <ErrorDataTable appId={appId} />
</Stack> </Stack>
</> </>
) )

View File

@@ -112,7 +112,7 @@ function AppLogsPage() {
{isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`} {isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
</Text> </Text>
</Stack> </Stack>
<Button {/* <Button
variant="light" variant="light"
color="gray" color="gray"
leftSection={<TbDownload size={18} />} leftSection={<TbDownload size={18} />}
@@ -120,7 +120,7 @@ function AppLogsPage() {
size="md" size="md"
> >
Export Export
</Button> </Button> */}
</Group> </Group>
<TextInput <TextInput
@@ -172,10 +172,10 @@ function AppLogsPage() {
> >
<Table.Thead bg="rgba(0,0,0,0.05)"> <Table.Thead bg="rgba(0,0,0,0.05)">
<Table.Tr> <Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '18%' }}>Timestamp</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '15%' }}>Timestamp</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>User & Village</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>User & Village</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Action</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '15%' }}>Action</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '50%' }}>Description</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '40%' }}>Description</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -187,7 +187,7 @@ function AppLogsPage() {
<TbCalendar size={14} /> <TbCalendar size={14} />
</ThemeIcon> </ThemeIcon>
<Stack gap={0}> <Stack gap={0}>
<Text size="xs" fw={700} style={{ color: 'var(--mantine-color-white)' }}> <Text size="xs" fw={700}>
{log.createdAt.split(' ').slice(1).join(' ')} {log.createdAt.split(' ').slice(1).join(' ')}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -216,7 +216,10 @@ function AppLogsPage() {
color={getActionColor(log.action)} color={getActionColor(log.action)}
radius="sm" radius="sm"
size="xs" size="xs"
styles={{ root: { fontWeight: 800 } }} styles={{
root: { fontWeight: 800 },
label: { textOverflow: 'clip', overflow: 'visible' }
}}
> >
{log.action} {log.action}
</Badge> </Badge>

View File

@@ -1,39 +1,45 @@
import { useState } from 'react'
import useSWR from 'swr'
import { import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Button,
ActionIcon, ActionIcon,
TextInput,
Table,
Avatar, Avatar,
Badge,
Box, Box,
Button,
Container,
Divider,
Group,
Modal,
Pagination, Pagination,
ThemeIcon, Paper,
ScrollArea, ScrollArea,
Tooltip, Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
Switch,
} from '@mantine/core' } from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks' import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useParams } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import { import {
TbBriefcase,
TbCircleCheck,
TbCircleX,
TbEdit,
TbHome2,
TbId,
TbMail,
TbPhone,
TbPlus, TbPlus,
TbSearch, TbSearch,
TbUsers, TbUsers,
TbX, TbX,
TbMail,
TbPhone,
TbId,
TbBriefcase,
TbHome2,
TbCircleCheck,
TbCircleX,
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/users/')({ export const Route = createFileRoute('/apps/$appId/users/')({
@@ -46,12 +52,17 @@ interface APIUser {
nik: string nik: string
phone: string phone: string
email: string email: string
gender: string
isWithoutOTP: boolean isWithoutOTP: boolean
isActive: boolean isActive: boolean
role: string role: string
village: string village: string
group: string group: string
position?: string position?: string
idUserRole: string
idVillage: string
idGroup: string
idPosition: string
} }
const fetcher = (url: string) => fetch(url).then((res) => res.json()) const fetcher = (url: string) => fetch(url).then((res) => res.json())
@@ -65,7 +76,7 @@ function UsersIndexPage() {
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
const { data: response, error, isLoading } = useSWR(apiUrl, fetcher) const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
const users: APIUser[] = response?.data?.user || [] const users: APIUser[] = response?.data?.user || []
const handleSearchChange = (val: string) => { const handleSearchChange = (val: string) => {
@@ -76,6 +87,210 @@ function UsersIndexPage() {
} }
} }
// --- ADD USER LOGIC ---
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [villageSearch, setVillageSearch] = useState('')
const [form, setForm] = useState({
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: ''
})
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
const [editForm, setEditForm] = useState({
id: '',
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: '',
isActive: true,
isWithoutOTP: false
})
// Options Data (Shared for both Add and Edit modals)
const isAnyModalOpened = opened || editOpened
const targetVillageId = opened ? form.idVillage : editForm.idVillage
const targetGroupId = opened ? form.idGroup : editForm.idGroup
const { data: rolesResp } = useSWR(isAnyModalOpened ? API_URLS.listRole() : null, fetcher)
const { data: villagesResp } = useSWR(
isAnyModalOpened && villageSearch.length >= 1 ? API_URLS.getVillages(1, villageSearch) : null,
fetcher
)
const { data: groupsResp } = useSWR(
isAnyModalOpened && targetVillageId ? API_URLS.listGroup(targetVillageId) : null,
fetcher
)
const { data: positionsResp } = useSWR(
isAnyModalOpened && targetGroupId ? API_URLS.listPosition(targetGroupId) : null,
fetcher
)
const rolesOptions = (rolesResp?.data || []).map((r: any) => ({ value: r.id, label: r.name }))
const villagesOptions = (villagesResp?.data || []).map((v: any) => ({ value: v.id, label: v.name }))
const groupsOptions = (groupsResp?.data || []).map((g: any) => ({ value: g.id, label: g.name }))
const positionsOptions = (positionsResp?.data || []).map((p: any) => ({ value: p.id, label: p.name }))
const handleCreateUser = async () => {
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
const missing = requiredFields.filter(f => !form[f as keyof typeof form])
if (missing.length > 0) {
notifications.show({
title: 'Validation Error',
message: `Please fill in all required fields: ${missing.join(', ')}`,
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.createUser(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
})
const result = await res.json()
if (result.success) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'CREATE', message: `Didaftarkan user (${appId}) baru: ${form.name}-${form.nik}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: 'User has been created successfully.',
color: 'teal',
icon: <TbCircleCheck size={18} />
})
mutate() // Refresh user list
close()
setForm({
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: ''
})
} else {
notifications.show({
title: 'Error',
message: result.message || 'Failed to create user.',
color: 'red',
icon: <TbCircleX size={18} />
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to connect to the server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
const handleEditOpen = (user: APIUser) => {
setEditForm({
id: user.id,
name: user.name,
nik: user.nik,
phone: user.phone,
email: user.email,
gender: user.gender,
idUserRole: user.idUserRole,
idVillage: user.idVillage,
idGroup: user.idGroup,
idPosition: user.idPosition,
isActive: user.isActive,
isWithoutOTP: user.isWithoutOTP
})
setVillageSearch(user.village)
openEdit()
}
const handleUpdateUser = async () => {
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
const missing = requiredFields.filter(f => !editForm[f as keyof typeof editForm])
if (missing.length > 0) {
notifications.show({
title: 'Validation Error',
message: `Please fill in all required fields: ${missing.join(', ')}`,
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.editUser(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(editForm)
})
const result = await res.json()
if (result.success) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Data user (${appId}) diperbarui: ${editForm.name}-${editForm.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: 'User has been updated successfully.',
color: 'teal',
icon: <TbCircleCheck size={18} />
})
mutate()
closeEdit()
} else {
notifications.show({
title: 'Error',
message: result.message || 'Failed to update user.',
color: 'red',
icon: <TbCircleX size={18} />
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to connect to the server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
const handleClearSearch = () => { const handleClearSearch = () => {
setSearch('') setSearch('')
setSearchQuery('') setSearchQuery('')
@@ -126,11 +341,279 @@ function UsersIndexPage() {
leftSection={<TbPlus size={18} />} leftSection={<TbPlus size={18} />}
radius="md" radius="md"
size="md" size="md"
onClick={open}
> >
Add User Add User
</Button> </Button>
</Group> </Group>
<Modal
opened={opened}
onClose={close}
title={<Text fw={700} size="lg">Add New User</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Personal Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={form.name}
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={form.nik}
onChange={(e) => setForm(f => ({ ...f, nik: e.target.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email Address"
placeholder="email@example.com"
required
value={form.email}
onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))}
/>
<TextInput
label="Phone Number"
placeholder="628xxxxxxxxxx"
required
value={form.phone}
onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={[
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
]}
mt="sm"
required
value={form.gender}
onChange={(v) => setForm(f => ({ ...f, gender: v || '' }))}
/>
</Box>
<Divider label="Role & Organization" labelPosition="center" my="sm" />
<Box>
<Select
label="User Role"
placeholder="Select user role"
data={rolesOptions}
required
value={form.idUserRole}
onChange={(v) => setForm(f => ({ ...f, idUserRole: v || '' }))}
/>
<Select
label="Village"
placeholder="Type to search village..."
searchable
onSearchChange={setVillageSearch}
data={villagesOptions}
mt="sm"
required
value={form.idVillage}
onChange={(v) => {
setForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
}}
/>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Select
label="Group"
placeholder={form.idVillage ? "Select group" : "Select village first"}
data={groupsOptions}
disabled={!form.idVillage}
required
value={form.idGroup}
onChange={(v) => {
setForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
}}
/>
<Select
label="Position"
placeholder={form.idGroup ? "Select position" : "Select group first"}
data={positionsOptions}
disabled={!form.idGroup}
value={form.idPosition || ''}
onChange={(v) => setForm(f => ({ ...f, idPosition: v || '' }))}
/>
</SimpleGrid>
</Box>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateUser}
>
Register User
</Button>
</Stack>
</Modal>
<Modal
opened={editOpened}
onClose={closeEdit}
title={<Text fw={700} size="lg">Edit User</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Personal Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={editForm.name}
onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={editForm.nik}
onChange={(e) => setEditForm(f => ({ ...f, nik: e.target.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email Address"
placeholder="email@example.com"
required
value={editForm.email}
onChange={(e) => setEditForm(f => ({ ...f, email: e.target.value }))}
/>
<TextInput
label="Phone Number"
placeholder="628xxxxxxxxxx"
required
value={editForm.phone}
onChange={(e) => setEditForm(f => ({ ...f, phone: e.target.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={[
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
]}
mt="sm"
required
value={editForm.gender}
onChange={(v) => setEditForm(f => ({ ...f, gender: v || '' }))}
/>
</Box>
<Divider label="Role & Organization" labelPosition="center" my="sm" />
<Box>
<Select
label="User Role"
placeholder="Select user role"
data={rolesOptions}
required
value={editForm.idUserRole}
onChange={(v) => setEditForm(f => ({ ...f, idUserRole: v || '' }))}
/>
<Select
label="Village"
placeholder="Type to search village..."
searchable
onSearchChange={setVillageSearch}
data={villagesOptions}
mt="sm"
required
value={editForm.idVillage}
onChange={(v) => {
setEditForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
}}
/>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Select
label="Group"
placeholder={editForm.idVillage ? "Select group" : "Select village first"}
data={groupsOptions}
disabled={!editForm.idVillage}
required
value={editForm.idGroup}
onChange={(v) => {
setEditForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
}}
/>
<Select
label="Position"
placeholder={editForm.idGroup ? "Select position" : "Select group first"}
data={positionsOptions}
disabled={!editForm.idGroup}
value={editForm.idPosition || ''}
onChange={(v) => setEditForm(f => ({ ...f, idPosition: v || '' }))}
/>
</SimpleGrid>
</Box>
<Divider label="System Access" labelPosition="center" my="sm" />
<SimpleGrid cols={2} spacing="xl">
<Switch
label="Account Active"
description="Enable or disable user access"
checked={editForm.isActive}
onChange={(event) => setEditForm(f => ({ ...f, isActive: event.currentTarget.checked }))}
/>
<Switch
label="Without OTP"
description="Bypass login OTP verification"
checked={editForm.isWithoutOTP}
onChange={(event) => setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
/>
</SimpleGrid>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleUpdateUser}
>
Update User
</Button>
</Stack>
</Modal>
<TextInput <TextInput
placeholder="Search name, NIK, or email..." placeholder="Search name, NIK, or email..."
leftSection={<TbSearch size={18} />} leftSection={<TbSearch size={18} />}
@@ -167,13 +650,13 @@ function UsersIndexPage() {
) : ( ) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}> <Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars> <ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table <Table
verticalSpacing="lg" verticalSpacing="md"
horizontalSpacing="xl" horizontalSpacing="md"
highlightOnHover highlightOnHover
withColumnBorders={false} withColumnBorders={false}
style={{ style={{
tableLayout: isMobile ? 'auto' : 'fixed', tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%', width: '100%',
minWidth: isMobile ? 900 : 'unset' minWidth: isMobile ? 900 : 'unset'
}} }}
@@ -182,19 +665,19 @@ function UsersIndexPage() {
<Table.Tr> <Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Organization</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '22%' }}>Organization</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Role</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>Role</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{users.map((user) => ( {users.map((user) => (
<Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}> <Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }} onClick={()=>{handleEditOpen(user)}}>
<Table.Td> <Table.Td>
<Group gap="md" wrap="nowrap"> <Group gap="md" wrap="nowrap">
<Avatar <Avatar
size="lg" size="lg"
radius="md" radius="md"
variant="light" variant="light"
color={getRoleColor(user.role)} color={getRoleColor(user.role)}
style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }} style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }}
@@ -243,10 +726,10 @@ function UsersIndexPage() {
</Stack> </Stack>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge <Badge
variant="filled" variant="filled"
color={getRoleColor(user.role)} color={getRoleColor(user.role)}
radius="md" radius="md"
size="sm" size="sm"
fullWidth={false} fullWidth={false}
styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }} styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }}

View File

@@ -4,14 +4,19 @@ import {
Button, Button,
Card, Card,
Group, Group,
Modal,
Paper, Paper,
SegmentedControl, SegmentedControl,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Textarea,
TextInput,
ThemeIcon, ThemeIcon,
Title Title
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { import {
@@ -32,6 +37,7 @@ import {
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr' import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
const fetcher = (url: string) => fetch(url).then((res) => res.json()) const fetcher = (url: string) => fetch(url).then((res) => res.json())
@@ -67,7 +73,14 @@ function ActivityChart({ villageId }: { villageId: string }) {
yearly: 'Yearly', yearly: 'Yearly',
} }
const data = response?.data || [] const rawData: any[] = Array.isArray(response?.data) ? response.data : []
// Normalize: map any field names from external API → { label, activity }
const data = rawData.map((item) => {
const label = item.label
const activity = item.aktivitas
return { label: String(label), activity: Number(activity) }
})
return ( return (
<Paper withBorder radius="xl" p="lg"> <Paper withBorder radius="xl" p="lg">
@@ -104,35 +117,20 @@ function ActivityChart({ villageId }: { villageId: string }) {
h={280} h={280}
data={data} data={data}
dataKey="label" dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]} series={[{ name: 'activity', color: '#2563EB' }]}
curveType="monotone" curveType="monotone"
withTooltip withTooltip={true}
withDots withDots={true}
tickLine="none" withPointLabels={false}
gridAxis="x"
tooltipAnimationDuration={150} tooltipAnimationDuration={150}
fillOpacity={1} tooltipProps={{
areaProps={{ allowEscapeViewBox: { x: true, y: false },
strokeWidth: 2.5,
fill: 'url(#villageAreaGrad)',
stroke: '#2563EB',
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
}} }}
dotProps={{ activeDotProps={{
r: 4, r: 6,
strokeWidth: 2, strokeWidth: 2,
stroke: '#2563EB',
fill: 'white',
}} }}
> />
<defs>
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} />
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} />
</linearGradient>
</defs>
</AreaChart>
)} )}
</Paper> </Paper>
) )
@@ -144,12 +142,135 @@ function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' }) const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate() const navigate = useNavigate()
const { data: infoRes, isLoading: infoLoading } = useSWR(API_URLS.infoVillages(villageId), fetcher) const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
const { data: infoRes, isLoading: infoLoading, mutate } = useSWR(API_URLS.infoVillages(villageId), fetcher)
const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher) const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
const [confirmModalOpened, { open: openConfirmModal, close: closeConfirmModal }] = useDisclosure(false)
const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false)
const [isUpdating, setIsUpdating] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editForm, setEditForm] = useState({ name: '', desc: '' })
const village = infoRes?.data const village = infoRes?.data
const stats = gridRes?.data const stats = gridRes?.data
const openEdit = () => {
setEditForm({
name: village?.name || '',
desc: village?.desc || ''
})
openEditModal()
}
const handleEditVillage = async () => {
if (!village) return
if (!editForm.name.trim() || !editForm.desc.trim()) {
notifications.show({
title: 'Validation Error',
message: 'All fields are required.',
color: 'red'
})
return
}
setIsEditing(true)
try {
const res = await fetch(API_URLS.editVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: village.id,
name: editForm.name,
desc: editForm.desc
})
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Data desa (${appId}) diperbarui: ${editForm.name}-${village.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: 'Village data has been updated successfully.',
color: 'teal'
})
mutate()
closeEditModal()
} else {
notifications.show({
title: 'Error',
message: 'Failed to update village data.',
color: 'red'
})
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'A network error occurred.',
color: 'red'
})
} finally {
setIsEditing(false)
}
}
const handleConfirmToggle = async () => {
if (!village) return
setIsUpdating(true)
try {
const res = await fetch(API_URLS.updateStatusVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: village.id,
active: !village.isActive
})
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Status desa (${appId}) diperbarui (${!village.isActive ? 'activated' : 'deactivated'}): ${village.name}-${village.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: `Village status has been ${!village.isActive ? 'activated' : 'deactivated'}.`,
color: 'teal'
})
mutate()
closeConfirmModal()
} else {
notifications.show({
title: 'Error',
message: 'Failed to update village status.',
color: 'red'
})
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'A network error occurred.',
color: 'red'
})
} finally {
setIsUpdating(false)
}
}
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } }) const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
if (infoLoading || gridLoading) { if (infoLoading || gridLoading) {
@@ -195,8 +316,10 @@ function VillageDetailPage() {
variant="filled" variant="filled"
color={village.isActive ? 'red' : 'green'} color={village.isActive ? 'red' : 'green'}
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />} leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
onClick={() => alert(`Toggle status for ${village.name}`)} onClick={openConfirmModal}
radius="md" radius="md"
loading={isUpdating}
disabled={!isDeveloper}
> >
{village.isActive ? 'Deactivate' : 'Active'} {village.isActive ? 'Deactivate' : 'Active'}
</Button> </Button>
@@ -204,7 +327,7 @@ function VillageDetailPage() {
variant="light" variant="light"
color="blue" color="blue"
leftSection={<TbEdit size={16} />} leftSection={<TbEdit size={16} />}
onClick={() => alert(`Edit settings for ${village.name}`)} onClick={openEdit}
radius="md" radius="md"
> >
Edit Edit
@@ -314,7 +437,9 @@ function VillageDetailPage() {
}} }}
> >
{/* Left (3/4): Activity Chart */} {/* Left (3/4): Activity Chart */}
<ActivityChart villageId={villageId} /> <Box style={{ minWidth: 0 }}>
<ActivityChart villageId={villageId} />
</Box>
{/* Right (1/4): Informasi Sistem */} {/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg"> <Paper withBorder radius="xl" p="lg">
@@ -328,7 +453,7 @@ function VillageDetailPage() {
{[ {[
{ label: 'Date Created', value: village.createdAt }, { label: 'Date Created', value: village.createdAt },
{ label: 'Created By', value: '-' }, { label: 'Created By', value: '-' },
{ label: 'Last Updated', value: '-' }, { label: 'Last Updated', value: village.updatedAt },
].map((item, idx, arr) => ( ].map((item, idx, arr) => (
<Group <Group
key={item.label} key={item.label}
@@ -347,6 +472,75 @@ function VillageDetailPage() {
</Paper> </Paper>
</Box> </Box>
{/* ── Confirmation Modal ── */}
<Modal
opened={confirmModalOpened}
onClose={closeConfirmModal}
title={<Text fw={700}>Confirm Status Change</Text>}
radius="xl"
centered
>
<Stack gap="md">
<Text size="sm">
Are you sure you want to <strong>{village.isActive ? 'deactivate' : 'activate'}</strong> village <strong>{village.name}</strong>?
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" color="gray" onClick={closeConfirmModal} radius="md">
Cancel
</Button>
<Button
color={village.isActive ? 'red' : 'green'}
onClick={handleConfirmToggle}
loading={isUpdating}
radius="md"
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
{/* ── Edit Village Modal ── */}
<Modal
opened={editModalOpened}
onClose={closeEditModal}
title={<Text fw={700}>Edit Village Details</Text>}
radius="xl"
size="md"
>
<Stack gap="md">
<TextInput
label="Village Name"
placeholder="Enter village name"
required
value={editForm.name}
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.currentTarget.value }))}
/>
<Textarea
label="Description"
placeholder="Enter village description..."
minRows={3}
required
value={editForm.desc}
onChange={(e) => setEditForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
/>
<Group justify="flex-end" gap="sm" mt="md">
<Button variant="light" color="gray" onClick={closeEditModal} radius="md">
Cancel
</Button>
<Button
variant="filled"
color="blue"
onClick={handleEditVillage}
loading={isEditing}
radius="md"
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
</Stack> </Stack>
) )
} }

View File

@@ -283,6 +283,12 @@ function AppVillagesIndexPage() {
}) })
if (res.ok) { if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'CREATE', message: `Desa baru didaftarkan: ${form.name}` })
}).catch(console.error)
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Village has been successfully registered.', message: 'Village has been successfully registered.',

View File

@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core' import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core'
import { useDebouncedValue } from '@mantine/hooks'
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { TbPlus, TbSearch } from 'react-icons/tb' import { TbPlus, TbSearch } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout' import { DashboardLayout } from '@/frontend/components/DashboardLayout'
@@ -10,9 +12,12 @@ export const Route = createFileRoute('/apps/')({
}) })
function AppsPage() { function AppsPage() {
const [search, setSearch] = useState('')
const [debouncedSearch] = useDebouncedValue(search, 300)
const { data: apps, isLoading } = useQuery({ const { data: apps, isLoading } = useQuery({
queryKey: ['apps'], queryKey: ['apps', debouncedSearch],
queryFn: () => fetch('/api/apps').then((r) => r.json()), queryFn: () => fetch(`/api/apps?search=${encodeURIComponent(debouncedSearch)}`).then((r) => r.json()),
}) })
return ( return (
@@ -24,14 +29,14 @@ function AppsPage() {
<Title order={2} className="gradient-text">Applications</Title> <Title order={2} className="gradient-text">Applications</Title>
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text> <Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
</Stack> </Stack>
<Button {/* <Button
variant="gradient" variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />} leftSection={<TbPlus size={18} />}
radius="md" radius="md"
> >
Add New Application Add New Application
</Button> </Button> */}
</Group> </Group>
<Group> <Group>
@@ -40,6 +45,8 @@ function AppsPage() {
leftSection={<TbSearch size={16} />} leftSection={<TbSearch size={16} />}
style={{ flex: 1 }} style={{ flex: 1 }}
radius="md" radius="md"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/> />
</Group> </Group>

View File

@@ -0,0 +1,703 @@
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { API_URLS } from '@/frontend/config/api'
import {
Accordion,
Avatar,
Badge,
Box,
Button,
Code,
Collapse,
Container,
Group,
Image,
Loader,
Modal,
Pagination,
Paper,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
TextInput,
Textarea,
Title,
Timeline,
} from '@mantine/core'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import {
TbAlertTriangle,
TbBug,
TbDeviceDesktop,
TbDeviceMobile,
TbFilter,
TbSearch,
TbHistory,
TbPhoto,
TbPlus,
TbCircleCheck,
TbCircleX,
} from 'react-icons/tb'
export const Route = createFileRoute('/bug-reports')({
component: ListErrorsPage,
})
function ListErrorsPage() {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [app, setApp] = useState('all')
const [status, setStatus] = useState('all')
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
const [showStackTrace, setShowStackTrace] = useState<Record<string, boolean>>({})
const toggleLogs = (bugId: string) => {
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
}
const toggleStackTrace = (bugId: string) => {
setShowStackTrace((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',
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',
source: 'USER',
affectedVersion: '',
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
} else {
throw new Error('Failed to create error report')
}
} catch (e) {
notifications.show({
title: 'Error',
message: 'Something went wrong.',
color: 'red',
icon: <TbCircleX size={18} />,
})
} finally {
setIsSubmitting(false)
}
}
const bugs = data?.data || []
const totalPages = data?.totalPages || 1
return (
<DashboardLayout>
<Container size="xl" py="lg">
<Stack gap="xl">
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={2} className="gradient-text">
Error Reports
</Title>
<Text size="sm" c="dimmed">
Centralized error tracking and analysis for all applications.
</Text>
</Stack>
<Group>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
onClick={open}
>
Report Error
</Button>
{/* <Button variant="light" color="red" leftSection={<TbBug size={16} />}>
Generate Report
</Button> */}
</Group>
</Group>
<Modal
opened={updateModalOpened}
onClose={closeUpdateModal}
title={<Text fw={700} size="lg">Update Bug Status</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
required
data={[
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'RELEASED', label: 'Released' },
{ value: 'CLOSED', label: 'Closed' },
]}
value={updateForm.status}
onChange={(val) => setUpdateForm({ ...updateForm, status: val || '' })}
/>
<Textarea
label="Update Note (Optional)"
placeholder="E.g. Fixed in commit xxxxx / Assigned to team"
minRows={3}
value={updateForm.description}
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isUpdating}
onClick={handleUpdateStatus}
>
Save Changes
</Button>
</Stack>
</Modal>
<Modal
opened={feedbackModalOpened}
onClose={closeFeedbackModal}
title={<Text fw={700} size="lg">Developer Feedback</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Textarea
data-autofocus
label="Feedback / Note"
placeholder="Explain the issue, root cause, or resolution..."
required
minRows={4}
value={feedbackForm.feedBack}
onChange={(e) => setFeedbackForm({ ...feedbackForm, feedBack: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isUpdatingFeedback}
onClick={handleUpdateFeedback}
>
Save Feedback
</Button>
</Stack>
</Modal>
<Modal
opened={opened}
onClose={close}
title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Textarea
label="Description"
placeholder="What happened? Describe the error in detail..."
required
minRows={3}
value={createForm.description}
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
/>
<SimpleGrid cols={2}>
<Select
label="Application"
data={appsList?.map((a: any) => ({ value: a.id, label: a.name })) || []}
value={createForm.app}
onChange={(val) => setCreateForm({ ...createForm, app: val as any })}
placeholder="Select application"
disabled={!appsList}
/>
<Select
label="Source"
data={[
{ value: 'USER', label: 'User' },
{ value: 'QC', label: 'QC' },
{ value: 'SYSTEM', label: 'System' },
]}
value={createForm.source}
onChange={(val) => setCreateForm({ ...createForm, source: val as any })}
/>
</SimpleGrid>
<TextInput
label="Version"
placeholder="e.g. 2.4.1"
required
value={createForm.affectedVersion}
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
/>
<SimpleGrid cols={2}>
<TextInput
label="Device"
placeholder="e.g. iPhone 13, Windows 11 PC"
required
value={createForm.device}
onChange={(e) => setCreateForm({ ...createForm, device: e.target.value })}
/>
<TextInput
label="OS"
placeholder="e.g. iOS 15.4, Windows 11"
required
value={createForm.os}
onChange={(e) => setCreateForm({ ...createForm, os: e.target.value })}
/>
</SimpleGrid>
<TextInput
label="Image URL (Optional)"
placeholder="https://example.com/screenshot.png"
value={createForm.imageUrl}
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
/>
<Textarea
label="Stack Trace (Optional)"
placeholder="Paste code or error logs here..."
style={{ fontFamily: 'monospace' }}
minRows={2}
value={createForm.stackTrace}
onChange={(e) => setCreateForm({ ...createForm, stackTrace: e.target.value })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateBug}
>
Submit Error Report
</Button>
</Stack>
</Modal>
<Paper withBorder radius="2xl" className="glass" p="md">
<SimpleGrid cols={{ base: 1, sm: 4 }} mb="md">
<TextInput
placeholder="Search description, device, os..."
leftSection={<TbSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md"
/>
<Select
placeholder="Application"
data={[
{ value: 'all', label: 'All Applications' },
...(appsList?.map((a: any) => ({ value: a.id, label: a.name })) || []),
]}
value={app}
onChange={(val) => setApp(val || 'all')}
radius="md"
disabled={!appsList}
/>
<Select
placeholder="Status"
data={[
{ value: 'all', label: 'All Status' },
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'RELEASED', label: 'Released' },
{ value: 'CLOSED', label: 'Closed' },
]}
value={status}
onChange={(val) => setStatus(val || 'all')}
radius="md"
/>
<Group justify="flex-end">
<Button variant="subtle" color="gray" leftSection={<TbFilter size={16} />} onClick={() => {setSearch(''); setApp('all'); setStatus('all')}}>
Reset
</Button>
</Group>
</SimpleGrid>
{isLoading ? (
<Stack align="center" py="xl">
<Loader size="lg" type="dots" />
<Text size="sm" c="dimmed">Loading error reports...</Text>
</Stack>
) : bugs.length === 0 ? (
<Paper p="xl" withBorder style={{ borderStyle: 'dashed', textAlign: 'center' }}>
<TbBug size={48} color="gray" style={{ marginBottom: 12, opacity: 0.5 }} />
<Text fw={600}>No error reports found</Text>
<Text size="sm" c="dimmed">Try adjusting your filters or search terms.</Text>
</Paper>
) : (
<Accordion variant="separated" radius="xl">
{bugs.map((bug: any) => (
<Accordion.Item
key={bug.id}
value={bug.id}
style={{
border: '1px solid var(--mantine-color-default-border)',
background: 'var(--mantine-color-default)',
marginBottom: '12px',
}}
>
<Accordion.Control>
<Group wrap="nowrap">
<ThemeIcon
color={
bug.status === 'OPEN'
? 'red'
: bug.status === 'IN_PROGRESS'
? 'blue'
: 'teal'
}
variant="light"
size="lg"
radius="md"
>
<TbAlertTriangle size={20} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Group justify="space-between">
<Text size="sm" fw={600} lineClamp={1}>
{bug.description}
</Text>
<Badge
color={
bug.status === 'OPEN'
? 'red'
: bug.status === 'IN_PROGRESS'
? 'blue'
: 'teal'
}
variant="dot"
size="xs"
>
{bug.status}
</Badge>
</Group>
<Group gap="md">
<Text size="xs" c="dimmed">
{new Date(bug.createdAt).toLocaleString()} {bug.appId?.toUpperCase()} v{bug.affectedVersion}
</Text>
</Group>
</Box>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="lg" py="xs">
{/* Device Info */}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
<Group gap="xs">
{bug.device.toLowerCase().includes('windows') || bug.device.toLowerCase().includes('mac') || bug.device.toLowerCase().includes('pc') ? (
<TbDeviceDesktop size={14} color="gray" />
) : (
<TbDeviceMobile size={14} color="gray" />
)}
<Text size="xs" fw={500}>{bug.device} ({bug.os})</Text>
</Group>
</Box>
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>SOURCE</Text>
<Badge variant="light" color="gray" size="sm">{bug.source}</Badge>
</Box>
</SimpleGrid>
{/* Feedback & Reporter Info */}
{(bug.user || bug.feedBack) && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{bug.user && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTED BY</Text>
<Group gap="xs">
<Avatar src={bug.user.image} radius="xl" size="sm" color="blue">
{bug.user.name?.charAt(0).toUpperCase()}
</Avatar>
<Text size="sm">{bug.user.name}</Text>
</Group>
</Box>
)}
{bug.feedBack && (
<Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVELOPER FEEDBACK</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{bug.feedBack}</Text>
</Box>
)}
</SimpleGrid>
)}
{/* Stack Trace */}
{bug.stackTrace && (
<Box>
<Group justify="space-between" mb={showStackTrace[bug.id] ? 8 : 0}>
<Text size="xs" fw={700} c="dimmed">STACK TRACE</Text>
<Button
variant="subtle"
size="compact-xs"
color="gray"
onClick={() => toggleStackTrace(bug.id)}
>
{showStackTrace[bug.id] ? 'Hide' : 'Show'}
</Button>
</Group>
<Collapse in={showStackTrace[bug.id]}>
<Code
block
color="red"
style={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
fontSize: '11px',
border: '1px solid var(--mantine-color-default-border)',
}}
>
{bug.stackTrace}
</Code>
</Collapse>
</Box>
)}
{/* Images */}
{bug.images && bug.images.length > 0 && (
<Box>
<Group gap="xs" mb={8}>
<TbPhoto size={16} color="gray" />
<Text size="xs" fw={700} c="dimmed">ATTACHED IMAGES ({bug.images.length})</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper>
))}
</SimpleGrid>
</Box>
)}
{/* Logs / History */}
{bug.logs && bug.logs.length > 0 && (
<Box>
<Group justify="space-between" mb={showLogs[bug.id] ? 12 : 0}>
<Group gap="xs">
<TbHistory size={16} color="gray" />
<Text size="xs" fw={700} c="dimmed">ACTIVITY LOG ({bug.logs.length})</Text>
</Group>
<Button
variant="subtle"
size="compact-xs"
color="gray"
onClick={() => toggleLogs(bug.id)}
>
{showLogs[bug.id] ? 'Hide' : 'Show'}
</Button>
</Group>
<Collapse in={showLogs[bug.id]}>
<Timeline active={bug.logs.length - 1} bulletSize={24} lineWidth={2} mt="md">
{bug.logs.map((log: any) => (
<Timeline.Item
key={log.id}
bullet={
<Badge size="xs" circle color={log.status === 'RESOLVED' ? 'teal' : 'blue'}> </Badge>
}
title={<Text size="sm" fw={600}>{log.status}</Text>}
>
<Text size="xs" c="dimmed" mb={4}>
{new Date(log.createdAt).toLocaleString()} by {log.user?.name || 'Unknown'}
</Text>
<Text size="sm">{log.description}</Text>
</Timeline.Item>
))}
</Timeline>
</Collapse>
</Box>
)}
<Group justify="flex-end" pt="sm">
<Button variant="light" size="compact-xs" color="blue" onClick={() => {
setSelectedBugId(bug.id)
setFeedbackForm({ feedBack: bug.feedBack || '' })
openFeedbackModal()
}}>Developer Feedback</Button>
<Button variant="light" size="compact-xs" color="teal" onClick={() => {
setSelectedBugId(bug.id)
setUpdateForm({ status: bug.status, description: '' })
openUpdateModal()
}}>Update Status</Button>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
)}
{totalPages > 1 && (
<Group justify="center" mt="xl">
<Pagination total={totalPages} value={page} onChange={setPage} radius="xl" />
</Group>
)}
</Paper>
</Stack>
</Container>
</DashboardLayout>
)
}

View File

@@ -1,23 +1,23 @@
import { useQuery } from '@tanstack/react-query' import { AppCard } from '@/frontend/components/AppCard'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import { useSession } from '@/frontend/hooks/useAuth'
import { import {
Badge, Badge,
Button, Button,
Container, Container,
Group, Group,
Loader,
Paper,
SimpleGrid, SimpleGrid,
Stack, Stack,
Table,
Text, Text,
Title, Title,
Paper,
Table,
Loader,
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute, redirect, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query'
import { TbActivity, TbApps, TbMessageReport, TbUsers, TbChevronRight } from 'react-icons/tb' import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { useLogout, useSession } from '@/frontend/hooks/useAuth' import { TbApps, TbChevronRight, TbMessageReport, TbUsers } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import { AppCard } from '@/frontend/components/AppCard'
export const Route = createFileRoute('/dashboard')({ export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
@@ -27,7 +27,6 @@ export const Route = createFileRoute('/dashboard')({
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
}) })
if (!data?.user) throw redirect({ to: '/login' }) if (!data?.user) throw redirect({ to: '/login' })
if (data.user.role !== 'SUPER_ADMIN') throw redirect({ to: '/profile' })
} catch (e) { } catch (e) {
if (e instanceof Error) throw redirect({ to: '/login' }) if (e instanceof Error) throw redirect({ to: '/login' })
throw e throw e
@@ -36,12 +35,6 @@ export const Route = createFileRoute('/dashboard')({
component: DashboardPage, component: DashboardPage,
}) })
const recentErrors = [
{ id: 1, app: 'Desa+', message: 'NullPointerException at village_sync.dart:45', version: '2.4.1', time: '2 mins ago', severity: 'critical' },
{ id: 2, app: 'E-Commerce', message: 'Failed to load checkout session', version: '1.8.0', time: '15 mins ago', severity: 'high' },
{ id: 3, app: 'Fitness App', message: 'SocketException: Connection timed out', version: '0.9.5', time: '1 hour ago', severity: 'medium' },
]
function DashboardPage() { function DashboardPage() {
const { data: sessionData } = useSession() const { data: sessionData } = useSession()
const user = sessionData?.user const user = sessionData?.user
@@ -56,6 +49,20 @@ function DashboardPage() {
queryFn: () => fetch('/api/apps').then((r) => r.json()), queryFn: () => fetch('/api/apps').then((r) => r.json()),
}) })
const { data: recentErrors = [], isLoading: recentErrorsLoading } = useQuery({
queryKey: ['dashboard', 'recent-errors'],
queryFn: () => fetch('/api/dashboard/recent-errors').then((r) => r.json()),
})
const formatTimeAgo = (dateStr: string) => {
const diff = new Date().getTime() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes || 1} mins ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} hours ago`
return `${Math.floor(hours / 24)} days ago`
}
return ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
@@ -65,7 +72,7 @@ function DashboardPage() {
<Title order={2} className="gradient-text">Overview Dashboard</Title> <Title order={2} className="gradient-text">Overview Dashboard</Title>
<Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text> <Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text>
</Stack> </Stack>
<Button {/* <Button
variant="gradient" variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbApps size={18} />} leftSection={<TbApps size={18} />}
@@ -74,7 +81,7 @@ function DashboardPage() {
to="/apps" to="/apps"
> >
Manage All Apps Manage All Apps
</Button> </Button> */}
</Group> </Group>
{statsLoading ? ( {statsLoading ? (
@@ -86,29 +93,29 @@ function DashboardPage() {
value={stats?.totalApps || 0} value={stats?.totalApps || 0}
icon={TbApps} icon={TbApps}
color="brand-blue" color="brand-blue"
trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }} // trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
/> />
<StatsCard <StatsCard
title="New Errors" title="New Errors"
value={stats?.newErrors || 0} value={stats?.newErrors || 0}
icon={TbMessageReport} icon={TbMessageReport}
color="brand-purple" color="brand-purple"
trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }} // trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
/> />
<StatsCard <StatsCard
title="Active Users" title="Users"
value={stats?.activeUsers || 0} value={stats?.activeUsers || 0}
icon={TbUsers} icon={TbUsers}
color="teal" color="teal"
trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }} // trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
/> />
</SimpleGrid> </SimpleGrid>
)} )}
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Title order={3}>Registered Applications</Title> <Title order={3}>Registered Applications</Title>
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}> <Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />} component={Link} to="/apps">
View Report View All Apps
</Button> </Button>
</Group> </Group>
@@ -124,7 +131,7 @@ function DashboardPage() {
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Title order={3}>Recent Error Reports</Title> <Title order={3}>Recent Error Reports</Title>
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}> <Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />} component={Link} to="/bug-reports">
View All Errors View All Errors
</Button> </Button>
</Group> </Group>
@@ -141,23 +148,35 @@ function DashboardPage() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{recentErrors.map((error) => ( {recentErrorsLoading ? (
<Table.Tr>
<Table.Td colSpan={5} align="center" py="xl">
<Loader size="sm" type="dots" />
</Table.Td>
</Table.Tr>
) : recentErrors.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5} align="center" py="xl">
<Text c="dimmed" size="sm">No recent errors found.</Text>
</Table.Td>
</Table.Tr>
) : recentErrors.map((error: any) => (
<Table.Tr key={error.id}> <Table.Tr key={error.id}>
<Table.Td> <Table.Td>
<Text fw={600} size="sm">{error.app}</Text> <Text fw={600} size="sm" style={{ textTransform: 'uppercase' }}>{error.app}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm" c="dimmed">{error.message}</Text> <Text size="sm" c="dimmed" lineClamp={1}>{error.message}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge variant="light" color="gray">v{error.version}</Badge> <Badge variant="light" color="gray">v{error.version}</Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs" c="dimmed">{error.time}</Text> <Text size="xs" c="dimmed">{formatTimeAgo(error.time)}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge <Badge
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'} color={error.severity === 'OPEN' ? 'red' : error.severity === 'IN_PROGRESS' || error.severity === 'ON_HOLD' ? 'orange' : 'yellow'}
variant="dot" variant="dot"
> >
{error.severity.toUpperCase()} {error.severity.toUpperCase()}

View File

@@ -1,3 +1,4 @@
import { useLogin } from '@/frontend/hooks/useAuth'
import { import {
Alert, Alert,
Button, Button,
@@ -13,8 +14,7 @@ import {
import { createFileRoute, redirect } from '@tanstack/react-router' import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { FcGoogle } from 'react-icons/fc' import { FcGoogle } from 'react-icons/fc'
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb' import { TbAlertCircle, TbLock, TbLogin, TbMail } from 'react-icons/tb'
import { useLogin } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/login')({ export const Route = createFileRoute('/login')({
validateSearch: (search: Record<string, unknown>): { error?: string } => ({ validateSearch: (search: Record<string, unknown>): { error?: string } => ({
@@ -27,7 +27,7 @@ export const Route = createFileRoute('/login')({
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
}) })
if (data?.user) { if (data?.user) {
throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' }) throw redirect({ to: '/dashboard' })
} }
} catch (e) { } catch (e) {
if (e instanceof Error) return if (e instanceof Error) return
@@ -57,12 +57,6 @@ function LoginPage() {
Login Login
</Title> </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) && ( {(login.isError || searchError) && (
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light"> <Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
{login.isError ? login.error.message : 'Google login failed, please try again.'} {login.isError ? login.error.message : 'Google login failed, please try again.'}
@@ -95,18 +89,6 @@ function LoginPage() {
> >
Sign in Sign in
</Button> </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> </Stack>
</form> </form>
</Paper> </Paper>

View File

@@ -10,67 +10,119 @@ import {
Avatar, Avatar,
Box, Box,
Divider, Divider,
Pagination,
Center,
Tooltip,
} from '@mantine/core' } from '@mantine/core'
import { useState, useMemo } from 'react' import { useState, useMemo, useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb' import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout' import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/logs')({ export const Route = createFileRoute('/logs')({
component: GlobalLogsPage, component: GlobalLogsPage,
}) })
const timelineData = [ const fetcher = (url: string) => fetch(url).then((res) => res.json())
{
date: 'TODAY', const typeConfig: Record<string, { color: string; icon?: any }> = {
logs: [ CREATE: { color: 'blue', icon: TbCheck },
{ id: 1, time: '12:12 PM', operator: 'Budi Santoso', app: 'Desa+', color: 'blue', content: <>generated document <Badge variant="light" color="gray" radius="sm">Surat Domisili</Badge> for <Badge variant="light" color="blue" radius="sm">Sukatani</Badge></> }, UPDATE: { color: 'teal', icon: TbCheck },
{ id: 2, time: '11:42 AM', operator: 'Siti Aminah', app: 'Desa+', color: 'teal', content: <>uploaded financial report <Badge variant="light" color="gray" radius="sm">Realisasi Q1</Badge> for <Badge variant="light" color="teal" radius="sm">Sukamaju</Badge></> }, DELETE: { color: 'red', icon: TbX },
{ id: 3, time: '10:12 AM', operator: 'System', app: 'Desa+', color: 'red', icon: TbX, content: <>experienced failure in <Badge variant="light" color="violet" radius="sm">SIAK Sync</Badge> at <Badge variant="light" color="red" radius="sm" leftSection={<TbX size={12}/>}>Cikini</Badge></>, message: { title: 'Sync Operation Failed (NullPointerException)', text: 'NullPointerException at village_sync.dart:45. The server returned a timeout error while waiting for the master database replica connection. Auto-retry scheduled in 15 minutes.' } }, LOGIN: { color: 'green', icon: TbClock },
{ id: 4, time: '09:42 AM', operator: 'Jane Smith', app: 'E-Commerce', color: 'orange', icon: TbCheck, content: <>resolved payment gateway issue for <Badge variant="light" color="orange" radius="sm">E-Commerce</Badge> checkout</> }, LOGOUT: { color: 'orange', icon: TbClock },
] }
},
{ const getRoleColor = (role: string) => {
date: 'YESTERDAY', const r = (role || '').toLowerCase()
logs: [ if (r.includes('super')) return 'red'
{ id: 5, time: '05:10 AM', operator: 'System', app: 'System', color: 'cyan', content: <>completed automated <Badge variant="light" color="cyan" radius="sm">Nightly Backup</Badge> for all 138 villages</> }, if (r.includes('admin')) return 'brand-blue'
{ id: 6, time: '04:50 AM', operator: 'Rahmat Hidayat', app: 'Desa+', color: 'green', content: <>granted Admin access to <Text component="span" fw={600}>Desa Bojong Gede</Text> operator</> }, if (r.includes('developer')) return 'violet'
{ id: 7, time: '03:42 AM', operator: 'System', app: 'Fitness App', color: 'red', icon: TbX, content: <>detected SocketException across <Badge variant="light" color="violet" radius="sm">Fitness App</Badge> wearable sync operations.</> }, return 'gray'
{ id: 8, time: '02:33 AM', operator: 'Agus Setiawan', app: 'Desa+', color: 'blue', content: <>verified 145 <Badge variant="light" color="gray" radius="sm">Surat Kematian</Badge> entries in batch.</> }, }
]
}, function groupLogsByDate(logs: any[]) {
{ const groups: Record<string, any[]> = {}
date: '12 APRIL, 2026',
logs: [ const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
{ id: 9, time: '03:42 AM', operator: 'Amel', app: 'Desa+', color: 'indigo', content: <>changed version configurations rolling out <Badge variant="light" color="gray" radius="sm">Desa+ v2.4.1</Badge></> }, const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
{ id: 10, time: '02:10 AM', operator: 'John Doe', app: 'E-Commerce', color: 'pink', content: <>updated App setting <Badge variant="light" color="gray" radius="sm">Require OTP on Login</Badge> <Text component="span" c="violet" fw={600} size="sm" style={{ cursor: 'pointer' }}>View Details</Text></> },
] logs.forEach(log => {
} const dateObj = new Date(log.createdAt)
] let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
if (dateStr === today) dateStr = 'TODAY'
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
if (!groups[dateStr]) groups[dateStr] = []
const timeStr = dateObj.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
groups[dateStr].push({
id: log.id,
time: timeStr,
user: log.user,
type: log.type,
content: log.message,
color: log.user ? getRoleColor(log.user.role) : 'gray',
icon: typeConfig[log.type as string]?.icon
})
})
// We want to keep the order as they came from the API (sorted by createdAt desc)
// but grouped by date. Object.entries might mess up the order if dates are not sequential.
// However, since the source logs are sorted, the first encounter of a date defines the group order.
const result: { date: string; logs: any[] }[] = []
const seenDates = new Set<string>()
logs.forEach(log => {
const dateObj = new Date(log.createdAt)
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
if (dateStr === today) dateStr = 'TODAY'
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
if (!seenDates.has(dateStr)) {
result.push({ date: dateStr, logs: groups[dateStr] })
seenDates.add(dateStr)
}
})
return result
}
function GlobalLogsPage() { function GlobalLogsPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [appFilter, setAppFilter] = useState<string | null>(null) const [debouncedSearch, setDebouncedSearch] = useState('')
const [operatorFilter, setOperatorFilter] = useState<string | null>(null) const [logType, setLogType] = useState<string | null>('all')
const [operatorId, setOperatorId] = useState<string | null>('all')
const [page, setPage] = useState(1)
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(timer)
}, [search])
const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher)
const operatorOptions = useMemo(() => {
if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All Operators' }]
return [
{ value: 'all', label: 'All Operators' },
...operatorsData.map((op: any) => ({ value: op.id, label: op.name }))
]
}, [operatorsData])
const { data: response, isLoading } = useSWR(
API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'),
fetcher
)
const filteredTimeline = useMemo(() => { const filteredTimeline = useMemo(() => {
return timelineData if (!response?.data) return []
.map(group => { return groupLogsByDate(response.data)
const filteredLogs = group.logs.filter(log => { }, [response?.data])
if (appFilter && log.app !== appFilter) return false;
if (operatorFilter && log.operator !== operatorFilter) return false;
if (search) {
const lSearch = search.toLowerCase();
if (!log.operator.toLowerCase().includes(lSearch) && !log.app.toLowerCase().includes(lSearch)) {
return false;
}
}
return true;
});
return { ...group, logs: filteredLogs };
})
.filter(group => group.logs.length > 0);
}, [search, appFilter, operatorFilter]);
return ( return (
<DashboardLayout> <DashboardLayout>
@@ -79,134 +131,156 @@ function GlobalLogsPage() {
{/* Header Controls */} {/* Header Controls */}
<Group mb="xl" gap="md"> <Group mb="xl" gap="md">
<TextInput <TextInput
placeholder="Search operator or app..." placeholder="Search operator or message..."
leftSection={<TbSearch size={16} />} leftSection={<TbSearch size={16} />}
radius="md" radius="md"
w={220} w={250}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => {
setSearch(e.currentTarget.value)
setPage(1)
}}
/> />
<Select <Select
placeholder="All Applications" placeholder="Log Type"
data={['Desa+', 'E-Commerce', 'Fitness App', 'System']} data={[
{ value: 'all', label: 'All Types' },
{ value: 'CREATE', label: 'Create' },
{ value: 'UPDATE', label: 'Update' },
{ value: 'DELETE', label: 'Delete' },
{ value: 'LOGIN', label: 'Login' },
{ value: 'LOGOUT', label: 'Logout' },
]}
radius="md" radius="md"
w={160} w={160}
clearable value={logType}
value={appFilter} onChange={(val) => {
onChange={setAppFilter} setLogType(val)
setPage(1)
}}
/> />
<Select <Select
placeholder="All Operators" placeholder="Operator"
data={['Agus Setiawan', 'Amel', 'Budi Santoso', 'Jane Smith', 'John Doe', 'Rahmat Hidayat', 'Siti Aminah', 'System']} data={operatorOptions}
searchable
radius="md" radius="md"
w={160} w={200}
clearable value={operatorId}
value={operatorFilter} onChange={(val) => {
onChange={setOperatorFilter} setOperatorId(val)
setPage(1)
}}
/> />
</Group> </Group>
{/* Timeline Content */} {/* Timeline Content */}
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)' }}> <Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
{filteredTimeline.length === 0 ? ( {isLoading ? (
<Center py="xl">
<Text c="dimmed">Loading logs...</Text>
</Center>
) : filteredTimeline.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text> <Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
) : filteredTimeline.map((group, groupIndex) => ( ) : (
<Box key={group.date}> <>
<Text {filteredTimeline.map((group, groupIndex) => (
size="xs" <Box key={group.date}>
fw={700} <Text
c="dimmed" size="xs"
mt={groupIndex > 0 ? "xl" : 0} fw={700}
mb="lg" c="dimmed"
style={{ textTransform: 'uppercase' }} mt={groupIndex > 0 ? "xl" : 0}
> mb="md"
{group.date} style={{ textTransform: 'uppercase' }}
</Text> >
{group.date}
<Stack gap={0} pl={4}> </Text>
{group.logs.map((log, logIndex) => {
const isLastLog = logIndex === group.logs.length - 1;
return ( <Stack gap={0} pl={4}>
<Group {group.logs.map((log, logIndex) => {
key={log.id} const isLastLog = logIndex === group.logs.length - 1;
wrap="nowrap"
align="flex-start" return (
gap="lg" <Group
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }} key={log.id}
> wrap="nowrap"
{/* Left: Time */} align="flex-start"
<Text gap="lg"
size="xs" style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
c="dimmed" >
w={70} {/* Left: Time */}
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }} <Text
> size="xs"
{log.time} c="dimmed"
</Text> w={70}
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
{/* Middle: Line & Avatar */}
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
{/* Vertical Line */}
{!isLastLog && (
<Box
style={{
position: 'absolute',
top: 24,
bottom: -8,
left: '50%',
transform: 'translateX(-50%)',
width: 1,
backgroundColor: 'rgba(128,128,128,0.2)'
}}
/>
)}
{/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}>
{log.icon ? (
<Avatar size={24} radius="xl" color={log.color} variant="light">
<log.icon size={14} />
</Avatar>
) : (
<Avatar size={24} radius="xl" color={log.color}>
{log.operator.charAt(0)}
</Avatar>
)}
</Box>
</Box>
{/* Right: Content */}
<Box style={{ flexGrow: 1, marginTop: 2 }}>
<Text size="sm">
<Text component="span" fw={600} mr={4}>{log.operator}</Text>
{log.content}
</Text>
{log.message && (
<Paper
withBorder
p="md"
radius="md"
mt="sm"
style={{ maxWidth: 800, backgroundColor: 'transparent' }}
> >
<Text size="sm" fw={600} mb={4}>{log.message.title}</Text> {log.time}
<Text size="sm" c="dimmed"> </Text>
{log.message.text}
{/* Middle: Line & Avatar */}
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
{/* Vertical Line */}
{!isLastLog && (
<Box
style={{
position: 'absolute',
top: 24,
bottom: -8,
left: '50%',
transform: 'translateX(-50%)',
width: 1,
backgroundColor: 'rgba(128,128,128,0.2)'
}}
/>
)}
{/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}>
<Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
<Avatar
size={24}
radius="xl"
color={log.color}
variant="light"
src={log.user?.image}
style={{ cursor: 'help' }}
>
{log.icon ? <log.icon size={14} /> : (log.user?.name?.charAt(0) || '?')}
</Avatar>
</Tooltip>
</Box>
</Box>
{/* Right: Content */}
<Box style={{ flexGrow: 1, marginTop: 2 }}>
<Text size="sm">
<Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
{log.content}
</Text> </Text>
</Paper> </Box>
)} </Group>
</Box> )
</Group> })}
) </Stack>
})}
</Stack> {groupIndex < filteredTimeline.length - 1 && (
<Divider my="xl" color="rgba(128,128,128,0.1)" />
{groupIndex < timelineData.length - 1 && ( )}
<Divider my="xl" color="rgba(128,128,128,0.1)" /> </Box>
))}
{response?.totalPages > 1 && (
<Center mt="xl">
<Pagination
total={response.totalPages}
value={page}
onChange={setPage}
radius="md"
/>
</Center>
)} )}
</Box> </>
))} )}
</Paper> </Paper>
</Container> </Container>
</DashboardLayout> </DashboardLayout>

View File

@@ -30,9 +30,8 @@ export const Route = createFileRoute('/profile')({
}) })
const roleBadgeColor: Record<string, string> = { const roleBadgeColor: Record<string, string> = {
USER: 'blue',
ADMIN: 'violet', ADMIN: 'violet',
SUPER_ADMIN: 'red', DEVELOPER: 'red',
} }
function ProfilePage() { function ProfilePage() {

View File

@@ -1,73 +1,212 @@
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import { import {
ActionIcon, ActionIcon,
Avatar,
Badge, Badge,
Button, Button,
Card, Card,
Container, Container,
Divider,
Group, Group,
List,
Modal,
Pagination,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack, Stack,
Table, Table,
Tabs,
Text, Text,
TextInput, TextInput,
Title,
Paper,
Tabs,
Avatar,
SimpleGrid,
ThemeIcon, ThemeIcon,
List, Title,
Box,
Divider,
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { import { useEffect, useState } from 'react'
TbPlus, import {
TbSearch,
TbPencil,
TbTrash,
TbUserCheck,
TbShieldCheck,
TbAccessPoint, TbAccessPoint,
TbCircleCheck, TbCircleCheck,
TbClock, TbCircleX,
TbApps, TbPencil,
TbPlus,
TbSearch,
TbShieldCheck,
TbTrash,
TbUserCheck
} from 'react-icons/tb' } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout' import useSWR from 'swr'
import { StatsCard } from '@/frontend/components/StatsCard' import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
export const Route = createFileRoute('/users')({ export const Route = createFileRoute('/users')({
component: UsersPage, component: UsersPage,
}) })
const mockUsers = [ const fetcher = (url: string) => fetch(url).then((res) => res.json())
{ id: 1, name: 'Amel', email: 'amel@company.com', role: 'SUPER_ADMIN', apps: 'All', status: 'Online', lastActive: 'Now' },
{ id: 2, name: 'John Doe', email: 'john@company.com', role: 'DEVELOPER', apps: 'Desa+, Fitness App', status: 'Offline', lastActive: '2h ago' }, const getRoleColor = (role: string) => {
{ id: 3, name: 'Jane Smith', email: 'jane@company.com', role: 'QA', apps: 'E-Commerce', status: 'Online', lastActive: '12m ago' }, const r = (role || '').toLowerCase()
{ id: 4, name: 'Rahmat Hidayat', email: 'rahmat@company.com', role: 'DEVELOPER', apps: 'Desa+', status: 'Online', lastActive: 'Now' }, if (r.includes('super')) return 'red'
] if (r.includes('admin')) return 'brand-blue'
if (r.includes('developer')) return 'violet'
return 'gray'
}
const roles = [ const roles = [
{ {
name: 'SUPER_ADMIN', name: 'DEVELOPER',
count: 2,
color: 'red', color: 'red',
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors'] permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
}, },
{ {
name: 'DEVELOPER', name: 'ADMIN',
count: 12,
color: 'brand-blue',
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
},
{
name: 'QA',
count: 5,
color: 'orange', color: 'orange',
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features'] permissions: ['View All Apps', 'View Logs', 'Report Errors']
}, },
] ]
function UsersPage() { function UsersPage() {
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [page, setPage] = useState(1)
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(timer)
}, [search])
const { data: stats, mutate: mutateStats } = useSWR(API_URLS.getOperatorStats(), fetcher)
const { data: response, isLoading, mutate: mutateOperators } = useSWR(
API_URLS.getOperators(page, debouncedSearch),
fetcher
)
const operators = response?.data || []
// ── Create User Modal ──
const [createOpened, { open: openCreate, close: closeCreate }] = useDisclosure(false)
const [isCreating, setIsCreating] = useState(false)
const [createForm, setCreateForm] = useState({
name: '',
email: '',
password: '',
role: 'USER',
})
const handleCreateUser = async () => {
if (!createForm.name || !createForm.email || !createForm.password) {
notifications.show({ title: 'Validation Error', message: 'Please fill in all required fields.', color: 'red' })
return
}
setIsCreating(true)
try {
const res = await fetch(API_URLS.createOperator(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
})
if (res.ok) {
notifications.show({ title: 'Success', message: 'User has been created.', color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
mutateStats()
closeCreate()
setCreateForm({ name: '', email: '', password: '', role: 'USER' })
} else {
const err = await res.json()
throw new Error(err.error || 'Failed to create user')
}
} catch (e: any) {
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
} finally {
setIsCreating(false)
}
}
// ── Edit User Modal ──
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
const [isEditing, setIsEditing] = useState(false)
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({
name: '',
email: '',
role: '',
})
const handleOpenEdit = (user: any) => {
setEditingUserId(user.id)
setEditForm({ name: user.name, email: user.email, role: user.role })
openEdit()
}
const handleEditUser = async () => {
if (!editingUserId || !editForm.name || !editForm.email) return
setIsEditing(true)
try {
const res = await fetch(API_URLS.editOperator(editingUserId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
})
if (res.ok) {
notifications.show({ title: 'Success', message: 'User has been updated.', color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
closeEdit()
} else {
throw new Error('Failed to update user')
}
} catch (e) {
notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
} finally {
setIsEditing(false)
}
}
// ── Delete User ──
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deletingUser, setDeletingUser] = useState<any>(null)
const handleOpenDelete = (user: any) => {
setDeletingUser(user)
openDelete()
}
const handleDeleteUser = async () => {
if (!deletingUser) return
setIsDeleting(true)
try {
const res = await fetch(API_URLS.deleteOperator(deletingUser.id), {
method: 'DELETE',
})
if (res.ok) {
notifications.show({ title: 'Success', message: 'User has been deleted.', color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
mutateStats()
closeDelete()
} else {
const err = await res.json()
throw new Error(err.error || 'Failed to delete user')
}
} catch (e: any) {
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
} finally {
setIsDeleting(false)
}
}
return ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
@@ -80,9 +219,9 @@ function UsersPage() {
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
<StatsCard title="Total Staff" value={24} icon={TbUserCheck} color="brand-blue" /> <StatsCard title="Total Staff" value={stats?.totalStaff ?? 0} icon={TbUserCheck} color="brand-blue" />
<StatsCard title="Active Now" value={18} icon={TbAccessPoint} color="teal" /> <StatsCard title="Active Now" value={stats?.activeNow ?? 0} icon={TbAccessPoint} color="teal" />
<StatsCard title="Security Roles" value={3} icon={TbShieldCheck} color="purple-primary" /> <StatsCard title="Security Roles" value={stats?.rolesCount ?? 0} icon={TbShieldCheck} color="purple-primary" />
</SimpleGrid> </SimpleGrid>
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md"> <Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
@@ -100,15 +239,23 @@ function UsersPage() {
radius="md" radius="md"
w={350} w={350}
variant="filled" variant="filled"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value)
setPage(1)
}}
/> />
<Button {isDeveloper && (
variant="gradient" <Button
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} variant="gradient"
leftSection={<TbPlus size={18} />} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
radius="md" leftSection={<TbPlus size={18} />}
> radius="md"
Add New User onClick={openCreate}
</Button> >
Add New User
</Button>
)}
</Group> </Group>
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}> <Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
@@ -117,56 +264,72 @@ function UsersPage() {
<Table.Tr> <Table.Tr>
<Table.Th>Name & Contact</Table.Th> <Table.Th>Name & Contact</Table.Th>
<Table.Th>Role</Table.Th> <Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>Joined Date</Table.Th>
<Table.Th>App Access</Table.Th>
<Table.Th>Actions</Table.Th> <Table.Th>Actions</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{mockUsers.map((user) => ( {isLoading ? (
<Table.Tr key={user.id}> <Table.Tr>
<Table.Td> <Table.Td colSpan={4} align="center">
<Group gap="sm"> <Text size="sm" c="dimmed" py="xl">Loading user data...</Text>
<Avatar size="sm" radius="xl" color="brand-blue">{user.name.charAt(0)}</Avatar>
<Stack gap={0}>
<Text fw={600} size="sm">{user.name}</Text>
<Text size="xs" c="dimmed">{user.email}</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light" color={user.role === 'SUPER_ADMIN' ? 'red' : user.role === 'DEVELOPER' ? 'brand-blue' : 'orange'}>
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={6}>
<Box style={{ width: 6, height: 6, borderRadius: '50%', background: user.status === 'Online' ? '#10b981' : '#94a3b8' }} />
<Text size="xs" fw={500}>{user.status}</Text>
<Text size="xs" c="dimmed" ml="xs"><TbClock size={10} style={{ marginBottom: -2 }} /> {user.lastActive}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap={4}>
<TbApps size={12} color="gray" />
<Text size="xs" fw={500}>{user.apps}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="light" size="sm" color="blue">
<TbPencil size={14} />
</ActionIcon>
<ActionIcon variant="light" size="sm" color="red">
<TbTrash size={14} />
</ActionIcon>
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ) : operators.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4} align="center">
<Text size="sm" c="dimmed" py="xl">No users found.</Text>
</Table.Td>
</Table.Tr>
) : (
operators.map((user: any) => (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="sm">
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
{user.name.charAt(0)}
</Avatar>
<Stack gap={0}>
<Text fw={600} size="sm">{user.name}</Text>
<Text size="xs" c="dimmed">{user.email}</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light" color={getRoleColor(user.role)}>
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
<TbPencil size={14} />
</ActionIcon>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
<TbTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Paper> </Paper>
{response?.totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
total={response.totalPages}
value={page}
onChange={setPage}
radius="md"
/>
</Group>
)}
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
@@ -179,16 +342,15 @@ function UsersPage() {
<ThemeIcon size="xl" radius="md" color={role.color} variant="light"> <ThemeIcon size="xl" radius="md" color={role.color} variant="light">
<TbShieldCheck size={28} /> <TbShieldCheck size={28} />
</ThemeIcon> </ThemeIcon>
<Badge variant="default" size="lg" radius="sm">{role.count} Users</Badge>
</Group> </Group>
<Stack gap={4}> <Stack gap={4}>
<Title order={4}>{role.name.replace('_', ' ')}</Title> <Title order={4}>{role.name.replace('_', ' ')}</Title>
<Text size="sm" c="dimmed">Core role for secure app management.</Text> <Text size="sm" c="dimmed">Core role for secure app management.</Text>
</Stack> </Stack>
<Divider /> <Divider />
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text> <Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
<List <List
spacing="xs" spacing="xs"
@@ -205,9 +367,9 @@ function UsersPage() {
))} ))}
</List> </List>
<Button fullWidth variant="light" color={role.color} mt="md" radius="md"> {/* <Button fullWidth variant="light" color={role.color} mt="md" radius="md">
Edit Permissions Edit Permissions
</Button> </Button> */}
</Stack> </Stack>
</Card> </Card>
))} ))}
@@ -216,7 +378,127 @@ function UsersPage() {
</Tabs> </Tabs>
</Stack> </Stack>
</Container> </Container>
{/* Create User Modal */}
<Modal
opened={createOpened}
onClose={closeCreate}
title={<Text fw={700} size="lg">Add New User</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
/>
<PasswordInput
label="Password"
placeholder="Enter password"
required
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
/>
<Select
label="Role"
data={[
{ value: 'ADMIN', label: 'Admin' },
{ value: 'DEVELOPER', label: 'Developer' },
]}
value={createForm.role}
onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isCreating}
onClick={handleCreateUser}
>
Create User
</Button>
</Stack>
</Modal>
{/* Edit User Modal */}
<Modal
opened={editOpened}
onClose={closeEdit}
title={<Text fw={700} size="lg">Edit User</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
/>
<Select
label="Role"
data={[
{ value: 'ADMIN', label: 'Admin' },
{ value: 'DEVELOPER', label: 'Developer' },
]}
value={editForm.role}
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isEditing}
onClick={handleEditUser}
>
Save Changes
</Button>
</Stack>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={deleteOpened}
onClose={closeDelete}
title={<Text fw={700} size="lg">Delete User</Text>}
radius="xl"
size="sm"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="sm">
Are you sure you want to delete <Text component="span" fw={700}>{deletingUser?.name}</Text>? This action cannot be undone.
</Text>
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={closeDelete}>
Cancel
</Button>
<Button color="red" loading={isDeleting} onClick={handleDeleteUser}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
</DashboardLayout> </DashboardLayout>
) )
} }

View File

@@ -84,7 +84,7 @@ body {
transition: var(--transition-smooth); transition: var(--transition-smooth);
} }
.sidebar-nav-item.active { .sidebar-nav-item[data-active] {
background: var(--gradient-blue-purple); background: var(--gradient-blue-purple);
color: white; color: white;
} }

View File

@@ -7,7 +7,7 @@ import { env } from './lib/env'
const isProduction = env.NODE_ENV === 'production' const isProduction = env.NODE_ENV === 'production'
// ─── Route Classification ────────────────────────────── // ─── Route Classification ──────────────────────────────
const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health'] const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health', '/docs']
function isApiRoute(pathname: string): boolean { function isApiRoute(pathname: string): boolean {
return API_PREFIXES.some((p) => pathname.startsWith(p)) || pathname === '/health' return API_PREFIXES.some((p) => pathname.startsWith(p)) || pathname === '/health'

View File

@@ -16,4 +16,5 @@ export const env = {
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'), GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'), GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean), SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean),
API_KEY: required('API_KEY'),
} as const } as const

18
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,18 @@
import { prisma } from './db'
import { LogType } from '../../generated/prisma'
export async function createSystemLog(userId: string, type: LogType, message: string) {
try {
return await prisma.log.create({
data: {
userId,
type,
message,
},
})
} catch (error) {
console.error('[Logger Error]', error)
// Don't throw, we don't want logging errors to break the main application flow
return null
}
}

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@@ -9,7 +9,7 @@ export function createTestApp() {
} }
/** Create a test user with hashed password, returns the user record */ /** Create a test user with hashed password, returns the user record */
export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'USER' | 'ADMIN' | 'SUPER_ADMIN' = 'USER') { export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'ADMIN' | 'DEVELOPER' = 'DEVELOPER') {
const hashed = await Bun.password.hash(password, { algorithm: 'bcrypt' }) const hashed = await Bun.password.hash(password, { algorithm: 'bcrypt' })
return prisma.user.upsert({ return prisma.user.upsert({
where: { email }, where: { email },