26 Commits

Author SHA1 Message Date
eb77aca715 Merge pull request 'upd: stack trace hide/show toggle and date format DD/MM/YYYY 24h' (#12) from amalia/22-apr-26 into main
Reviewed-on: #12
2026-04-22 17:32:17 +08:00
041d891a8d upd: stack trace hide/show toggle and date format DD/MM/YYYY 24h
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:25:50 +08:00
de5ec1af93 Merge pull request 'upd: swagger docs, api key auth, bug fixes' (#11) from amalia/21-apr-26 into main
Reviewed-on: #11
2026-04-21 17:33:17 +08:00
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
56 changed files with 4722 additions and 864 deletions

View File

@@ -16,6 +16,9 @@ GOOGLE_CLIENT_SECRET=
# Role
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_NOTIFY_TOKEN=
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>`
- 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.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 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)`.
- `src/index.tsx` — Server entry. Adds Vite middleware (dev) or static file serving (prod), click-to-source editor integration, and `.listen()`.
- `src/serve.ts` — Dev entry (`bun --watch src/serve.ts`). Dynamic import workaround for Bun EADDRINUSE race.
- `bun <file>` not `node` / `ts-node`
- `bun test` not `jest` / `vitest`
- `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)
- Client singleton: `src/lib/db.ts` — import `{ prisma }` from here
- Seed: `prisma/seed.ts` — demo users with `Bun.password.hash` bcrypt
- Commands: `bun run db:migrate`, `bun run db:seed`, `bun run db:generate`
# Database
bun run db:migrate # prisma migrate dev
bun run db:seed # seed demo data
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
- 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
## Architecture
## 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
- 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
### Database
## 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.
- 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`.
**Schema models:** `User`, `Session`, `App`, `Log`, `Bug`, `BugImage`, `BugLog`
**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
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
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
```
## Dev Tools
- `tests/helpers.ts``createTestApp()`, `seedTestUser()`, `createTestSession()`, `cleanupTestData()`
- Integration tests use `createApp().handle(new Request(...))` — no server needed
- 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.
## 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
- **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.
- **HMR:** Vite 8 + `@vitejs/plugin-react` v6. `dedupeRefreshPlugin` in `src/vite.ts` prevents double React Refresh injection.
- **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`).

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,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
@@ -8,9 +7,11 @@
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10",
@@ -26,11 +27,14 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",
"@playwright/mcp": "^0.0.70",
"@playwright/test": "^1.59.1",
"@tanstack/router-vite-plugin": "^1.166.27",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1",
"playwright": "^1.59.1",
"prisma": "6",
"puppeteer-core": "^24.40.0",
"typescript": "^6.0.2",
@@ -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/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/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/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=="],
"@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/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=="],
"@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=="],
"@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=="],
"@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=="],
"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=="],
"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=="],
"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=="],
"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=="],
@@ -515,6 +539,8 @@
"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=="],
"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=="],
"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=="],
"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=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"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=="],
"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=="],
@@ -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=="],
"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-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=="],
"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-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-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=="],
"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=="],
"zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="],
"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=="],
@@ -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=="],
"@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=="],
"@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=="],
"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=="],
"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/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=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"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/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=="],
"@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=="],
"@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:studio": "bunx prisma studio",
"db:generate": "bunx prisma generate",
"db:push": "bunx prisma db push"
"db:push": "bunx prisma db push",
"mcp:playwright": "playwright-mcp --headless --port 3000"
},
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
@@ -43,11 +45,14 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",
"@playwright/mcp": "^0.0.70",
"@playwright/test": "^1.59.1",
"@tanstack/router-vite-plugin": "^1.166.27",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1",
"playwright": "^1.59.1",
"prisma": "6",
"puppeteer-core": "^24.40.0",
"typescript": "^6.0.2",

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

View File

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

View File

@@ -1,11 +1,56 @@
import { cors } from '@elysiajs/cors'
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 { 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() {
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(html())
@@ -24,34 +69,63 @@ export function createApp() {
})
})
// API routes
.get('/health', () => ({ status: 'ok' }))
// ─── Health ───────────────────────────────────────
.get('/health', () => ({ status: 'ok' }), {
detail: {
summary: 'Health Check',
description: 'Memeriksa apakah server sedang berjalan.',
tags: ['System'],
},
})
// ─── Auth API ──────────────────────────────────────
.post('/api/auth/login', async ({ request, set }) => {
const { email, password } = (await request.json()) as { email: string; password: string }
.post('/api/auth/login', async ({ body, set }) => {
const { email, password } = body
let user = await prisma.user.findUnique({ where: { email } })
if (!user || !(await Bun.password.verify(password, user.password))) {
set.status = 401
return { error: 'Email atau password salah' }
}
// Auto-promote super admin from env
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') {
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } })
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
}
const token = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
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 } }
}, {
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 }) => {
const cookie = request.headers.get('cookie') ?? ''
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'
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 }) => {
@@ -68,112 +142,669 @@ export function createApp() {
return { user: null }
}
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 ──────────────────────────────────
.get('/api/auth/google', ({ request, set }) => {
const origin = new URL(request.url).origin
const params = new URLSearchParams({
client_id: env.GOOGLE_CLIENT_ID,
redirect_uri: `${origin}/api/auth/callback/google`,
response_type: 'code',
scope: 'openid email profile',
access_type: 'offline',
prompt: 'consent',
// ─── Dashboard API ─────────────────────────────────
.get('/api/dashboard/stats', async () => {
const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } })
const users = await prisma.user.count()
return {
totalApps: 1,
newErrors: newErrors,
activeUsers: users,
trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 }
}
}, {
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 }) => {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const origin = url.origin
if (!code) {
set.status = 302; set.headers['location'] ='/login?error=google_failed'
return
// ─── Apps API ──────────────────────────────────────
.get('/api/apps', async ({ query }) => {
const search = query.search || ''
const where: any = {}
if (search) {
where.name = { contains: search, mode: 'insensitive' }
}
// Exchange code for tokens
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: `${origin}/api/auth/callback/google`,
grant_type: 'authorization_code',
const apps = await prisma.app.findMany({
where,
include: {
_count: { select: { bugs: true } },
bugs: { where: { status: 'OPEN' }, select: { id: true } },
},
orderBy: { name: 'asc' },
})
return apps.map((app) => ({
id: app.id,
name: app.name,
status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active',
errors: app.bugs.length,
version: app.version ?? '-',
maintenance: app.maintenance,
}))
}, {
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) {
set.status = 302; set.headers['location'] ='/login?error=google_failed'
return
return {
data: logs,
totalPages: Math.ceil(total / limit),
totalItems: total
}
const tokens = (await tokenRes.json()) as { access_token: string }
// Get user info
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
})
if (!userInfoRes.ok) {
set.status = 302; set.headers['location'] ='/login?error=google_failed'
return
}
const googleUser = (await userInfoRes.json()) as { email: string; name: string }
// Upsert user (no password for Google users)
const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email)
const user = await prisma.user.upsert({
where: { email: googleUser.email },
update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) },
create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' },
})
// Create session
const token = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
}, {
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 pesan log atau nama pengguna' })),
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"' })),
}),
detail: {
summary: 'List Activity Logs',
description: 'Mengembalikan log aktivitas sistem dengan pagination. Mendukung filter berdasarkan tipe log (CREATE, UPDATE, DELETE, LOGIN, LOGOUT) dan pengguna.',
tags: ['Logs'],
},
})
// ─── Monitoring API ────────────────────────────────
.get('/api/dashboard/stats', () => ({
totalApps: 3,
newErrors: 185,
activeUsers: '24.5k',
trends: { totalApps: 1, newErrors: 12, activeUsers: 5.2 }
}))
.get('/api/apps', () => [
{ id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
{ id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' },
{ id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' },
])
.get('/api/apps/:appId', ({ params: { appId } }) => {
const apps = {
'desa-plus': { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
.post('/api/logs', async ({ 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
}
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 ───────────────────────────────────
.get('/api/hello', () => ({
message: 'Hello, world!',
method: 'GET',
}))
}), {
detail: { summary: 'Hello GET', tags: ['System'] },
})
.put('/api/hello', () => ({
message: 'Hello, world!',
method: 'PUT',
}))
}), {
detail: { summary: 'Hello PUT', tags: ['System'] },
})
.get('/api/hello/:name', ({ params }) => ({
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 { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
import { TbChevronRight, TbDeviceMobile } from 'react-icons/tb'
interface AppCardProps {
id: string
name: string
status: 'active' | 'warning' | 'error'
users: number
users?: number
errors: number
version: string
maintenance?: boolean
}
export function AppCard({ id, name, status, users, errors, version }: AppCardProps) {
export function AppCard({ id, name, status, errors, version }: AppCardProps) {
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
@@ -46,12 +47,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
</Avatar>
<Stack gap={0}>
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
<Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text>
{/* <Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text> */}
</Stack>
</Group>
<Badge color={statusColor} variant="dot" size="sm">
{/* <Badge color={statusColor} variant="dot" size="sm">
{status.toUpperCase()}
</Badge>
</Badge> */}
</Group>
{/* <Stack gap="md" mt="sm">

View File

@@ -1,15 +1,15 @@
import {
Paper,
Stack,
Text,
Group,
ThemeIcon,
Box,
import { BarChart, LineChart } from '@mantine/charts'
import {
Badge,
Box,
Group,
Paper,
Stack,
Text,
ThemeIcon,
useMantineTheme
} from '@mantine/core'
import { LineChart, BarChart } from '@mantine/charts'
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb'
interface ChartProps {
data?: any[]
@@ -18,7 +18,7 @@ interface ChartProps {
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme()
return (
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
<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>
</Box>
</Group>
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
{isLoading ? '...' : 'Live'}
</Badge>
{
isLoading && (
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
...
</Badge>
)
}
</Group>
<Box h={300} mt="lg">
@@ -48,6 +53,9 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
gridAxis="x"
withTooltip
tooltipAnimationDuration={200}
tooltipProps={{
allowEscapeViewBox: { x: true, y: false },
}}
styles={{
root: {
'.recharts-line-curve': {
@@ -86,17 +94,38 @@ export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps)
h={300}
data={data}
dataKey="village"
series={[{ name: 'activity', color: 'indigo.6' }]}
series={[{ name: 'activity', color: 'blue.6' }]} // Menggunakan warna dari theme
withTooltip
tickLine="none"
gridAxis="y"
barProps={{
radius: [8, 8, 4, 4],
radius: [8, 8, 0, 0],
fill: 'url(#barGradient)', // Menggunakan gradient yang Anda buat
}}
styles={{
bar: {
fill: 'url(#barGradient)',
tooltipProps={{
cursor: { fill: '#373A40', opacity: 0.4 },
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,39 +1,45 @@
import { useState } from 'react'
import useSWR from 'swr'
import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Button,
ActionIcon,
TextInput,
Table,
Avatar,
Badge,
Box,
Button,
Container,
Divider,
Group,
Modal,
Pagination,
ThemeIcon,
Paper,
ScrollArea,
Tooltip,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
Switch,
} 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 { useState } from 'react'
import {
TbBriefcase,
TbCircleCheck,
TbCircleX,
TbEdit,
TbHome2,
TbId,
TbMail,
TbPhone,
TbPlus,
TbSearch,
TbUsers,
TbX,
TbMail,
TbPhone,
TbId,
TbBriefcase,
TbHome2,
TbCircleCheck,
TbCircleX,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/users/')({
@@ -46,12 +52,17 @@ interface APIUser {
nik: string
phone: string
email: string
gender: string
isWithoutOTP: boolean
isActive: boolean
role: string
village: string
group: string
position?: string
idUserRole: string
idVillage: string
idGroup: string
idPosition: string
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
@@ -65,7 +76,7 @@ function UsersIndexPage() {
const isDesaPlus = appId === 'desa-plus'
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 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 = () => {
setSearch('')
setSearchQuery('')
@@ -126,11 +341,279 @@ function UsersIndexPage() {
leftSection={<TbPlus size={18} />}
radius="md"
size="md"
onClick={open}
>
Add User
</Button>
</Group>
<Modal
opened={opened}
onClose={close}
title={<Text fw={700} size="lg">Add New User</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Personal Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={form.name}
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={form.nik}
onChange={(e) => setForm(f => ({ ...f, nik: e.target.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email Address"
placeholder="email@example.com"
required
value={form.email}
onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))}
/>
<TextInput
label="Phone Number"
placeholder="628xxxxxxxxxx"
required
value={form.phone}
onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={[
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
]}
mt="sm"
required
value={form.gender}
onChange={(v) => setForm(f => ({ ...f, gender: v || '' }))}
/>
</Box>
<Divider label="Role & Organization" labelPosition="center" my="sm" />
<Box>
<Select
label="User Role"
placeholder="Select user role"
data={rolesOptions}
required
value={form.idUserRole}
onChange={(v) => setForm(f => ({ ...f, idUserRole: v || '' }))}
/>
<Select
label="Village"
placeholder="Type to search village..."
searchable
onSearchChange={setVillageSearch}
data={villagesOptions}
mt="sm"
required
value={form.idVillage}
onChange={(v) => {
setForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
}}
/>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Select
label="Group"
placeholder={form.idVillage ? "Select group" : "Select village first"}
data={groupsOptions}
disabled={!form.idVillage}
required
value={form.idGroup}
onChange={(v) => {
setForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
}}
/>
<Select
label="Position"
placeholder={form.idGroup ? "Select position" : "Select group first"}
data={positionsOptions}
disabled={!form.idGroup}
value={form.idPosition || ''}
onChange={(v) => setForm(f => ({ ...f, idPosition: v || '' }))}
/>
</SimpleGrid>
</Box>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateUser}
>
Register User
</Button>
</Stack>
</Modal>
<Modal
opened={editOpened}
onClose={closeEdit}
title={<Text fw={700} size="lg">Edit User</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Personal Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={editForm.name}
onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={editForm.nik}
onChange={(e) => setEditForm(f => ({ ...f, nik: e.target.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email Address"
placeholder="email@example.com"
required
value={editForm.email}
onChange={(e) => setEditForm(f => ({ ...f, email: e.target.value }))}
/>
<TextInput
label="Phone Number"
placeholder="628xxxxxxxxxx"
required
value={editForm.phone}
onChange={(e) => setEditForm(f => ({ ...f, phone: e.target.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={[
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
]}
mt="sm"
required
value={editForm.gender}
onChange={(v) => setEditForm(f => ({ ...f, gender: v || '' }))}
/>
</Box>
<Divider label="Role & Organization" labelPosition="center" my="sm" />
<Box>
<Select
label="User Role"
placeholder="Select user role"
data={rolesOptions}
required
value={editForm.idUserRole}
onChange={(v) => setEditForm(f => ({ ...f, idUserRole: v || '' }))}
/>
<Select
label="Village"
placeholder="Type to search village..."
searchable
onSearchChange={setVillageSearch}
data={villagesOptions}
mt="sm"
required
value={editForm.idVillage}
onChange={(v) => {
setEditForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
}}
/>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Select
label="Group"
placeholder={editForm.idVillage ? "Select group" : "Select village first"}
data={groupsOptions}
disabled={!editForm.idVillage}
required
value={editForm.idGroup}
onChange={(v) => {
setEditForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
}}
/>
<Select
label="Position"
placeholder={editForm.idGroup ? "Select position" : "Select group first"}
data={positionsOptions}
disabled={!editForm.idGroup}
value={editForm.idPosition || ''}
onChange={(v) => setEditForm(f => ({ ...f, idPosition: v || '' }))}
/>
</SimpleGrid>
</Box>
<Divider label="System Access" labelPosition="center" my="sm" />
<SimpleGrid cols={2} spacing="xl">
<Switch
label="Account Active"
description="Enable or disable user access"
checked={editForm.isActive}
onChange={(event) => setEditForm(f => ({ ...f, isActive: event.currentTarget.checked }))}
/>
<Switch
label="Without OTP"
description="Bypass login OTP verification"
checked={editForm.isWithoutOTP}
onChange={(event) => setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
/>
</SimpleGrid>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleUpdateUser}
>
Update User
</Button>
</Stack>
</Modal>
<TextInput
placeholder="Search name, NIK, or email..."
leftSection={<TbSearch size={18} />}
@@ -167,13 +650,13 @@ function UsersIndexPage() {
) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table
verticalSpacing="lg"
horizontalSpacing="xl"
highlightOnHover
<Table
verticalSpacing="md"
horizontalSpacing="md"
highlightOnHover
withColumnBorders={false}
style={{
tableLayout: isMobile ? 'auto' : 'fixed',
style={{
tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%',
minWidth: isMobile ? 900 : 'unset'
}}
@@ -182,19 +665,19 @@ function UsersIndexPage() {
<Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Organization</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Role</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '22%' }}>Organization</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>Role</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((user) => (
<Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }} onClick={()=>{handleEditOpen(user)}}>
<Table.Td>
<Group gap="md" wrap="nowrap">
<Avatar
size="lg"
radius="md"
<Avatar
size="lg"
radius="md"
variant="light"
color={getRoleColor(user.role)}
style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }}
@@ -243,10 +726,10 @@ function UsersIndexPage() {
</Stack>
</Table.Td>
<Table.Td>
<Badge
variant="filled"
color={getRoleColor(user.role)}
radius="md"
<Badge
variant="filled"
color={getRoleColor(user.role)}
radius="md"
size="sm"
fullWidth={false}
styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }}

View File

@@ -4,14 +4,19 @@ import {
Button,
Card,
Group,
Modal,
Paper,
SegmentedControl,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import {
@@ -32,6 +37,7 @@ import {
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
const fetcher = (url: string) => fetch(url).then((res) => res.json())
@@ -67,7 +73,14 @@ function ActivityChart({ villageId }: { villageId: string }) {
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 (
<Paper withBorder radius="xl" p="lg">
@@ -104,35 +117,20 @@ function ActivityChart({ villageId }: { villageId: string }) {
h={280}
data={data}
dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]}
series={[{ name: 'activity', color: '#2563EB' }]}
curveType="monotone"
withTooltip
withDots
tickLine="none"
gridAxis="x"
withTooltip={true}
withDots={true}
withPointLabels={false}
tooltipAnimationDuration={150}
fillOpacity={1}
areaProps={{
strokeWidth: 2.5,
fill: 'url(#villageAreaGrad)',
stroke: '#2563EB',
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
tooltipProps={{
allowEscapeViewBox: { x: true, y: false },
}}
dotProps={{
r: 4,
activeDotProps={{
r: 6,
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>
)
@@ -144,12 +142,135 @@ function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
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 [confirmModalOpened, { open: openConfirmModal, close: closeConfirmModal }] = useDisclosure(false)
const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false)
const [isUpdating, setIsUpdating] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editForm, setEditForm] = useState({ name: '', desc: '' })
const village = infoRes?.data
const stats = gridRes?.data
const openEdit = () => {
setEditForm({
name: village?.name || '',
desc: village?.desc || ''
})
openEditModal()
}
const handleEditVillage = async () => {
if (!village) return
if (!editForm.name.trim() || !editForm.desc.trim()) {
notifications.show({
title: 'Validation Error',
message: 'All fields are required.',
color: 'red'
})
return
}
setIsEditing(true)
try {
const res = await fetch(API_URLS.editVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: village.id,
name: editForm.name,
desc: editForm.desc
})
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Data desa (${appId}) diperbarui: ${editForm.name}-${village.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: 'Village data has been updated successfully.',
color: 'teal'
})
mutate()
closeEditModal()
} else {
notifications.show({
title: 'Error',
message: 'Failed to update village data.',
color: 'red'
})
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'A network error occurred.',
color: 'red'
})
} finally {
setIsEditing(false)
}
}
const handleConfirmToggle = async () => {
if (!village) return
setIsUpdating(true)
try {
const res = await fetch(API_URLS.updateStatusVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: village.id,
active: !village.isActive
})
})
if (res.ok) {
await fetch(API_URLS.createLog(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'UPDATE', message: `Status desa (${appId}) diperbarui (${!village.isActive ? 'activated' : 'deactivated'}): ${village.name}-${village.id}` })
}).catch(console.error)
notifications.show({
title: 'Success',
message: `Village status has been ${!village.isActive ? 'activated' : 'deactivated'}.`,
color: 'teal'
})
mutate()
closeConfirmModal()
} else {
notifications.show({
title: 'Error',
message: 'Failed to update village status.',
color: 'red'
})
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'A network error occurred.',
color: 'red'
})
} finally {
setIsUpdating(false)
}
}
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
if (infoLoading || gridLoading) {
@@ -195,8 +316,10 @@ function VillageDetailPage() {
variant="filled"
color={village.isActive ? 'red' : 'green'}
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
onClick={() => alert(`Toggle status for ${village.name}`)}
onClick={openConfirmModal}
radius="md"
loading={isUpdating}
disabled={!isDeveloper}
>
{village.isActive ? 'Deactivate' : 'Active'}
</Button>
@@ -204,7 +327,7 @@ function VillageDetailPage() {
variant="light"
color="blue"
leftSection={<TbEdit size={16} />}
onClick={() => alert(`Edit settings for ${village.name}`)}
onClick={openEdit}
radius="md"
>
Edit
@@ -314,7 +437,9 @@ function VillageDetailPage() {
}}
>
{/* Left (3/4): Activity Chart */}
<ActivityChart villageId={villageId} />
<Box style={{ minWidth: 0 }}>
<ActivityChart villageId={villageId} />
</Box>
{/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg">
@@ -328,7 +453,7 @@ function VillageDetailPage() {
{[
{ label: 'Date Created', value: village.createdAt },
{ label: 'Created By', value: '-' },
{ label: 'Last Updated', value: '-' },
{ label: 'Last Updated', value: village.updatedAt },
].map((item, idx, arr) => (
<Group
key={item.label}
@@ -347,6 +472,75 @@ function VillageDetailPage() {
</Paper>
</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>
)
}

View File

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

View File

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

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

View File

@@ -1,3 +1,4 @@
import { useLogin } from '@/frontend/hooks/useAuth'
import {
Alert,
Button,
@@ -13,8 +14,7 @@ import {
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react'
import { FcGoogle } from 'react-icons/fc'
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
import { useLogin } from '@/frontend/hooks/useAuth'
import { TbAlertCircle, TbLock, TbLogin, TbMail } from 'react-icons/tb'
export const Route = createFileRoute('/login')({
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
@@ -27,7 +27,7 @@ export const Route = createFileRoute('/login')({
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
})
if (data?.user) {
throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' })
throw redirect({ to: '/dashboard' })
}
} catch (e) {
if (e instanceof Error) return
@@ -57,12 +57,6 @@ function LoginPage() {
Login
</Title>
<Text c="dimmed" size="sm" ta="center">
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
<br />
or: <strong>user@example.com</strong> / <strong>user123</strong>
</Text>
{(login.isError || searchError) && (
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
{login.isError ? login.error.message : 'Google login failed, please try again.'}
@@ -95,18 +89,6 @@ function LoginPage() {
>
Sign in
</Button>
<Divider label="or" labelPosition="center" />
<Button
component="a"
href="/api/auth/google"
fullWidth
variant="default"
leftSection={<FcGoogle size={18} />}
>
Login with Google
</Button>
</Stack>
</form>
</Paper>

View File

@@ -10,67 +10,119 @@ import {
Avatar,
Box,
Divider,
Pagination,
Center,
Tooltip,
} from '@mantine/core'
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/logs')({
component: GlobalLogsPage,
})
const timelineData = [
{
date: 'TODAY',
logs: [
{ 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></> },
{ 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></> },
{ 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.' } },
{ 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</> },
]
},
{
date: 'YESTERDAY',
logs: [
{ 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</> },
{ 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</> },
{ 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.</> },
{ 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.</> },
]
},
{
date: '12 APRIL, 2026',
logs: [
{ 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></> },
{ 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></> },
]
}
]
const fetcher = (url: string) => fetch(url).then((res) => res.json())
const typeConfig: Record<string, { color: string; icon?: any }> = {
CREATE: { color: 'blue', icon: TbCheck },
UPDATE: { color: 'teal', icon: TbCheck },
DELETE: { color: 'red', icon: TbX },
LOGIN: { color: 'green', icon: TbClock },
LOGOUT: { color: 'orange', icon: TbClock },
}
const getRoleColor = (role: string) => {
const r = (role || '').toLowerCase()
if (r.includes('super')) return 'red'
if (r.includes('admin')) return 'brand-blue'
if (r.includes('developer')) return 'violet'
return 'gray'
}
function groupLogsByDate(logs: any[]) {
const groups: Record<string, any[]> = {}
const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
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() {
const [search, setSearch] = useState('')
const [appFilter, setAppFilter] = useState<string | null>(null)
const [operatorFilter, setOperatorFilter] = useState<string | null>(null)
const [debouncedSearch, setDebouncedSearch] = useState('')
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(() => {
return timelineData
.map(group => {
const filteredLogs = group.logs.filter(log => {
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]);
if (!response?.data) return []
return groupLogsByDate(response.data)
}, [response?.data])
return (
<DashboardLayout>
@@ -79,134 +131,156 @@ function GlobalLogsPage() {
{/* Header Controls */}
<Group mb="xl" gap="md">
<TextInput
placeholder="Search operator or app..."
placeholder="Search operator or message..."
leftSection={<TbSearch size={16} />}
radius="md"
w={220}
w={250}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onChange={(e) => {
setSearch(e.currentTarget.value)
setPage(1)
}}
/>
<Select
placeholder="All Applications"
data={['Desa+', 'E-Commerce', 'Fitness App', 'System']}
placeholder="Log Type"
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"
w={160}
clearable
value={appFilter}
onChange={setAppFilter}
value={logType}
onChange={(val) => {
setLogType(val)
setPage(1)
}}
/>
<Select
placeholder="All Operators"
data={['Agus Setiawan', 'Amel', 'Budi Santoso', 'Jane Smith', 'John Doe', 'Rahmat Hidayat', 'Siti Aminah', 'System']}
placeholder="Operator"
data={operatorOptions}
searchable
radius="md"
w={160}
clearable
value={operatorFilter}
onChange={setOperatorFilter}
w={200}
value={operatorId}
onChange={(val) => {
setOperatorId(val)
setPage(1)
}}
/>
</Group>
{/* Timeline Content */}
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)' }}>
{filteredTimeline.length === 0 ? (
<Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
{isLoading ? (
<Center py="xl">
<Text c="dimmed">Loading logs...</Text>
</Center>
) : filteredTimeline.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
) : filteredTimeline.map((group, groupIndex) => (
<Box key={group.date}>
<Text
size="xs"
fw={700}
c="dimmed"
mt={groupIndex > 0 ? "xl" : 0}
mb="lg"
style={{ textTransform: 'uppercase' }}
>
{group.date}
</Text>
<Stack gap={0} pl={4}>
{group.logs.map((log, logIndex) => {
const isLastLog = logIndex === group.logs.length - 1;
) : (
<>
{filteredTimeline.map((group, groupIndex) => (
<Box key={group.date}>
<Text
size="xs"
fw={700}
c="dimmed"
mt={groupIndex > 0 ? "xl" : 0}
mb="md"
style={{ textTransform: 'uppercase' }}
>
{group.date}
</Text>
return (
<Group
key={log.id}
wrap="nowrap"
align="flex-start"
gap="lg"
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
>
{/* Left: Time */}
<Text
size="xs"
c="dimmed"
w={70}
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
>
{log.time}
</Text>
{/* Middle: Line & Avatar */}
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
{/* Vertical Line */}
{!isLastLog && (
<Box
style={{
position: 'absolute',
top: 24,
bottom: -8,
left: '50%',
transform: 'translateX(-50%)',
width: 1,
backgroundColor: 'rgba(128,128,128,0.2)'
}}
/>
)}
{/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}>
{log.icon ? (
<Avatar size={24} radius="xl" color={log.color} variant="light">
<log.icon size={14} />
</Avatar>
) : (
<Avatar size={24} radius="xl" color={log.color}>
{log.operator.charAt(0)}
</Avatar>
)}
</Box>
</Box>
{/* Right: Content */}
<Box style={{ flexGrow: 1, marginTop: 2 }}>
<Text size="sm">
<Text component="span" fw={600} mr={4}>{log.operator}</Text>
{log.content}
</Text>
{log.message && (
<Paper
withBorder
p="md"
radius="md"
mt="sm"
style={{ maxWidth: 800, backgroundColor: 'transparent' }}
<Stack gap={0} pl={4}>
{group.logs.map((log, logIndex) => {
const isLastLog = logIndex === group.logs.length - 1;
return (
<Group
key={log.id}
wrap="nowrap"
align="flex-start"
gap="lg"
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
>
{/* Left: Time */}
<Text
size="xs"
c="dimmed"
w={70}
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
>
<Text size="sm" fw={600} mb={4}>{log.message.title}</Text>
<Text size="sm" c="dimmed">
{log.message.text}
{log.time}
</Text>
{/* Middle: Line & Avatar */}
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
{/* Vertical Line */}
{!isLastLog && (
<Box
style={{
position: 'absolute',
top: 24,
bottom: -8,
left: '50%',
transform: 'translateX(-50%)',
width: 1,
backgroundColor: 'rgba(128,128,128,0.2)'
}}
/>
)}
{/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}>
<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>
</Paper>
)}
</Box>
</Group>
)
})}
</Stack>
{groupIndex < timelineData.length - 1 && (
<Divider my="xl" color="rgba(128,128,128,0.1)" />
</Box>
</Group>
)
})}
</Stack>
{groupIndex < filteredTimeline.length - 1 && (
<Divider my="xl" color="rgba(128,128,128,0.1)" />
)}
</Box>
))}
{response?.totalPages > 1 && (
<Center mt="xl">
<Pagination
total={response.totalPages}
value={page}
onChange={setPage}
radius="md"
/>
</Center>
)}
</Box>
))}
</>
)}
</Paper>
</Container>
</DashboardLayout>

View File

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

View File

@@ -1,73 +1,212 @@
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import {
ActionIcon,
Avatar,
Badge,
Button,
Card,
Container,
Divider,
Group,
List,
Modal,
Pagination,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
TextInput,
Title,
Paper,
Tabs,
Avatar,
SimpleGrid,
ThemeIcon,
List,
Box,
Divider,
Title,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute } from '@tanstack/react-router'
import {
TbPlus,
TbSearch,
TbPencil,
TbTrash,
TbUserCheck,
TbShieldCheck,
import { useEffect, useState } from 'react'
import {
TbAccessPoint,
TbCircleCheck,
TbClock,
TbApps,
TbCircleX,
TbPencil,
TbPlus,
TbSearch,
TbShieldCheck,
TbTrash,
TbUserCheck
} from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
export const Route = createFileRoute('/users')({
component: UsersPage,
})
const mockUsers = [
{ 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' },
{ id: 3, name: 'Jane Smith', email: 'jane@company.com', role: 'QA', apps: 'E-Commerce', status: 'Online', lastActive: '12m ago' },
{ id: 4, name: 'Rahmat Hidayat', email: 'rahmat@company.com', role: 'DEVELOPER', apps: 'Desa+', status: 'Online', lastActive: 'Now' },
]
const fetcher = (url: string) => fetch(url).then((res) => res.json())
const getRoleColor = (role: string) => {
const r = (role || '').toLowerCase()
if (r.includes('super')) return 'red'
if (r.includes('admin')) return 'brand-blue'
if (r.includes('developer')) return 'violet'
return 'gray'
}
const roles = [
{
name: 'SUPER_ADMIN',
count: 2,
{
name: 'DEVELOPER',
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',
count: 12,
color: 'brand-blue',
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
},
{
name: 'QA',
count: 5,
{
name: 'ADMIN',
color: 'orange',
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
permissions: ['View All Apps', 'View Logs', 'Report Errors']
},
]
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 (
<DashboardLayout>
<Container size="xl" py="lg">
@@ -80,9 +219,9 @@ function UsersPage() {
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
<StatsCard title="Total Staff" value={24} icon={TbUserCheck} color="brand-blue" />
<StatsCard title="Active Now" value={18} icon={TbAccessPoint} color="teal" />
<StatsCard title="Security Roles" value={3} icon={TbShieldCheck} color="purple-primary" />
<StatsCard title="Total Staff" value={stats?.totalStaff ?? 0} icon={TbUserCheck} color="brand-blue" />
<StatsCard title="Active Now" value={stats?.activeNow ?? 0} icon={TbAccessPoint} color="teal" />
<StatsCard title="Security Roles" value={stats?.rolesCount ?? 0} icon={TbShieldCheck} color="purple-primary" />
</SimpleGrid>
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
@@ -100,15 +239,23 @@ function UsersPage() {
radius="md"
w={350}
variant="filled"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value)
setPage(1)
}}
/>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
>
Add New User
</Button>
{isDeveloper && (
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
onClick={openCreate}
>
Add New User
</Button>
)}
</Group>
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
@@ -117,56 +264,72 @@ function UsersPage() {
<Table.Tr>
<Table.Th>Name & Contact</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>App Access</Table.Th>
<Table.Th>Joined Date</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockUsers.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="sm">
<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>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={4} align="center">
<Text size="sm" c="dimmed" py="xl">Loading user data...</Text>
</Table.Td>
</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>
</Paper>
{response?.totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
total={response.totalPages}
value={page}
onChange={setPage}
radius="md"
/>
</Group>
)}
</Stack>
</Tabs.Panel>
@@ -179,16 +342,15 @@ function UsersPage() {
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
<TbShieldCheck size={28} />
</ThemeIcon>
<Badge variant="default" size="lg" radius="sm">{role.count} Users</Badge>
</Group>
<Stack gap={4}>
<Title order={4}>{role.name.replace('_', ' ')}</Title>
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
</Stack>
<Divider />
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
<List
spacing="xs"
@@ -205,9 +367,9 @@ function UsersPage() {
))}
</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
</Button>
</Button> */}
</Stack>
</Card>
))}
@@ -216,7 +378,127 @@ function UsersPage() {
</Tabs>
</Stack>
</Container>
{/* Create User Modal */}
<Modal
opened={createOpened}
onClose={closeCreate}
title={<Text fw={700} size="lg">Add New User</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
/>
<PasswordInput
label="Password"
placeholder="Enter password"
required
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
/>
<Select
label="Role"
data={[
{ value: 'ADMIN', label: 'Admin' },
{ value: 'DEVELOPER', label: 'Developer' },
]}
value={createForm.role}
onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isCreating}
onClick={handleCreateUser}
>
Create User
</Button>
</Stack>
</Modal>
{/* Edit User Modal */}
<Modal
opened={editOpened}
onClose={closeEdit}
title={<Text fw={700} size="lg">Edit User</Text>}
radius="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
/>
<Select
label="Role"
data={[
{ value: 'ADMIN', label: 'Admin' },
{ value: 'DEVELOPER', label: 'Developer' },
]}
value={editForm.role}
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
/>
<Button
fullWidth
mt="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isEditing}
onClick={handleEditUser}
>
Save Changes
</Button>
</Stack>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={deleteOpened}
onClose={closeDelete}
title={<Text fw={700} size="lg">Delete User</Text>}
radius="xl"
size="sm"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="sm">
Are you sure you want to delete <Text component="span" fw={700}>{deletingUser?.name}</Text>? This action cannot be undone.
</Text>
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={closeDelete}>
Cancel
</Button>
<Button color="red" loading={isDeleting} onClick={handleDeleteUser}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
</DashboardLayout>
)
}

View File

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

View File

@@ -7,7 +7,7 @@ import { env } from './lib/env'
const isProduction = env.NODE_ENV === 'production'
// ─── Route Classification ──────────────────────────────
const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health']
const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health', '/docs']
function isApiRoute(pathname: string): boolean {
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_SECRET: required('GOOGLE_CLIENT_SECRET'),
SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean),
API_KEY: required('API_KEY'),
} 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 */
export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'USER' | 'ADMIN' | 'SUPER_ADMIN' = 'USER') {
export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'ADMIN' | 'DEVELOPER' = 'DEVELOPER') {
const hashed = await Bun.password.hash(password, { algorithm: 'bcrypt' })
return prisma.user.upsert({
where: { email },