Compare commits

..

202 Commits

Author SHA1 Message Date
6fb6ab9750 upd: update data pengajuan surat 2025-12-15 15:45:29 +08:00
11a78d7371 upd: update edit pengajuan surat 2025-12-15 14:22:37 +08:00
3baba059ab upd: api update 2025-12-15 12:17:59 +08:00
dcd072034c upd: api update pelayanan pengajuan surat 2025-12-15 11:32:02 +08:00
ee27813da7 upd: update pengajuan surat 2025-12-12 17:32:22 +08:00
29f6ecfd23 upd: api update 2025-12-12 15:13:32 +08:00
d6882d4b3a upd: api update pelayanan surat 2025-12-12 15:08:09 +08:00
286c989bcf upd: api jenna ai
deskripsi:
- update pelayanan surat

No Issues:
2025-12-12 14:41:20 +08:00
031e408640 Merge pull request 'upd: api jenna ai' (#75) from amalia/11-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/75
2025-12-11 17:06:29 +08:00
c797d1fc46 upd: api jenna ai
Deskripsi:
- detail post pengaduan
- detail post pengajuan surat

No Issues
2025-12-11 17:05:53 +08:00
6f6905a414 Merge pull request 'amalia/11-des-25' (#74) from amalia/11-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/74
2025-12-11 14:19:34 +08:00
91e5f6a77e upd: console data 2025-12-11 14:18:43 +08:00
3f567b57b2 upd: detail api
Deskripsi
- detail pengaduan by nomer pengaduan
- detail pengajuan surat by nomer pengajuan

No Issues
2025-12-11 14:16:31 +08:00
c98cfd21ce Merge pull request 'upd: list pengaduan dan list pelayanan surat api jenna ai' (#73) from amalia/11-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/73
2025-12-11 12:05:02 +08:00
fdf7b0a13f upd: list pengaduan dan list pelayanan surat api jenna ai 2025-12-11 12:04:16 +08:00
d76a702d2d Merge pull request 'upd: api pelayanan' (#72) from amalia/10-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/72
2025-12-10 17:25:04 +08:00
6bc6a9d357 upd: api pelayanan 2025-12-10 17:24:12 +08:00
dee32b8cfd Merge pull request 'upd: api pelayanan surat' (#71) from amalia/10-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/71
2025-12-10 16:31:18 +08:00
ff0b0273bf upd: api pelayanan surat 2025-12-10 16:30:51 +08:00
f8dcffa9c5 Merge pull request 'amalia/10-des-25' (#70) from amalia/10-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/70
2025-12-10 11:45:12 +08:00
20e3056e04 upd: list pengaduan 2025-12-10 11:44:22 +08:00
84c9f405d6 upd: api jenna 2025-12-10 11:40:35 +08:00
22597c0159 Merge pull request 'upd: api jenna' (#69) from amalia/10-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/69
2025-12-10 11:34:01 +08:00
0f9af404e1 upd: api jenna 2025-12-10 11:33:05 +08:00
676edaa22b Merge pull request 'upd: api jenna' (#68) from amalia/10-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/68
2025-12-10 11:10:25 +08:00
7f6f495eaa upd: api jenna 2025-12-10 11:09:00 +08:00
b5af41b07d Merge pull request 'amalia/09-des-25' (#67) from amalia/09-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/67
2025-12-09 17:17:31 +08:00
6428f5084e upd: api jenna ai 2025-12-09 17:16:24 +08:00
bfc292ec6c upd: api jenna ai 2025-12-09 17:15:08 +08:00
3b71976863 Merge pull request 'upd: api' (#66) from amalia/09-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/66
2025-12-09 16:18:48 +08:00
5680466c98 upd: api 2025-12-09 16:17:39 +08:00
270f3687a3 Merge pull request 'upd: jenna ai mcp' (#65) from amalia/09-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/65
2025-12-09 15:24:40 +08:00
f5cc45937c upd: jenna ai mcp 2025-12-09 15:23:21 +08:00
5b4164b151 Merge pull request 'upd: api pelayanan jenna ai' (#64) from amalia/09-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/64
2025-12-09 14:15:20 +08:00
225c58b346 upd: api pelayanan jenna ai 2025-12-09 14:14:04 +08:00
b8b3aed86e Merge pull request 'upd: api jenna ai' (#63) from amalia/08-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/63
2025-12-08 16:31:00 +08:00
fc530399dd upd: api jenna ai 2025-12-08 16:28:37 +08:00
281e34ea69 Merge pull request 'upd' (#62) from amalia/08-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/62
2025-12-08 14:51:18 +08:00
f928fc504f upd 2025-12-08 14:50:29 +08:00
4fb98d0480 Merge pull request 'upd: api' (#61) from amalia/08-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/61
2025-12-08 14:32:54 +08:00
bfb33e2105 upd: api 2025-12-08 14:32:05 +08:00
2579714000 Merge pull request 'upd' (#60) from amalia/08-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/60
2025-12-08 14:12:36 +08:00
d69189cf7d upd
:  api tambah pengaduan
2025-12-08 14:10:34 +08:00
20e24a03aa Merge pull request 'amalia/08-des-25' (#59) from amalia/08-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/59
2025-12-08 11:50:41 +08:00
c256f4b729 upd: api jenna ai
Deskripsi:
- create pengaduan pake nama dan nomer hp dari header

No Issues
2025-12-08 11:49:24 +08:00
9430ad3728 upd: update data pengaduan dari wawrga 2025-12-08 11:37:16 +08:00
c6c3ba95f8 upd: update data pengaduan by jenna AI 2025-12-08 11:04:03 +08:00
bipproduction
3c58230c3a tambahan untuk handle true user 2025-12-08 10:16:27 +08:00
d22b4b973f Merge pull request 'upd: api jenna ai' (#58) from amalia/02-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/58
2025-12-02 17:27:35 +08:00
700fbe3bd7 upd: api jenna ai 2025-12-02 17:26:43 +08:00
9b7a61e134 Merge pull request 'upd: api jenna ai' (#57) from amalia/02-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/57
2025-12-02 14:40:07 +08:00
2d376663bb upd: api jenna ai
Deskripsi:
- create tambah data pengajuan pelayanan surat

No Issues
2025-12-02 14:37:59 +08:00
7c669f3494 Merge pull request 'fix: dashboard admin' (#56) from amalia/02-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/56
2025-12-02 12:01:42 +08:00
0ed9dc6ddd fix: dashboard admin
Deskripsi:
- list pengaduan
- list pengajuan surat

No Issues
2025-12-02 12:00:46 +08:00
b9984c6337 Merge pull request 'amalia/02-des-25' (#55) from amalia/02-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/55
2025-12-02 11:51:54 +08:00
48a7d43713 fix: list pengaduan dan list pelayanan surat
Deskripsi:
- fix date string

No Issues
2025-12-02 11:50:58 +08:00
6a52d10faa upd: dashboard admin
Deskripsi:
- home > ganti warna

No Issues
2025-12-02 11:40:30 +08:00
2b94684570 upd: dashboard admin
Deskripsi:
- pagination list pengajuan surat

NO Issues
2025-12-02 11:32:16 +08:00
cc7c8eb704 upd: dashboard admin
Deskripsi:
- pagination list pengaduan

No Issues
2025-12-02 11:22:05 +08:00
4996da4189 Merge pull request 'upd: test api' (#54) from amalia/01-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/54
2025-12-01 16:45:32 +08:00
c32cce838f upd: test api 2025-12-01 16:45:06 +08:00
5af9b720ca Merge pull request 'amalia/01-des-25' (#53) from amalia/01-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/53
2025-12-01 16:03:21 +08:00
35618bb438 upd: api test 2025-12-01 16:02:48 +08:00
eee8aadb1a upd: list warga 2025-12-01 16:02:13 +08:00
1f95c7d7d8 Merge pull request 'amalia/01-des-25' (#52) from amalia/01-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/52
2025-12-01 15:25:25 +08:00
23df516aad upd: test 2025-12-01 15:24:59 +08:00
70175cedc6 upd: balik 2025-12-01 15:21:59 +08:00
f52f5f87ca Merge pull request 'upd' (#51) from amalia/01-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/51
2025-12-01 15:21:00 +08:00
ea17357638 upd 2025-12-01 15:20:29 +08:00
9c7c9d8595 Merge pull request 'upd: api test' (#50) from amalia/01-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/50
2025-12-01 15:14:53 +08:00
5ecf264155 upd: api test 2025-12-01 15:14:24 +08:00
4cc28c4311 Merge pull request 'upd dashboard admin' (#49) from amalia/01-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/49
2025-12-01 14:19:25 +08:00
c25e5eeba0 upd dashboard admin
Deskripsi:
- update api detail pengajuan surat
- table detail history jam pengajuan surat

No Issues
2025-12-01 14:18:23 +08:00
574603e290 Merge pull request 'upd: test update created At client' (#48) from amalia/01-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/48
2025-12-01 14:12:20 +08:00
ba76eb5e59 upd: test update created At client 2025-12-01 14:11:40 +08:00
6ae83ec19c Merge pull request 'fix: api tambah pengaduan jenna ai' (#47) from amalia/01-des-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/47
2025-12-01 11:22:36 +08:00
ba0414a99c fix: api tambah pengaduan jenna ai 2025-12-01 11:21:38 +08:00
4dd66dbd9a Merge pull request 'fix:list user' (#46) from amalia/28-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/46
2025-11-28 17:39:47 +08:00
fa201274d4 fix:list user 2025-11-28 17:38:52 +08:00
dff1aa61c5 Merge pull request 'fixing : list user' (#45) from amalia/28-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/45
2025-11-28 17:33:29 +08:00
90b8fdf573 fixing : list user 2025-11-28 17:32:23 +08:00
239b1bdc1b Merge pull request 'fix: list user' (#44) from amalia/28-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/44
2025-11-28 17:22:02 +08:00
f99da3b2a6 fix: list user 2025-11-28 17:21:14 +08:00
3866f71e2d Merge pull request 'upd' (#43) from amalia/28-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/43
2025-11-28 17:12:38 +08:00
d57ff92aa9 upd
fix: list user
2025-11-28 17:11:45 +08:00
f901cff3b1 Merge pull request 'amalia/28-nov-25' (#42) from amalia/28-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/42
2025-11-28 17:02:35 +08:00
fe3ebf4bd3 upd: dashboard admin 2025-11-28 17:01:19 +08:00
075cb12417 upd: dashboard admin
Deskripsi:
- view file pada detail pengaduan
- view file pada detail pelayanan surat
- close modal file jika error

No Issues
2025-11-28 16:04:07 +08:00
cd7e602254 upd: menu home
Deskripsi
:
- tampilan grafik

NO Issues
2025-11-28 10:57:58 +08:00
f9b84f89eb Merge pull request 'update: dashboard admin' (#41) from amalia/27-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/41
2025-11-27 18:03:22 +08:00
cca1840922 update: dashboard admin
Deskripsi:
- menu dashboard
- api dashboard

No Issues
2025-11-27 17:55:01 +08:00
c622565bb7 Merge pull request 'upd: api detail pengaduan jenna ai' (#40) from amalia/26-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/40
2025-11-26 14:30:48 +08:00
d7e77da16a upd: api detail pengaduan jenna ai 2025-11-26 14:29:00 +08:00
decf6dd972 Merge pull request 'upd: dashboard admin' (#39) from amalia/26-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/39
2025-11-26 12:14:55 +08:00
5b72f1a9cc upd: dashboard admin
Deskripsi:
- login input
- login redirect sesuai dg akses
- tampilan jika tidak ada data ttd pada setting desa
- disable button pada list kategori pengaduan dg value id == lainnya
- disable button aksi pada list role dg value id == developer
- tidak menampilkan list data menu akses pada modal tambah dan edi role
- tampilan list permission pada table role
- order data permission yg telah terpilih sesuai dengan data json menu

NO Issues
2025-11-26 12:14:09 +08:00
acb5ae7cd1 Merge pull request 'amalia/25-nov-25' (#38) from amalia/25-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/38
2025-11-25 17:27:07 +08:00
6bdb0246c9 upd: api upload
Deskripsi:
- summary upload file form data

No Issues'
2025-11-25 16:47:47 +08:00
3f68f212cd upd: api jenna ai
Deskripsi:
- api upsert warga pada create pengaduan
- tampilan detail pengaduan jika tidak ada gambar

NO Issues
2025-11-25 16:17:44 +08:00
bipproduction
e0236a907f tambahan 2025-11-25 15:11:22 +08:00
e4189d40e9 Merge pull request 'amalia/25-nov-25' (#37) from amalia/25-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/37
2025-11-25 15:01:35 +08:00
94e7604afb upd: api jenna ai
Deskripsi:
- tambah pengaduan

NO Issues
2025-11-25 15:01:01 +08:00
a253d40d19 upd: api jenna ai
Deskripsi:
- tambah pengaduan

NO Issues
2025-11-25 14:58:40 +08:00
26c7357ca3 Merge pull request 'amalia/25-nov-25' (#36) from amalia/25-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/36
2025-11-25 14:05:46 +08:00
15c5140902 upd: dashboard admin
Deskripsi:
- nama field pada modal edit dan tambah role user

No Issues
2025-11-25 12:17:03 +08:00
c5b1452955 upd: dashboard admin
Deskripsi:
- tambah role user
- edit role user

No Issues
2025-11-25 12:15:29 +08:00
e1431fafb2 Merge pull request 'amalia/24-nov-25' (#35) from amalia/24-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/35
2025-11-24 17:41:22 +08:00
ad7b40523c upd: dashboard admin
Deskripsi:
- tambah role user
- api edit tambah dan delete role user

NO Issues
2025-11-24 17:40:27 +08:00
10db3f922e up: dashboard admin
Deskripsi:
- akses role pada menu dashboard
- akses role pada setting
- akses role pada pelayanan surat
- akses role pada pengaduan warga
- akses role pada warga

NO Issues
2025-11-24 16:27:35 +08:00
0a3afb7b9c upd: dashboard admin
Deskripsi:
- databse
- seeder
- list user role

NO Issues
2025-11-24 14:27:19 +08:00
c72ef5a755 fix: dashboard admin
Deskripsi
- list warga
- list pelayanan

No Issues
2025-11-24 11:15:14 +08:00
4c047324bc upd: dashboard admin
Deskripsi:
- view file seafile

No Issuesg
2025-11-24 10:56:28 +08:00
e4a03e3a8f Merge pull request 'amalia/21-nov-25' (#34) from amalia/21-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/34
2025-11-21 17:46:42 +08:00
41af733c6e upd: dashbaord admin/
Deksirps:
- format surat
- view file
- api

No Issues
2025-11-21 17:45:12 +08:00
bipproduction
436016641b tambahan 2025-11-21 14:33:25 +08:00
bipproduction
6fbddb3806 tambahan 2025-11-21 14:28:53 +08:00
bipproduction
eb1eaa11ea tambahan 2025-11-21 14:23:56 +08:00
bipproduction
54ae3b746d tambahan 2025-11-21 14:21:00 +08:00
bipproduction
7781882531 tambahan 2025-11-21 14:13:55 +08:00
558d8aaafb upd: dashboard admin
Deskripsi:
- ttd pada semua format surat
- fix api warga -- salah summary
- nama file surat saat download

No Issues
2025-11-21 12:13:02 +08:00
d7267abdb3 Merge pull request 'amalia/20-nov-25' (#33) from amalia/20-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/33
2025-11-20 17:32:19 +08:00
bda427b688 upd: dashboard admin
Desrkipsi:
- update ttd skusaha

No Issues
2025-11-20 17:31:15 +08:00
e5a9ee86dd upd: dahsboard admin
Deskripsi:
- tampil image
- tampil ttd pada setting desa

No Issues
2025-11-20 17:22:01 +08:00
d0ff675950 upd: dashboard admin
Deskripsi
- edit upload ttd setting desa

No Issues
2025-11-20 16:09:36 +08:00
03715b7c98 upd: dashboard admin
Deskripsi:
- sk tidak mampu
- sk tempat usaha
- sk kematian
- sk domisili organisasi
- sk belum kawin
- sk beda biodata diri

No Issues
2025-11-20 11:57:55 +08:00
a27a7740d0 Merge pull request 'upd: api pengaduan' (#32) from amalia/19-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/32
2025-11-19 17:40:08 +08:00
236d6cfc72 upd: api pengaduan
Deskripsi:
- update api tambah pengaduan
- update api pelayanan surat

No Issues
2025-11-19 17:39:27 +08:00
482227a502 Merge pull request 'amalia/19-nov-25' (#31) from amalia/19-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/31
2025-11-19 17:10:51 +08:00
fe52fb52c6 fix: dashboard admin
Deskripsi:
- fix error pada list pengajuan surat
- menghilangkan status dikerjakan pada list pengajuan surat

No Issues
2025-11-19 17:10:00 +08:00
99247b7a44 upd: dashboard admin
Deskripsi:
- update sk penghasilan
- update sk kelakuan baik
- update sk kelahiran

NO Issues
2025-11-19 16:58:19 +08:00
73e247c87f upd: dashboard admin
Deskripsi:
- sk tidak mampu
- sk yatim piatu

No Issues
2025-11-19 15:45:44 +08:00
4b914e1852 upd: dashboard admin
Deskripsi:
- update modal surat
- api surat
- dowload surat

No Issues
2025-11-19 15:26:58 +08:00
dfc35e88d5 Merge pull request 'upd:' (#30) from amalia/18-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/30
2025-11-18 17:38:05 +08:00
79660a766c upd:
dashboard admin

Deskripsi:
- list pelayanan surat
- detail pelayanan surat
- terima pelayanan surat
- menolak pelayanan surat
- menyelesaikan pelayanan surat
- tambah surat > databse

No Issues
2025-11-18 17:37:07 +08:00
083eb11bb0 Merge pull request 'amalia/17-nov-25' (#29) from amalia/17-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/29
2025-11-17 17:26:02 +08:00
3e09c934d4 fix : list pengaduan error 2025-11-17 17:25:14 +08:00
282b9678b3 upd: dashboard admin
Deskripsi:
- detail pengaduan
- menerima pengaduan
- mengerjakan pengaduan/
- menyelesaikan pengaduan
- menolak pengaduan

No Issues
2025-11-17 17:13:11 +08:00
ceed3e67c7 Merge pull request 'upd : dahboard admin' (#28) from amalia/17-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/28
2025-11-17 13:42:32 +08:00
04b5d26507 upd : dahboard admin
Deskripsi:
- detail pengaduan

No Issues
2025-11-17 13:41:34 +08:00
327434b42e Merge pull request 'upd: warga route' (#27) from amalia/14-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/27
2025-11-14 17:27:15 +08:00
c4e4aaffe7 upd: warga route 2025-11-14 17:26:42 +08:00
51e323c264 Merge pull request 'amalia/14-nov-25' (#26) from amalia/14-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/26
2025-11-14 17:25:42 +08:00
b5d3b2bd08 upd: conflict 2025-11-14 17:24:51 +08:00
67c066990e update dashboard admin
Deskripsi:
- list warga
- detail warga

No Issues
2025-11-14 17:19:50 +08:00
6c6ee02cf0 upd: dahsboar admin
Deskripsi:
- list kategori pelayanan surat
- edit kategori pelayanan surat
- tambah kategori pelayanan surat
- hapush kategori pelayanan surat

No Issues
2025-11-14 14:32:32 +08:00
bipproduction
a3c07ca255 tambahannnya 2025-11-14 11:42:03 +08:00
bipproduction
76c425700a tambahannnya 2025-11-14 11:05:08 +08:00
bipproduction
467dbfa296 tambahannnya 2025-11-14 11:02:12 +08:00
bipproduction
719f92cbe8 tambahannnya 2025-11-14 11:00:48 +08:00
bipproduction
2075d0fba1 tambahannnya 2025-11-14 10:56:08 +08:00
bipproduction
7d98f9f61c tambahannnya 2025-11-14 10:53:20 +08:00
bipproduction
1a5bd72237 tambahannnya 2025-11-14 10:51:51 +08:00
bipproduction
85cb36289c tambahannnya 2025-11-14 10:46:23 +08:00
8d535793b1 upd: dashboard admin kategori pengaduan
Deskripsi:
- hapus kategori pengaduan

No Issues
2025-11-14 10:42:24 +08:00
bipproduction
77cbb6062b tambahannnya 2025-11-14 10:24:49 +08:00
bipproduction
e0bef23eab tambahannnya 2025-11-14 10:19:44 +08:00
bipproduction
4ea72fb846 fix mcp generator 2025-11-13 21:58:55 +08:00
bipproduction
d380c859a4 fix mcp generator 2025-11-13 20:36:26 +08:00
bipproduction
78f2263c86 fix mcp generator 2025-11-13 19:37:09 +08:00
254d05f5ea Merge pull request 'amalia/13-nov-25' (#24) from amalia/13-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/24
2025-11-13 17:44:13 +08:00
039524d092 upd: dashboard admin
Deskripsi:
- list user
- tambah usr
- delete user

- beserta api

NO Issues
2025-11-13 17:43:19 +08:00
cc293d3bad upd: profile
Deskripsi
- tampilan edit profile
- integrasi api edit profile
- tampilan edit password
- integrasi api update password

No Issues
2025-11-13 16:34:01 +08:00
6e1d3ecb56 Merge pull request 'amalia/12-nov-25' (#23) from amalia/12-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/23
2025-11-12 17:41:45 +08:00
5101a21f54 upd: api mcp pengaduan
Deskripsi:
- bahasa nya lebih bisa di mengerti ai bukan bahasa developer

No Issues
2025-11-12 17:41:00 +08:00
503c3e330d upd: dashboard admin
Deskripsi:
- tampilan list kategori pengaduan
- tambah kategori pengaduan
- edit kategori pengaduan

No Issues
2025-11-12 17:20:14 +08:00
001c3df47d Merge pull request 'upd: coba api create pengaduan dengan gambar' (#22) from amalia/12-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/22
2025-11-12 15:36:33 +08:00
a4167cfc8b upd: coba api create pengaduan dengan gambar 2025-11-12 15:35:41 +08:00
5b240b782a Merge pull request 'upd: api create pengaduan dg image' (#21) from amalia/12-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/21
2025-11-12 15:19:49 +08:00
14e2d711b3 upd: api create pengaduan dg image 2025-11-12 15:19:19 +08:00
dc3ae99c05 Merge pull request 'amalia/12-nov-25' (#20) from amalia/12-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/20
2025-11-12 14:58:14 +08:00
63c88161d3 upd: coba api pengaduan dengan upload gambar 2025-11-12 14:57:22 +08:00
eacc8fc220 update: dashboard admin
Deskripsi:
- list data konfigurasi api
- edit data konfigurasi api
- integrasi api

No Issues
2025-11-12 14:40:13 +08:00
422ca5a2cc Merge pull request 'amalia/11-nov-25' (#19) from amalia/11-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/19
2025-11-11 17:49:00 +08:00
adae0d3db1 upd: tampil gambar
Deskripsi:
- masih blm bisa

No Issues
2025-11-11 16:52:52 +08:00
715a929e13 upd: dashboard admin
Deskripsi:
- tampilan profile;2A
- tampilan list category pengaduan
- tampilan list category pelayanan surat
- tampilan list configurasi desa

No Issues
2025-11-11 15:31:00 +08:00
5b0f9b06d8 update: dashboard
Deskripsi:
- tampilan list warga
- tampilan detail warga

No Issues
2025-11-11 12:01:54 +08:00
663e36bc4b update: dashboard admin
Deskripsi:
- list pelayanan surat
- detail pelayanan surat

No Issues
2025-11-11 11:11:21 +08:00
ddefbbbbff update: dashboard admin
Deskripsi:
- tampilan detail pengaduan

No Issues
2025-11-11 11:00:54 +08:00
2aaa44cf14 Merge pull request 'upd : dashboard admin' (#18) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/18
2025-11-11 10:15:42 +08:00
fbf00a55da upd : dashboard admin
Deskripsi:
- tampilan detail pengaduan

No Issues
2025-11-10 17:47:32 +08:00
bipproduction
03955743ca tambah route texs 2025-11-10 17:12:50 +08:00
bipproduction
cdd7c6fa2b tambah route texs 2025-11-10 17:08:10 +08:00
bipproduction
c51dcfdad4 tambah route texs 2025-11-10 16:59:26 +08:00
bipproduction
e68fe87e9e tambah route texs 2025-11-10 16:55:38 +08:00
bipproduction
fca77c6bd8 tambah route texs 2025-11-10 16:53:00 +08:00
aa89a10aa8 Merge pull request 'return upload base64' (#17) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/17
2025-11-10 14:11:17 +08:00
21af3e3310 return upload base64 2025-11-10 14:10:22 +08:00
08faa9f6b0 Merge pull request 'upd: json return' (#16) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/16
2025-11-10 11:36:27 +08:00
b101c63f8d upd: json return 2025-11-10 11:35:51 +08:00
41820ff2b3 Merge pull request 'upload base64 fix' (#15) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/15
2025-11-10 11:25:28 +08:00
9c045f32ea upload base64 fix 2025-11-10 11:24:50 +08:00
6dd8dcd06e Merge pull request 'upd: upload base64' (#14) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/14
2025-11-10 10:54:52 +08:00
f79629e97e upd: upload base64 2025-11-10 10:54:22 +08:00
b52bb57fbc Merge pull request 'upload base64' (#13) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/13
2025-11-10 10:35:00 +08:00
401f8f13a2 upload base64 2025-11-10 10:34:30 +08:00
7b0d4e5d30 Merge pull request 'amalia/07-nov-25' (#12) from amalia/07-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/12
2025-11-07 17:35:13 +08:00
5c71d000f6 tampilan pelayanan surat
Deskripsi:
- tampilan list pelayanan surat

No Issues
2025-11-07 17:34:07 +08:00
621cfc931a upd: upload base64
Deskripsi:
- api upload base64 test

No Issues
2025-11-07 16:22:56 +08:00
928ecb4c76 upd: list pengaduan
Deskripsi:
- pencarian data list pengaduan

No Issues
2025-11-07 15:19:23 +08:00
0ac649345d Merge pull request 'amalia/07-nov-25' (#11) from amalia/07-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/11
2025-11-07 12:07:58 +08:00
e0456b2dba upd: list pengaduan dashboard 2025-11-07 12:06:46 +08:00
14ec81d98d upd: tambah cors 2025-11-07 12:06:04 +08:00
0e5fab6a84 Merge pull request 'amalia/06-nov-25' (#10) from amalia/06-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/10
2025-11-06 17:39:00 +08:00
72 changed files with 11714 additions and 1118 deletions

250
bak/mcp_route.ts.txt Normal file
View File

@@ -0,0 +1,250 @@
import { Elysia } from "elysia";
import { getMcpTools } from "../lib/mcp_tool_convert";
var tools = [] as any[];
const OPENAPI_URL = process.env.BUN_PUBLIC_BASE_URL + "/docs/json";
const FILTER_TAG = "mcp";
if (!process.env.BUN_PUBLIC_BASE_URL) {
throw new Error("BUN_PUBLIC_BASE_URL environment variable is not set");
}
// =====================
// MCP Protocol Types
// =====================
type JSONRPCRequest = {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: any;
};
type JSONRPCResponse = {
jsonrpc: "2.0";
id: string | number;
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
};
// =====================
// Tool Executor
// =====================
export async function executeTool(
tool: any,
args: Record<string, any> = {},
baseUrl: string
) {
const x = tool["x-props"] || {};
const method = (x.method || "GET").toUpperCase();
const path = x.path || `/${tool.name}`;
const url = `${baseUrl}${path}`;
const opts: RequestInit = {
method,
headers: { "Content-Type": "application/json" },
};
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
opts.body = JSON.stringify(args || {});
}
const res = await fetch(url, opts);
const contentType = res.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await res.json()
: await res.text();
return {
success: res.ok,
status: res.status,
method,
path,
data,
};
}
// =====================
// MCP Handler (Async)
// =====================
async function handleMCPRequestAsync(
request: JSONRPCRequest
): Promise<JSONRPCResponse> {
const { id, method, params } = request;
switch (method) {
case "initialize":
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "elysia-mcp-server", version: "1.0.0" },
},
};
case "tools/list":
return {
jsonrpc: "2.0",
id,
result: {
tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({
name,
description,
inputSchema,
"x-props": x,
})),
},
};
case "tools/call": {
const toolName = params?.name;
const tool = tools.find((t) => t.name === toolName);
if (!tool) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Tool '${toolName}' not found` },
};
}
try {
const baseUrl =
process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000";
const result = await executeTool(tool, params?.arguments || {}, baseUrl);
const data = result.data.data;
const isObject = typeof data === "object" && data !== null;
return {
jsonrpc: "2.0",
id,
result: {
content: [
isObject
? { type: "json", data: data }
: { type: "text", text: JSON.stringify(data || result.data || result) },
],
},
};
} catch (error: any) {
return {
jsonrpc: "2.0",
id,
error: { code: -32603, message: error.message },
};
}
}
case "ping":
return { jsonrpc: "2.0", id, result: {} };
default:
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method '${method}' not found` },
};
}
}
// =====================
// Elysia MCP Server
// =====================
export const MCPRoute = new Elysia({
tags: ["MCP Server"]
})
.post("/mcp", async ({ request, set }) => {
if (!tools.length) {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
}
set.headers["Content-Type"] = "application/json";
set.headers["Access-Control-Allow-Origin"] = "*";
try {
const body = await request.json();
if (!Array.isArray(body)) {
const res = await handleMCPRequestAsync(body);
return res;
}
const results = await Promise.all(
body.map((req) => handleMCPRequestAsync(req))
);
return results;
} catch (error: any) {
set.status = 400;
return {
jsonrpc: "2.0",
id: null,
error: {
code: -32700,
message: "Parse error",
data: error.message,
},
};
}
})
// Tools list (debug)
.get("/mcp/tools", async ({ set }) => {
if (!tools.length) {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
}
set.headers["Access-Control-Allow-Origin"] = "*";
return {
tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({
name,
description,
inputSchema,
"x-props": x,
})),
};
})
// MCP status
.get("/mcp/status", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "active", timestamp: Date.now() };
})
// Health check
.get("/health", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "ok", timestamp: Date.now(), tools: tools.length };
})
.get("/mcp/init", async ({ set }) => {
const _tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
tools = _tools;
return {
success: true,
message: "MCP initialized",
tools: tools.length,
};
})
// CORS
.options("/mcp", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS";
set.headers["Access-Control-Allow-Headers"] =
"Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
})
.options("/mcp/tools", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS";
set.headers["Access-Control-Allow-Headers"] =
"Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
});

381
bak/mcp_tool_convert.ts.txt Normal file
View File

@@ -0,0 +1,381 @@
import _ from "lodash";
interface McpTool {
name: string;
description: string;
inputSchema: any;
"x-props": {
method: string;
path: string;
operationId?: string;
tag?: string;
deprecated?: boolean;
summary?: string;
};
}
/**
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions.
*/
export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] {
const tools: McpTool[] = [];
if (!openApiJson || typeof openApiJson !== "object") {
console.warn("Invalid OpenAPI JSON");
return tools;
}
const paths = openApiJson.paths || {};
if (Object.keys(paths).length === 0) {
console.warn("No paths found in OpenAPI spec");
return tools;
}
for (const [path, methods] of Object.entries(paths)) {
if (!path || typeof path !== "string") continue;
if (path.startsWith("/mcp")) continue;
if (!methods || typeof methods !== "object") continue;
for (const [method, operation] of Object.entries<any>(methods)) {
const validMethods = ["get", "post", "put", "delete", "patch", "head", "options"];
if (!validMethods.includes(method.toLowerCase())) continue;
if (!operation || typeof operation !== "object") continue;
const tags: string[] = Array.isArray(operation.tags) ? operation.tags : [];
if (!tags.length || !tags.some(t =>
typeof t === "string" && t.toLowerCase().includes(filterTag)
)) continue;
try {
const tool = createToolFromOperation(path, method, operation, tags);
if (tool) {
tools.push(tool);
}
} catch (error) {
console.error(`Error creating tool for ${method.toUpperCase()} ${path}:`, error);
continue;
}
}
}
return tools;
}
/**
* Buat MCP tool dari operation OpenAPI
*/
function createToolFromOperation(
path: string,
method: string,
operation: any,
tags: string[]
): McpTool | null {
try {
const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool";
const name = cleanToolName(rawName);
if (!name || name === "unnamed_tool") {
console.warn(`Invalid tool name for ${method} ${path}`);
return null;
}
const description =
operation.description ||
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
// ✅ Extract schema berdasarkan method
let schema;
if (method.toLowerCase() === "get") {
// ✅ Untuk GET, ambil dari parameters (query/path)
schema = extractParametersSchema(operation.parameters || []);
} else {
// ✅ Untuk POST/PUT/etc, ambil dari requestBody
schema = extractRequestBodySchema(operation);
}
const inputSchema = createInputSchema(schema);
return {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
},
inputSchema,
};
} catch (error) {
console.error(`Failed to create tool from operation:`, error);
return null;
}
}
/**
* Extract schema dari parameters (untuk GET requests)
*/
function extractParametersSchema(parameters: any[]): any {
if (!Array.isArray(parameters) || parameters.length === 0) {
return null;
}
const properties: any = {};
const required: string[] = [];
for (const param of parameters) {
if (!param || typeof param !== "object") continue;
// ✅ Support path, query, dan header parameters
if (["path", "query", "header"].includes(param.in)) {
const paramName = param.name;
if (!paramName || typeof paramName !== "string") continue;
properties[paramName] = {
type: param.schema?.type || "string",
description: param.description || `${param.in} parameter: ${paramName}`,
};
// ✅ Copy field tambahan dari schema
if (param.schema) {
const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"];
for (const field of allowedFields) {
if (param.schema[field] !== undefined) {
properties[paramName][field] = param.schema[field];
}
}
}
if (param.required === true) {
required.push(paramName);
}
}
}
if (Object.keys(properties).length === 0) {
return null;
}
return {
type: "object",
properties,
required,
};
}
/**
* Extract schema dari requestBody (untuk POST/PUT/etc requests)
*/
function extractRequestBodySchema(operation: any): any {
if (!operation.requestBody?.content) {
return null;
}
const content = operation.requestBody.content;
const contentTypes = [
"application/json",
"multipart/form-data",
"application/x-www-form-urlencoded",
"text/plain",
];
for (const contentType of contentTypes) {
if (content[contentType]?.schema) {
return content[contentType].schema;
}
}
for (const [_, value] of Object.entries<any>(content)) {
if (value?.schema) {
return value.schema;
}
}
return null;
}
/**
* Buat input schema yang valid untuk MCP
*/
function createInputSchema(schema: any): any {
const defaultSchema = {
type: "object",
properties: {},
additionalProperties: false,
};
if (!schema || typeof schema !== "object") {
return defaultSchema;
}
try {
const properties: any = {};
const required: string[] = [];
const originalRequired = Array.isArray(schema.required) ? schema.required : [];
if (schema.properties && typeof schema.properties === "object") {
for (const [key, prop] of Object.entries<any>(schema.properties)) {
if (!key || typeof key !== "string") continue;
try {
const cleanProp = cleanProperty(prop);
if (cleanProp) {
properties[key] = cleanProp;
// ✅ PERBAIKAN: Check optional flag dengan benar
const isOptional = prop?.optional === true || prop?.optional === "true";
const isInRequired = originalRequired.includes(key);
// ✅ Hanya masukkan ke required jika memang required DAN bukan optional
if (isInRequired && !isOptional) {
required.push(key);
}
}
} catch (error) {
console.error(`Error cleaning property ${key}:`, error);
continue;
}
}
}
return {
type: "object",
properties,
required,
additionalProperties: false,
};
} catch (error) {
console.error("Error creating input schema:", error);
return defaultSchema;
}
}
/**
* Bersihkan property dari field custom
*/
function cleanProperty(prop: any): any | null {
if (!prop || typeof prop !== "object") {
return { type: "string" };
}
try {
const cleaned: any = {
type: prop.type || "string",
};
const allowedFields = [
"description",
"examples",
"example",
"default",
"enum",
"pattern",
"minLength",
"maxLength",
"minimum",
"maximum",
"format",
"multipleOf",
"exclusiveMinimum",
"exclusiveMaximum",
];
for (const field of allowedFields) {
if (prop[field] !== undefined && prop[field] !== null) {
cleaned[field] = prop[field];
}
}
if (prop.properties && typeof prop.properties === "object") {
cleaned.properties = {};
for (const [key, value] of Object.entries(prop.properties)) {
const cleanedNested = cleanProperty(value);
if (cleanedNested) {
cleaned.properties[key] = cleanedNested;
}
}
if (Array.isArray(prop.required)) {
cleaned.required = prop.required.filter((r: any) => typeof r === "string");
}
}
if (prop.items) {
cleaned.items = cleanProperty(prop.items);
}
if (Array.isArray(prop.oneOf)) {
cleaned.oneOf = prop.oneOf.map(cleanProperty).filter(Boolean);
}
if (Array.isArray(prop.anyOf)) {
cleaned.anyOf = prop.anyOf.map(cleanProperty).filter(Boolean);
}
if (Array.isArray(prop.allOf)) {
cleaned.allOf = prop.allOf.map(cleanProperty).filter(Boolean);
}
return cleaned;
} catch (error) {
console.error("Error cleaning property:", error);
return null;
}
}
/**
* Bersihkan nama tool
*/
function cleanToolName(name: string): string {
if (!name || typeof name !== "string") {
return "unnamed_tool";
}
try {
return name
.replace(/[{}]/g, "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.replace(/^(get|post|put|delete|patch|api)_/i, "")
.replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "")
.replace(/(^_|_$)/g, "")
|| "unnamed_tool";
} catch (error) {
console.error("Error cleaning tool name:", error);
return "unnamed_tool";
}
}
/**
* Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP
*/
export async function getMcpTools(url: string, filterTag: string): Promise<McpTool[]> {
try {
console.log(`Fetching OpenAPI spec from: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const openApiJson = await response.json();
const tools = convertOpenApiToMcpTools(openApiJson, filterTag);
console.log(`✅ Successfully generated ${tools.length} MCP tools`);
return tools;
} catch (error) {
console.error("Error fetching MCP tools:", error);
throw error;
}
}

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "jenna-mcp",
@@ -21,7 +22,11 @@
"@types/lodash": "^4.17.20",
"@types/uuid": "^11.0.0",
"add": "^2.0.6",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"elysia": "^1.4.15",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"react": "^19.2.0",
@@ -139,10 +144,16 @@
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
@@ -175,6 +186,8 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"biome": ["biome@0.3.3", "", { "dependencies": { "bluebird": "^3.4.1", "chalk": "^1.1.3", "commander": "^2.9.0", "editor": "^1.0.0", "fs-promise": "^0.5.0", "inquirer-promise": "0.0.3", "request-promise": "^3.0.0", "untildify": "^3.0.2", "user-home": "^2.0.0" }, "bin": { "biome": "./dist/index.js" } }, "sha512-4LXjrQYbn9iTXu9Y4SKT7ABzTV0WnLDHCVSd2fPUOKsy1gQ+E4xPFmlY1zcWexoi0j7fGHItlL6OWA2CZ/yYAQ=="],
@@ -197,6 +210,8 @@
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
"chalk": ["chalk@1.1.3", "", { "dependencies": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", "has-ansi": "^2.0.0", "strip-ansi": "^3.0.0", "supports-color": "^2.0.0" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="],
@@ -231,7 +246,7 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-js": ["core-js@2.6.12", "", {}, "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="],
"core-js": ["core-js@3.47.0", "", {}, "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg=="],
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
@@ -239,6 +254,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -265,6 +282,8 @@
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -273,6 +292,10 @@
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
"echarts": ["echarts@6.0.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" } }, "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ=="],
"echarts-for-react": ["echarts-for-react@3.0.5", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "size-sensor": "^1.0.1" }, "peerDependencies": { "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", "react": "^15.0.0 || >=16.0.0" } }, "sha512-YpEI5Ty7O/2nvCfQ7ybNa+S90DwE8KYZWacGvJW4luUqywP7qStQ+pxDlYOmr4jGDu10mhEkiAuMKcUlT4W5vg=="],
"editor": ["editor@1.0.0", "", {}, "sha512-SoRmbGStwNYHgKfjOrX2L0mUvp9bUVv0uPppZSOMAntEbcFtoC3MKF5b3T6HQPXKIV+QGY3xPO3JK5it5lVkuw=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -323,6 +346,8 @@
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -379,6 +404,8 @@
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ=="],
@@ -395,6 +422,8 @@
"inquirer-promise": ["inquirer-promise@0.0.3", "", { "dependencies": { "earlgrey-runtime": ">=0.0.11", "inquirer": "^0.11.3" } }, "sha512-82CQX586JAV9GAgU9yXZsMDs+NorjA0nLhkfFx9+PReyOnuoHRbHrC1Z90sS95bFJI1Tm1gzMObuE0HabzkJpg=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "^1.0.0" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
@@ -423,6 +452,8 @@
"jsonfile": ["jsonfile@2.4.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw=="],
"jspdf": ["jspdf@3.0.3", "", { "dependencies": { "@babel/runtime": "^7.26.9", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ=="],
"jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="],
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
@@ -487,6 +518,8 @@
"oxlint": ["oxlint@1.25.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.25.0", "@oxlint/darwin-x64": "1.25.0", "@oxlint/linux-arm64-gnu": "1.25.0", "@oxlint/linux-arm64-musl": "1.25.0", "@oxlint/linux-x64-gnu": "1.25.0", "@oxlint/linux-x64-musl": "1.25.0", "@oxlint/win32-arm64": "1.25.0", "@oxlint/win32-x64": "1.25.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.4.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-O6iJ9xeuy9eQCi8/EghvsNO6lzSaUPs0FR1uLy51Exp3RkVpjvJKyPPhd9qv65KLnfG/BNd2HE/rH0NbEfVVzA=="],
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
@@ -539,6 +572,8 @@
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="],
@@ -571,7 +606,7 @@
"readline2": ["readline2@1.0.1", "", { "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", "mute-stream": "0.0.5" } }, "sha512-8/td4MmwUB6PkZUbV25uKz7dfrmjYWxsW8DVfibWdlHRk/l/DfHKn4pU+dfcoGLFgWOdyGCzINRQD7jn+Bv+/g=="],
"regenerator-runtime": ["regenerator-runtime@0.9.6", "", {}, "sha512-D0Y/JJ4VhusyMOd/o25a3jdUqN/bC85EFsaoL9Oqmy/O4efCh+xhp7yj2EEOsj974qvMkcW8AwUzJ1jB/MbxCw=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"request": ["request@2.88.2", "", { "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", "caseless": "~0.12.0", "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~2.3.2", "har-validator": "~5.1.3", "http-signature": "~1.2.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "oauth-sign": "~0.9.0", "performance-now": "^2.1.0", "qs": "~6.5.2", "safe-buffer": "^5.1.2", "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" } }, "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw=="],
@@ -581,6 +616,8 @@
"restore-cursor": ["restore-cursor@1.0.1", "", { "dependencies": { "exit-hook": "^1.0.0", "onetime": "^1.0.0" } }, "sha512-reSjH4HuiFlxlaBaFCiS6O76ZGG2ygKoSlCsipKdaZuKSPx/+bt9mULkn4l0asVzbEfQQmXRg6Wp6gv6m0wElw=="],
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -615,10 +652,14 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"size-sensor": ["size-sensor@1.0.2", "", {}, "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"string-width": ["string-width@1.0.2", "", { "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", "strip-ansi": "^3.0.0" } }, "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw=="],
@@ -631,10 +672,14 @@
"supports-color": ["supports-color@2.0.0", "", {}, "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g=="],
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
"swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="],
"tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="],
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -651,7 +696,7 @@
"tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
@@ -687,6 +732,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
"valtio": ["valtio@2.2.0", "", { "dependencies": { "proxy-compare": "^3.0.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-l/zzQahUIm+dfUUP9fIecNVEWJLea9shMC1Bb1aK+v4XNOEzoq796Qax+yzMemmqpltuxfH7kPJy62FVGJDEtw=="],
@@ -705,12 +752,18 @@
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
"zrender": ["zrender@6.0.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg=="],
"@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=="],
"body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"earlgrey-runtime/core-js": ["core-js@2.6.12", "", {}, "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="],
"earlgrey-runtime/regenerator-runtime": ["regenerator-runtime@0.9.6", "", {}, "sha512-D0Y/JJ4VhusyMOd/o25a3jdUqN/bC85EFsaoL9Oqmy/O4efCh+xhp7yj2EEOsj974qvMkcW8AwUzJ1jB/MbxCw=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -727,12 +780,22 @@
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"react-remove-scroll-bar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"request/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"request/qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="],
"request/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="],
"use-callback-ref/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"use-sidecar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@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.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],

3
kirim.sh Normal file
View File

@@ -0,0 +1,3 @@
curl -X POST https://cld-dkr-prod-jenna-mcp.wibudev.com/api/pengaduan/upload-file-form-data \
-H "Accept: application/json" \
-F "file=@image.png"

View File

@@ -28,7 +28,11 @@
"@types/lodash": "^4.17.20",
"@types/uuid": "^11.0.0",
"add": "^2.0.6",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"elysia": "^1.4.15",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"react": "^19.2.0",

View File

@@ -9,11 +9,13 @@ datasource db {
}
model Role {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User[]
id String @id @default(cuid())
name String
permissions Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User[]
}
model User {
@@ -184,8 +186,8 @@ model SuratPelayanan {
Warga Warga @relation(fields: [idWarga], references: [id])
idWarga String
noSurat String
dateExpired DateTime @db.Date
status Int
dateExpired DateTime? @db.Date
status Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -1,5 +1,6 @@
import { categoryPelayananSurat } from "@/lib/categoryPelayananSurat";
import { confDesa } from "@/lib/configurationDesa";
import permissionConfig from "@/lib/listPermission.json"; // JSON yang kita buat
import { prisma } from "@/server/lib/prisma";
const category = [
@@ -29,14 +30,6 @@ const role = [
{
id: "developer",
name: "developer"
},
{
id: "admin",
name: "admin"
},
{
id: "pelaksana",
name: "pelaksana"
}
]
@@ -51,11 +44,30 @@ const user = [
];
(async () => {
const allKeys: string[] = [];
function collectKeys(items: any[]) {
items.forEach((item) => {
allKeys.push(item.key);
if (item.children) collectKeys(item.children);
});
}
collectKeys(permissionConfig.menus);
for (const r of role) {
await prisma.role.upsert({
where: { id: r.id },
create: r,
update: r
create: {
id: r.id,
name: r.name,
permissions: allKeys as any,
},
update: {
name: r.name,
permissions: allKeys as any,
}
})
console.log(`✅ Role ${r.name} seeded successfully`)

View File

@@ -1,7 +1,7 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "@mantine/dates/styles.css";
import { Notifications } from "@mantine/notifications";
import "@mantine/notifications/styles.css";
import { MantineProvider } from "@mantine/core";
import AppRoutes from "./AppRoutes";

View File

@@ -1,28 +1,34 @@
// ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY
import { BrowserRouter, Route, Routes } from "react-router-dom";
import DarmasabaHome from "./pages/darmasaba/darmasaba_home";
import DarmasabaLayout from "./pages/darmasaba/darmasaba_layout";
import FormKartuKeluarga from "./pages/darmasaba/form_kartu_keluarga";
import FormKartuTandaPenduduk from "./pages/darmasaba/form_kartu_tanda_penduduk";
import FormKeteranganKelahiran from "./pages/darmasaba/form_keterangan_kelahiran";
import FormLaporanSampah from "./pages/darmasaba/form_laporan_sampah";
import FormSuratKeteranganBelumKawin from "./pages/darmasaba/form_surat_keterangan_belum_kawin";
import FormSuratKeteranganDomisiliOrganisasi from "./pages/darmasaba/form_surat_keterangan_domisili_organisasi";
import FormSuratKeteranganKelakuanBaik from "./pages/darmasaba/form_surat_keterangan_kelakuan_baik";
import FormSuratKeteranganPenghasilan from "./pages/darmasaba/form_surat_keterangan_penghasilan";
import FormSuratKeteranganTempatUsaha from "./pages/darmasaba/form_surat_keterangan_tempat_usaha";
import FormSuratKeteranganTidakMampu from "./pages/darmasaba/form_surat_keterangan_tidak_mampu";
import FormSuratKeteranganUsaha from "./pages/darmasaba/form_surat_keterangan_usaha";
import DirPage from "./pages/dir/dir_page";
import Home from "./pages/Home";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Login from "./pages/Login";
import NotFound from "./pages/NotFound";
import ApikeyPage from "./pages/scr/dashboard/apikey/apikey_page";
import DarmasabaLayout from "./pages/darmasaba/darmasaba_layout";
import FormSuratKeteranganUsaha from "./pages/darmasaba/form_surat_keterangan_usaha";
import FormSuratKeteranganTidakMampu from "./pages/darmasaba/form_surat_keterangan_tidak_mampu";
import DarmasabaHome from "./pages/darmasaba/darmasaba_home";
import FormKartuTandaPenduduk from "./pages/darmasaba/form_kartu_tanda_penduduk";
import FormKartuKeluarga from "./pages/darmasaba/form_kartu_keluarga";
import FormLaporanSampah from "./pages/darmasaba/form_laporan_sampah";
import FormSuratKeteranganPenghasilan from "./pages/darmasaba/form_surat_keterangan_penghasilan";
import FormSuratKeteranganDomisiliOrganisasi from "./pages/darmasaba/form_surat_keterangan_domisili_organisasi";
import FormSuratKeteranganBelumKawin from "./pages/darmasaba/form_surat_keterangan_belum_kawin";
import FormKeteranganKelahiran from "./pages/darmasaba/form_keterangan_kelahiran";
import FormSuratKeteranganTempatUsaha from "./pages/darmasaba/form_surat_keterangan_tempat_usaha";
import FormSuratKeteranganKelakuanBaik from "./pages/darmasaba/form_surat_keterangan_kelakuan_baik";
import Home from "./pages/Home";
import CredentialPage from "./pages/scr/dashboard/credential/credential_page";
import DashboardHome from "./pages/scr/dashboard/dashboard_home";
import DashboardLayout from "./pages/scr/dashboard/dashboard_layout";
import ListPelayananPage from "./pages/scr/dashboard/pelayanan-surat/list_pelayanan_page";
import DetailPelayananPage from "./pages/scr/dashboard/pelayanan-surat/detail_pelayanan_page";
import DetailWargaPage from "./pages/scr/dashboard/warga/detail_warga_page";
import ListWargaPage from "./pages/scr/dashboard/warga/list_warga_page";
import ListPage from "./pages/scr/dashboard/pengaduan/list_page";
import DetailPage from "./pages/scr/dashboard/pengaduan/detail_page";
import ApikeyPage from "./pages/scr/dashboard/apikey/apikey_page";
import DashboardLayout from "./pages/scr/dashboard/dashboard_layout";
import DetailSettingPage from "./pages/scr/dashboard/setting/detail_setting_page";
import ScrLayout from "./pages/scr/scr_layout";
import DirPage from "./pages/dir/dir_page";
import NotFound from "./pages/NotFound";
export default function AppRoutes() {
return (
@@ -93,14 +99,38 @@ export default function AppRoutes() {
path="/scr/dashboard/dashboard-home"
element={<DashboardHome />}
/>
<Route
path="/scr/dashboard/pelayanan-surat/list-pelayanan"
element={<ListPelayananPage />}
/>
<Route
path="/scr/dashboard/pelayanan-surat/detail-pelayanan"
element={<DetailPelayananPage />}
/>
<Route
path="/scr/dashboard/warga/detail-warga"
element={<DetailWargaPage />}
/>
<Route
path="/scr/dashboard/warga/list-warga"
element={<ListWargaPage />}
/>
<Route
path="/scr/dashboard/pengaduan/list"
element={<ListPage />}
/>
<Route
path="/scr/dashboard/pengaduan/detail"
element={<DetailPage />}
/>
<Route
path="/scr/dashboard/apikey/apikey"
element={<ApikeyPage />}
/>
<Route
path="/scr/dashboard/setting/detail-setting"
element={<DetailSettingPage />}
/>
</Route>
</Route>
<Route path="/dir/dir" element={<DirPage />} />

View File

@@ -19,8 +19,14 @@ const clientRoutes = {
"/scr/dashboard": "/scr/dashboard",
"/scr/dashboard/credential/credential": "/scr/dashboard/credential/credential",
"/scr/dashboard/dashboard-home": "/scr/dashboard/dashboard-home",
"/scr/dashboard/pelayanan-surat/list-pelayanan": "/scr/dashboard/pelayanan-surat/list-pelayanan",
"/scr/dashboard/pelayanan-surat/detail-pelayanan": "/scr/dashboard/pelayanan-surat/detail-pelayanan",
"/scr/dashboard/warga/detail-warga": "/scr/dashboard/warga/detail-warga",
"/scr/dashboard/warga/list-warga": "/scr/dashboard/warga/list-warga",
"/scr/dashboard/pengaduan/list": "/scr/dashboard/pengaduan/list",
"/scr/dashboard/pengaduan/detail": "/scr/dashboard/pengaduan/detail",
"/scr/dashboard/apikey/apikey": "/scr/dashboard/apikey/apikey",
"/scr/dashboard/setting/detail-setting": "/scr/dashboard/setting/detail-setting",
"/dir/dir": "/dir/dir",
"/*": "/*"
} as const;

View File

@@ -0,0 +1,98 @@
import apiFetch from "@/lib/apiFetch";
import { Card, Flex, Grid, Group, Stack, Text } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconFileCertificate, IconMessageReport, IconUsers } from "@tabler/icons-react";
import useSWR from "swr";
export default function DashboardCountData() {
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api.dashboard.count.get()
);
useShallowEffect(() => {
mutate();
}, []);
return (
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<MetricCard
icon={<IconMessageReport size={28} />}
label="Pengaduan Hari Ini"
value={String(data?.data?.pengaduan?.today)}
change={String(data?.data?.pengaduan?.kenaikan) + "%"}
color={"gray"}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<MetricCard
icon={<IconFileCertificate size={28} />}
label="Pengajuan Surat Hari Ini"
value={String(data?.data?.pelayanan?.today)}
change={String(data?.data?.pelayanan?.kenaikan) + "%"}
color="gray"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<MetricCard
icon={<IconUsers size={28} />}
label="Warga"
value={String(data?.data?.warga)}
color="blue"
/>
</Grid.Col>
</Grid>
);
}
function MetricCard({
icon,
label,
value,
change,
color,
}: {
icon: React.ReactNode;
label: string;
value: string;
change?: string;
color: string;
}) {
return (
<Card
radius="lg"
p="md"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(30,30,30,0.95), rgba(55,55,55,0.9))",
borderColor: "rgba(100,100,100,0.2)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.boxShadow = "0 0 10px rgba(0,255,200,0.2)")
}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
>
<Stack gap={6}>
<Group gap={6}>
{icon}
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Flex align="center" justify="space-between">
<Text fw={600} size="xl" c="gray.0">
{value}
</Text>
{change && (
<Text size="sm" c={color}>
{change}
</Text>
)}
</Flex>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,83 @@
import apiFetch from "@/lib/apiFetch";
import { Card, Divider, Flex, Stack, Text, Title } from "@mantine/core";
import { IconChartBar } from "@tabler/icons-react";
import type { EChartsOption } from "echarts";
import EChartsReact from "echarts-for-react";
import { useEffect, useState } from "react";
import useSWR from "swr";
export default function DashboardGrafik() {
const [options, setOptions] = useState<EChartsOption>({});
const { data, mutate, isLoading } = useSWR(
"grafik-dashboard",
async () => {
return apiFetch.api.dashboard.grafik.get().then(res => res.data);
}
);
const loadData = () => {
if (!data) return;
const option: EChartsOption = {
darkMode: true,
animation: true,
legend: {
textStyle: { color: "#fff" }
},
tooltip: {},
dataset: {
dimensions: data.dimensions,
source: data.source
},
xAxis: {
type: "category",
axisLabel: { color: "#fff" }
},
yAxis: {
type: "value",
minInterval: 1,
axisLabel: { color: "#fff" }
},
color: ["#1abc9c", "#10816aff"],
series: [
{ type: "bar" },
{ type: "bar" }
]
};
setOptions(option);
};
useEffect(() => {
if (data) loadData();
}, [data]);
return (
<Card
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 25px rgba(0,255,200,0.08)",
}}
>
<Stack gap="sm">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={4} c="gray.0">
Grafik Pengaduan dan Pelayanan Surat
</Title>
<Text size="sm">7 Hari Terakhir</Text>
</Flex>
<IconChartBar size={20} color="gray" />
</Flex>
<Divider my="xs" />
<Stack gap="sm">
<EChartsReact style={{ height: 400 }} option={options} />
</Stack>
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,133 @@
import apiFetch from "@/lib/apiFetch";
import { Badge, Button, Card, Flex, Group, Stack, Text, Title, Tooltip } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
export default function DashboardLastData() {
const navigate = useNavigate();
const { data, mutate, isLoading } = useSWR("last-update", async () => {
const res = await apiFetch.api.dashboard["last-update"].get();
return res.data
});
useShallowEffect(() => {
mutate();
}, []);
return (
<Flex justify="flex-start" gap="md">
<Card
radius="lg"
p="xl"
withBorder
w={"50%"}
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 25px rgba(0,255,200,0.08)",
}}
>
<Stack gap="sm">
<Flex align="center" pb={"sm"} justify="space-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
<Title order={4} c="gray.0">
Last update pengaduan
</Title>
<Button variant="subtle" size="xs" radius="md" onClick={() => navigate(`/scr/dashboard/pengaduan/list`)}>View All</Button>
</Flex>
<Stack gap="sm" mt="md" align="stretch" justify="center">
{
data && Array.isArray(data.pengaduan) && data.pengaduan.length > 0 ? data.pengaduan.map((item: any, index: number) => (
<PengaduanSection
key={index}
id={item.id}
nomer={item.noPengaduan}
judul={item.title}
status={item.status}
updated={item.updatedAt}
kategori="pengaduan"
/>
)) : <Text c="dimmed" ta={"center"} >Tidak ada data</Text>
}
</Stack>
</Stack>
</Card>
<Card
radius="lg"
p="xl"
withBorder
w={"50%"}
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 25px rgba(0,255,200,0.08)",
}}
>
<Stack gap="sm">
<Flex align="center" pb={"sm"} justify="space-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
<Title order={4} c="gray.0">
Last update pelayanan surat
</Title>
<Button variant="subtle" size="xs" radius="md" onClick={() => navigate(`/scr/dashboard/pelayanan-surat/list-pelayanan`)}>View All</Button>
</Flex>
<Stack gap="sm" mt="md" align="stretch" justify="center">
{
data && Array.isArray(data.pelayanan) && data.pelayanan.length > 0 ? data.pelayanan.map((item: any, index: number) => (
<PengaduanSection
key={index}
id={item.id}
nomer={item.noPengajuan}
judul={item.title}
status={item.status}
updated={item.updatedAt}
kategori="pelayanan"
/>
)) : <Text c="dimmed" ta={"center"} >Tidak ada data</Text>
}
</Stack>
</Stack>
</Card>
</Flex>
);
}
function PengaduanSection({ id, nomer, judul, status, updated, kategori }: { id: string, nomer: string, judul: string, status: string, updated: string, kategori: 'pengaduan' | 'pelayanan' }) {
const navigate = useNavigate();
return (
<Stack
gap="xs"
onClick={() => navigate(kategori == "pelayanan" ? `/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${id}` : `/scr/dashboard/pengaduan/detail?id=${id}`)}
>
<Flex align="center" pb={"sm"} justify="space-between" gap="md" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
<Flex direction={"column"}>
<Text size="md" c="gray.2" lineClamp={1}>
{judul}
</Text>
<Group>
<Text size="sm" c="dimmed">
#{nomer} {updated}
</Text>
</Group>
</Flex>
<Tooltip label={status}>
<Badge size="xs" circle color={
status === "diterima"
? "green"
: status === "ditolak"
? "red"
: status === "selesai"
? "blue"
: status === "dikerjakan"
? "gray"
: "yellow"
} />
</Tooltip>
</Flex>
</Stack>
)
}

View File

@@ -0,0 +1,240 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Anchor,
Button,
Divider,
FileInput,
Flex,
Group,
Input,
Modal,
Stack,
Table,
Title,
Tooltip
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useState } from "react";
import useSWR from "swr";
import ModalFile from "./ModalFile";
import notification from "./notificationGlobal";
export default function DesaSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(false);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [img, setImg] = useState<any>()
const [openedPreview, setOpenedPreview] = useState(false);
const [viewImg, setViewImg] = useState("");
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api["configuration-desa"].list.get(),
);
const list = data?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
value: "",
name: "",
});
useShallowEffect(() => {
mutate();
}, []);
async function handleEdit() {
try {
setBtnLoading(true);
let finalData = { ...dataEdit }; // ← buffer data terbaru
if (dataEdit.name === "TTD") {
const oldImg = await apiFetch.api.pengaduan["delete-image"].post({ file: dataEdit.value, folder: "lainnya" });
const resImg = await apiFetch.api.pengaduan.upload.post({ file: img, folder: "lainnya" });
if (resImg.status === 200) {
finalData = {
...finalData,
value: resImg.data?.filename || ""
};
setDataEdit(finalData); // update state
} else {
return notification({
title: "Error",
message: "Failed to upload image",
type: "error",
});
}
}
const res = await apiFetch.api["configuration-desa"].edit.post(finalData);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your settings have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit configuration",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit configuration",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({ data }: { data: { id: string; value: string; name: string }; }) {
setDataEdit(data);
open();
}
function onValidation({ kat, value }: { kat: "value"; value: string }) {
if (value.length < 1) {
setBtnDisable(true);
} else {
setBtnDisable(false);
}
if (kat === "value") {
setDataEdit({ ...dataEdit, value: value });
}
}
useShallowEffect(() => {
if (dataEdit.value.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
return (
<>
<Modal
opened={opened}
onClose={close}
title={"Edit"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
{
dataEdit.name == "TTD"
?
(
<Input.Wrapper label={dataEdit.name}>
<FileInput
clearable
placeholder="Upload TTD"
accept="image/*"
onChange={(e) => { setImg(e) }}
/>
</Input.Wrapper>
)
:
(
<Input.Wrapper label={dataEdit.name}>
<Input
value={dataEdit.value}
onChange={(e) =>
onValidation({ kat: "value", value: e.target.value })
}
/>
</Input.Wrapper>
)
}
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={btnDisable || (dataEdit.name == "TTD" && !img)}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
<ModalFile
open={openedPreview && !_.isEmpty(viewImg)}
onClose={() => setOpenedPreview(false)}
folder="lainnya"
fileName={viewImg}
/>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Pengaturan Desa
</Title>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>Value</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>
{
v.name == "TTD"
?
v.value ?
<Anchor href="#" onClick={() => { setViewImg(v.value); setOpenedPreview(true); }} underline="always">
Lihat
</Anchor>
:
"-"
:
v.value
}
</Table.Td>
<Table.Td>
<Tooltip label={permissions.includes("setting.desa.edit") ? "Edit Setting" : "Edit Setting - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes("setting.desa.edit")}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,620 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Grid,
Group,
Input,
List,
Modal,
Stack,
Table,
TagsInput,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconEye, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPelayananSurat({ permissions }: { permissions: JsonValue[] }) {
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [openedDetail, { open: openDetail, close: closeDetail }] =
useDisclosure(false);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api.pelayanan.category.get(),
);
const list = data?.data || [];
const [dataChoose, setDataChoose] = useState({
id: "",
name: "",
syaratDokumen: [{ name: "", desc: "" }],
dataText: [""],
});
const [dataTambah, setDataTambah] = useState({
name: "",
syaratDokumen: [{ name: "", desc: "" }],
dataText: [""],
});
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const cleanedDataText = dataTambah.dataText
.map((v) => v.trim())
.filter((v) => v !== "");
const cleanedSyarat = dataTambah.syaratDokumen
.map((item) => ({
name: item.name.trim(),
desc: item.desc.trim(),
}))
.filter((item) => item.name !== "" && item.desc !== "");
const cleanedTambah = {
name: dataTambah.name.trim(),
syaratDokumen: cleanedSyarat,
dataText: cleanedDataText,
};
const res =
await apiFetch.api.pelayanan.category.create.post(cleanedTambah);
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah({
name: "",
syaratDokumen: [{ name: "", desc: "" }],
dataText: [""],
});
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const cleanedDataText = dataChoose.dataText
.map((v) => v.trim())
.filter((v) => v !== "");
const cleanedSyarat = dataChoose.syaratDokumen
.map((item) => ({
name: item.name.trim(),
desc: item.desc.trim(),
}))
.filter((item) => item.name !== "" && item.desc !== "");
const res = await apiFetch.api.pelayanan.category.update.post({
id: dataChoose.id,
name: dataChoose.name,
syaratDokumen: cleanedSyarat,
dataText: cleanedDataText,
});
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pelayanan.category.delete.post({
id: dataDelete,
});
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your category have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function handleAddSyarat() {
setDataChoose({
...dataChoose,
syaratDokumen: [...dataChoose.syaratDokumen, { name: "", desc: "" }],
});
}
function handleDeleteSyarat(index: number) {
setDataChoose({
...dataChoose,
syaratDokumen: dataChoose.syaratDokumen.filter((_, i) => i !== index),
});
}
function handleEditSyarat(
index: number,
data: { name: string; desc: string },
) {
setDataChoose({
...dataChoose,
syaratDokumen: dataChoose.syaratDokumen.map((v, i) =>
i === index ? data : v,
),
});
}
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
size="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="lg">
<Input.Wrapper label="Kategori">
<Input
value={dataChoose.name}
onChange={(e) =>
setDataChoose({ ...dataChoose, name: e.target.value })
}
/>
</Input.Wrapper>
<TagsInput
label="Data Pelengkap"
placeholder="Tambah data pelengkap"
splitChars={[","]}
value={dataChoose.dataText}
onChange={(value) =>
setDataChoose({ ...dataChoose, dataText: value })
}
/>
<Flex direction={"column"} gap={"md"}>
<Group>
<Text size="sm" c={"white"}>
Syarat dokumen
</Text>
<Tooltip label="Tambah Syarat Dokumen">
<ActionIcon
variant="light"
size="sm"
color="blue"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={handleAddSyarat}
>
<IconPlus size={20} />
</ActionIcon>
</Tooltip>
</Group>
{dataChoose?.syaratDokumen?.map((v: any, i: number) => (
<Grid
key={i}
style={{
borderBottom: "1px solid gray",
paddingBottom: "10px",
}}
>
<Grid.Col
span={1}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Tooltip label="Delete Syarat Dokumen">
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
handleDeleteSyarat(i);
}}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Grid.Col>
<Grid.Col span={5}>
<Input.Wrapper label="Nama">
<Input
value={v.name}
onChange={(e) =>
handleEditSyarat(i, {
name: e.target.value,
desc: v.desc,
})
}
/>
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={6}>
<Input.Wrapper label="Deskripsi">
<Input
value={v.desc}
onChange={(e) =>
handleEditSyarat(i, {
name: v.name,
desc: e.target.value,
})
}
/>
</Input.Wrapper>
</Grid.Col>
</Grid>
))}
</Flex>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button variant="filled" onClick={handleEdit} loading={btnLoading}>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size={"lg"}
>
<Stack gap="lg">
<Input.Wrapper label="Tambah Kategori Pelayanan Surat">
<Input
value={dataTambah.name}
onChange={(e) =>
setDataTambah({ ...dataTambah, name: e.target.value })
}
/>
</Input.Wrapper>
<TagsInput
label="Data Pelengkap"
placeholder="Tambah data pelengkap"
splitChars={[","]}
value={dataTambah.dataText}
onChange={(value) =>
setDataTambah({ ...dataTambah, dataText: value })
}
/>
<Flex direction={"column"} gap={"md"}>
<Group>
<Text size="sm" c={"white"}>
Syarat dokumen
</Text>
<Tooltip label="Tambah Syarat Dokumen">
<ActionIcon
variant="light"
size="sm"
color="blue"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataTambah({
...dataTambah,
syaratDokumen: [
...dataTambah.syaratDokumen,
{ name: "", desc: "" },
],
});
}}
>
<IconPlus size={20} />
</ActionIcon>
</Tooltip>
</Group>
{dataTambah?.syaratDokumen?.map((v: any, index: number) => (
<Grid
key={index}
style={{
borderBottom: "1px solid gray",
paddingBottom: "10px",
}}
>
<Grid.Col
span={1}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Tooltip label="Delete Syarat Dokumen">
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataTambah({
...dataTambah,
syaratDokumen: dataTambah.syaratDokumen.filter(
(v: any, i: number) => i !== index,
),
});
}}
disabled={dataTambah?.syaratDokumen?.length === 1}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Grid.Col>
<Grid.Col span={5}>
<Input.Wrapper label="Nama">
<Input
value={dataTambah?.syaratDokumen[index]?.name}
onChange={(e) =>
setDataTambah({
...dataTambah,
syaratDokumen: dataTambah.syaratDokumen.map(
(v: any, i: number) =>
i === index ? { ...v, name: e.target.value } : v,
),
})
}
/>
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={6}>
<Input.Wrapper label="Deskripsi">
<Input
value={dataTambah?.syaratDokumen[index]?.desc}
onChange={(e) =>
setDataTambah({
...dataTambah,
syaratDokumen: dataTambah.syaratDokumen.map(
(v: any, i: number) =>
i === index ? { ...v, desc: e.target.value } : v,
),
})
}
/>
</Input.Wrapper>
</Grid.Col>
</Grid>
))}
</Flex>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete Kategori Pelayanan Surat"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus kategori ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Detail */}
<Modal
opened={openedDetail}
onClose={closeDetail}
title={"Detail Kategori"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size={"lg"}
>
<Stack gap="sm">
<Flex direction={"column"}>
<Text size="sm" color="white">
Kategori
</Text>
<Text size="md">{dataChoose?.name ?? ""}</Text>
</Flex>
<Flex direction={"column"}>
<Text size="sm" color="white">
Syarat Dokumen
</Text>
<List>
{dataChoose?.syaratDokumen?.map((v: any) => (
<List.Item key={v.id}>{v.desc}</List.Item>
))}
</List>
</Flex>
<Flex direction={"column"}>
<Text size="sm" color="white">
Data Pelengkap
</Text>
<List>
{dataChoose?.dataText?.map((v: any) => (
<List.Item key={v.id}>{v}</List.Item>
))}
</List>
</Flex>
</Stack>
</Modal>
{/* Table */}
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Kategori Pelayanan Surat
</Title>
{
permissions.includes("setting.kategori_pelayanan.tambah") && (
<Tooltip label="Tambah Kategori Pelayanan Surat">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Kategori</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="View Detail">
<ActionIcon
variant="light"
size="sm"
color="green"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataChoose(v);
openDetail();
}}
>
<IconEye size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label={permissions.includes("setting.kategori_pelayanan.edit") ? "Edit Kategori" : "Edit Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataChoose(v);
open();
}}
disabled={!permissions.includes("setting.kategori_pelayanan.edit")}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label={permissions.includes("setting.kategori_pelayanan.delete") ? "Hapus Kategori" : "Hapus Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes("setting.kategori_pelayanan.delete")}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,362 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPengaduan({ permissions }: { permissions: JsonValue[] }) {
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api.pengaduan.category.get(),
);
const list = data?.data?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
name: "",
});
const [dataTambah, setDataTambah] = useState("");
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.create.post({
name: dataTambah,
});
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah("");
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.update.post(dataEdit);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({
data,
}: {
data: { id: string; value: string; name: string };
}) {
setDataEdit(data);
open();
}
function onValidation({
kat,
value,
aksi,
}: {
kat: "name";
value: string;
aksi: "edit" | "tambah";
}) {
if (value.length < 1) {
setBtnDisable(true);
} else {
setBtnDisable(false);
}
if (kat === "name") {
if (aksi === "edit") {
setDataEdit({ ...dataEdit, name: value });
} else {
setDataTambah(value);
}
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.delete.post({
id: dataDelete,
});
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your category have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
useShallowEffect(() => {
if (dataEdit.name.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
useShallowEffect(() => {
if (dataTambah.length > 0) {
setBtnDisable(false);
}
}, [dataTambah]);
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label="Edit Kategori">
<Input
value={dataEdit.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={btnDisable}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label="Tambah Kategori">
<Input
value={dataTambah}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
disabled={btnDisable}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus kategori ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Kategori Pengaduan
</Title>
{
permissions.includes("setting.kategori_pengaduan.tambah") && (
<Tooltip label="Tambah Kategori Pengaduan">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Kategori</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>
<Group>
<Tooltip label={permissions.includes("setting.kategori_pengaduan.edit") ? "Edit Kategori" : "Edit Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes("setting.kategori_pengaduan.edit") || v.id == "lainnya"}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label={permissions.includes("setting.kategori_pengaduan.delete") ? "Hapus Kategori" : "Hapus Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes("setting.kategori_pengaduan.delete") || v.id == "lainnya"}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,94 @@
import { detectFileType } from "@/server/lib/detect-type-of-file";
import { Flex, Image, Loader, Modal } from "@mantine/core";
import { useEffect, useState } from "react";
import notification from "./notificationGlobal";
export default function ModalFile({ open, onClose, folder, fileName }: { open: boolean, onClose: () => void, folder: string, fileName: string }) {
const [viewFile, setViewFile] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [typeFile, setTypeFile] = useState<string>("");
const [error, setError] = useState<boolean>(false);
useEffect(() => {
if (open && fileName) {
loadImage();
}
}, [open, fileName]);
const loadImage = async () => {
try {
setViewFile("");
setLoading(true);
// detect type of file
const { ext, type } = detectFileType(fileName);
setTypeFile(type || "");
// load file
const urlApi = '/api/pengaduan/image?folder=' + folder + '&fileName=' + fileName;
const res = await fetch(urlApi);
if (!res.ok) {
setError(true);
return notification({
title: "Error",
message: "Failed to load image",
type: "error",
});
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewFile(url);
} catch (err) {
setError(true);
notification({
title: "Error",
message: "Failed to load image",
type: "error",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
if (error) {
onClose();
}
}, [error]);
return (
<Modal
opened={open}
onClose={onClose}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size="xl"
withCloseButton
removeScrollProps={{ allowPinchZoom: true }}
title="File"
>
{loading && (
<Flex justify="center" align="center" h={200}>
<Loader />
</Flex>
)}
{viewFile && (
<>
{typeFile == "pdf" ? (
<embed src={viewFile} type="application/pdf" width="100%" height="950" />
) : (
<Image
radius="md"
h={300}
fit="contain"
src={viewFile}
/>
)}
</>
)}
</Modal>
);
}

View File

@@ -0,0 +1,139 @@
import apiFetch from "@/lib/apiFetch";
import { ActionIcon, Flex, Modal } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconDownload, IconX } from "@tabler/icons-react";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
import { useRef } from "react";
import useSWR from "swr";
import SKBedaBiodataDiri from "./surat/SKBedaBiodataDiri";
import SKBelumKawin from "./surat/SKBelumKawin";
import SKDomisiliOrganisasi from "./surat/SKDomisiliOrganisasi";
import SKKelahiran from "./surat/SKKelahiran";
import SKKelakuanBaik from "./surat/SKKelakuanBaik";
import SKKematian from "./surat/SKKematian";
import SKPenghasilan from "./surat/SKPenghasilan";
import SKTempatUsaha from "./surat/SKTempatUsaha";
import SKTidakMampu from "./surat/SKTidakMampu";
import SKUsaha from "./surat/SKUsaha";
import SKYatim from "./surat/SKYatimPiatu";
export default function ModalSurat({ open, onClose, surat }: { open: boolean, onClose: () => void, surat: string }) {
const A4Style = {
width: "210mm",
height: "297mm",
padding: "20mm",
background: "#fff",
color: "#000",
fontSize: "14px",
fontFamily: "Times New Roman",
};
const hiddenRef = useRef<any>(null);
const { data, mutate, isLoading } = useSWR("surat", () =>
apiFetch.api.surat.detail.get({
query: {
id: surat,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
const downloadPDF = async () => {
const element = hiddenRef.current;
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
allowTaint: true,
width: element.offsetWidth,
height: element.offsetHeight,
});
const imgData = canvas.toDataURL("image/jpeg", 1.0);
const pdf = new jsPDF("p", "mm", "a4");
const pageWidth = 210; // A4 width mm
const pageHeight = 297; // A4 height mm
const imgWidth = pageWidth;
const imgHeight = (canvas.height * pageWidth) / canvas.width;
pdf.addImage(imgData, "JPEG", 0, 0, imgWidth, imgHeight);
pdf.save(`${data?.data?.surat?.nameCategory}.pdf`);
};
return (
<>
<Modal
opened={open}
onClose={() => onClose()}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size="auto"
withCloseButton={false}
removeScrollProps={{ allowPinchZoom: true }}
styles={{
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
},
title: {
width: "100%",
}
}}
title={
<Flex justify="space-between" align="center" w="100%">
<div style={{ fontSize: 18, fontWeight: 600 }}>
Preview Surat
</div>
<Flex gap={8}>
<ActionIcon size={32} variant="default">
<IconDownload size={20} onClick={downloadPDF} />
</ActionIcon>
<ActionIcon size={32} variant="default" onClick={onClose}>
<IconX size={20} />
</ActionIcon>
</Flex>
</Flex>
}
>
<div ref={hiddenRef} style={A4Style}>
{
data && data.data
? data.data.surat.idCategory == "skusaha"
? <SKUsaha data={data.data} />
: data.data.surat.idCategory == "skkelahiran"
? <SKKelahiran data={data.data} />
: data.data.surat.idCategory == "skkelakuanbaik"
? <SKKelakuanBaik data={data.data} />
: data.data.surat.idCategory == "skpenghasilan"
? <SKPenghasilan data={data.data} />
: data.data.surat.idCategory == "sktidakmampu"
? <SKTidakMampu data={data.data} />
: data.data.surat.idCategory == "skyatimpiatu"
? <SKYatim data={data.data} />
: data.data.surat.idCategory == "skdomisiliorganisasi"
? <SKDomisiliOrganisasi data={data.data} />
: data.data.surat.idCategory == "skbedabiodata"
? <SKBedaBiodataDiri data={data.data} />
: data.data.surat.idCategory == "sktempatusaha"
? <SKTempatUsaha data={data.data} />
: data.data.surat.idCategory == "skbelumkawin"
? <SKBelumKawin data={data.data} />
: data.data.surat.idCategory == "skkematian"
? <SKKematian data={data.data} />
: <></>
: <></>
}
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,67 @@
import { groupPermissions } from "@/lib/groupPermission";
import { Anchor, Flex, Stack, Text } from "@mantine/core";
import { useState } from "react";
interface Node {
label: string;
children: any;
actions: string[];
}
function RenderNode({ node }: { node: Node }) {
const sub = Object.values(node.children || {});
return (
<Stack pl="md" gap={6}>
{/* Title */}
<Text size="sm">- {node.label}</Text>
{/* Children */}
{sub.map((child: any, i) => (
<RenderNode key={i} node={child} />
))}
</Stack>
);
}
function RenderNode2({ node }: { node: Node }) {
const sub = Object.values(node.children || {});
return (
<Flex direction={"row"} wrap={'wrap'} gap={6}>
{/* Title */}
<Text size="sm">{node.label},</Text>
{/* Children */}
{sub.map((child: any, i) => (
<RenderNode2 key={i} node={child} />
))}
</Flex>
);
}
export default function PermissionRole({ permissions }: { permissions: string[] }) {
const [showAll, setShowAll] = useState(false);
if (!permissions?.length) return <Text c="dimmed">-</Text>;
const groups = groupPermissions(permissions);
const rootNodes = Object.values(groups);
return (
<Stack gap="sm">
{
showAll ?
rootNodes.map((node: any, idx) => (
<RenderNode key={idx} node={node} />
))
:
rootNodes.slice(0, 2).map((node: any, idx) => (
<RenderNode2 key={idx} node={node} />
))
}
<Anchor size="xs" onClick={() => setShowAll(!showAll)} >
{showAll ? "View less" : "View more"}
</Anchor>
</Stack>
);
}

View File

@@ -0,0 +1,177 @@
import permissionConfig from "@/lib/listPermission.json";
import { ActionIcon, Checkbox, Collapse, Group, Stack, Text } from "@mantine/core";
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { useState } from "react";
interface Node {
label: string;
key: string;
children?: Node[];
}
export default function PermissionTree({
selected,
onChange,
}: {
selected: string[];
onChange: (val: string[]) => void;
}) {
// Ambil semua child dari node
const [openNodes, setOpenNodes] = useState<Record<string, boolean>>({});
function toggleNode(label: string) {
setOpenNodes(prev => ({ ...prev, [label]: !prev[label] }));
}
function getAllChildKeys(node: Node): string[] {
let result: string[] = [];
if (node.children) {
node.children.forEach((c) => {
result.push(c.key);
result = [...result, ...getAllChildKeys(c)];
});
}
return result;
}
// Dapatkan parentKey, jika ada
function getParentKey(key: string) {
const split = key.split(".");
if (split.length <= 1) return null;
split.pop();
return split.join(".");
}
// Update parent ke atas secara rekursif
function updateParent(next: string[], parentKey: string | null): string[] {
if (!parentKey) return next;
const allChildKeys = findAllChildKeysFromKey(parentKey);
const selectedChild = allChildKeys.filter((c) => next.includes(c));
if (selectedChild.length === 0) {
// Semua child uncheck → parent uncheck
next = next.filter((x) => x !== parentKey);
} else if (selectedChild.length === allChildKeys.length) {
// Semua child check → parent check
if (!next.includes(parentKey)) {
next.push(parentKey);
}
} else {
// Sebagian child check → parent intermediate (checked = true, rendered sebagai indeterminate)
if (!next.includes(parentKey)) {
next.push(parentKey);
}
}
// Rekursif naik ke atas
return updateParent(next, getParentKey(parentKey));
}
// dapatkan child dari string key
function findAllChildKeysFromKey(parentKey: string) {
const list: string[] = [];
function traverse(nodes: Node[]) {
nodes.forEach((n) => {
if (n.key.startsWith(parentKey + ".") && n.key !== parentKey) {
list.push(n.key);
}
if (n.children) traverse(n.children);
});
}
traverse(permissionConfig.menus);
return list;
}
const RenderMenu = ({ menu }: { menu: Node }) => {
const hasChild = menu.children && menu.children.length > 0;
const open = openNodes[menu.label] ?? false;
const childKeys = getAllChildKeys(menu);
const isChecked = selected.includes(menu.key);
const isIndeterminate =
!isChecked &&
selected.some(
(x) =>
typeof x === "string" &&
x.startsWith(menu.key + ".")
);
function handleCheck() {
let next = [...selected];
if (childKeys.length > 0) {
// klik parent
if (!isChecked) {
next = [...new Set([...next, menu.key, ...childKeys])];
} else {
next = next.filter((x) => x !== menu.key && !childKeys.includes(x));
}
next = updateParent(next, getParentKey(menu.key));
onChange(next);
return;
}
// klik child
if (isChecked) {
next = next.filter((x) => x !== menu.key);
} else {
next.push(menu.key);
}
next = updateParent(next, getParentKey(menu.key));
onChange(next);
}
return (
<Stack gap={4}>
<Group gap="xs">
{menu.children && menu.children.length > 0 ? (
<ActionIcon
variant="subtle"
onClick={() => toggleNode(menu.label)}
>
{openNodes[menu.label] ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
</ActionIcon>
) : (
<div style={{ width: 28 }} />
)}
<Checkbox
label={menu.label}
checked={isChecked}
indeterminate={isIndeterminate}
onChange={handleCheck}
/>
</Group>
{menu.children && (
<Collapse in={open}>
<Stack gap={4} pl="md">
{menu.children.map((child) => (
<RenderMenu key={child.key} menu={child} />
))}
</Stack>
</Collapse>
)}
</Stack>
);
};
return (
<Stack>
<Text size="sm">Hak Akses</Text>
{permissionConfig.menus.filter((menu: Node) => !menu.key.startsWith("api") && !menu.key.startsWith("credential")).map((menu: Node) => (
<RenderMenu key={menu.key} menu={menu} />
))}
</Stack>
);
}

View File

@@ -0,0 +1,256 @@
import apiFetch from "@/lib/apiFetch";
import {
Button,
Divider,
Flex,
Group,
Input,
Modal,
Stack,
Title,
} from "@mantine/core";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import notification from "./notificationGlobal";
export default function ProfileUser({ permissions }: { permissions: JsonValue[] }) {
const [opened, setOpened] = useState(false);
const [openedPassword, setOpenedPassword] = useState(false);
const [pwdBaru, setPwdBaru] = useState("");
const [host, setHost] = useState({
id: "",
name: "",
phone: "",
roleId: "",
email: "",
});
const [error, setError] = useState({
name: false,
email: false,
phone: false,
});
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost({
id: data?.user?.id ?? "",
name: data?.user?.name ?? "",
phone: data?.user?.phone ?? "",
roleId: data?.user?.roleId ?? "",
email: data?.user?.email ?? "",
});
}
fetchHost();
}, []);
function onValidation({
kat,
value,
}: {
kat: "name" | "email" | "phone";
value: string;
}) {
if (value.length < 1) {
setError({ ...error, [kat]: true });
} else {
setError({ ...error, [kat]: false });
}
setHost({ ...host, [kat]: value });
}
async function handleUpdate() {
try {
const res = await apiFetch.api.user.update.post(host);
if (res.status === 200) {
setOpened(false);
notification({
title: "Success",
message: "Your profile have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to update profile",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to update profile",
type: "error",
});
}
}
async function handleUpdatePassword() {
try {
const res = await apiFetch.api.user["update-password"].post({
password: pwdBaru,
id: host.id,
});
if (res.status === 200) {
setPwdBaru("");
setOpenedPassword(false);
notification({
title: "Success",
message: "Your password have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to update password",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to update password",
type: "error",
});
}
}
return (
<>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Profile Pengguna
</Title>
<Group gap="md">
{
permissions.includes("setting.profile.edit") && (
<Button variant="light" onClick={() => setOpened(true)}>
Edit
</Button>
)
}
{
permissions.includes("setting.profile.password") && (
<Button variant="light" onClick={() => setOpenedPassword(true)}>
Ubah Password
</Button>
)
}
</Group>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Group gap="xl" grow>
<Input.Wrapper label="Nama" description="" error="">
<Input value={host?.name ?? ""} readOnly />
</Input.Wrapper>
<Input.Wrapper label="Phone" description="" error="">
<Input value={host?.phone ?? ""} readOnly />
</Input.Wrapper>
</Group>
<Group gap="xl" grow>
<Input.Wrapper label="Email" description="" error="">
<Input value={host?.email ?? ""} readOnly />
</Input.Wrapper>
<Input.Wrapper label="Role" description="" error="">
<Input value={host?.roleId ?? ""} readOnly />
</Input.Wrapper>
</Group>
</Stack>
</Stack>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={"Edit Profile"}
size={"lg"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap={"md"}>
<Input.Wrapper
label="Nama"
description=""
error={error.name ? "Field is required" : ""}
>
<Input
value={host?.name ?? ""}
onChange={(e) =>
onValidation({ kat: "name", value: e.target.value })
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Phone"
description=""
error={error.phone ? "Field is required" : ""}
>
<Input
value={host?.phone ?? ""}
onChange={(e) =>
onValidation({ kat: "phone", value: e.target.value })
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Email"
description=""
error={error.email ? "Field is required" : ""}
>
<Input
value={host?.email ?? ""}
onChange={(e) =>
onValidation({ kat: "email", value: e.target.value })
}
/>
</Input.Wrapper>
<Group grow>
<Button variant="light" onClick={() => setOpened(false)}>
Batal
</Button>
<Button
variant="filled"
onClick={() => handleUpdate()}
disabled={error.name || error.phone || error.email}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={openedPassword}
onClose={() => setOpenedPassword(false)}
title={"Ubah Password"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap={"md"}>
<Input.Wrapper label="Password Baru" description="">
<Input
value={pwdBaru}
onChange={(e) => setPwdBaru(e.target.value)}
/>
</Input.Wrapper>
<Group grow>
<Button variant="light" onClick={() => setOpenedPassword(false)}>
Batal
</Button>
<Button
variant="filled"
onClick={() => handleUpdatePassword()}
disabled={pwdBaru.length < 1}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@@ -0,0 +1,427 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Stack,
Table,
Text,
Title,
Tooltip
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import listMenu from "../lib/listPermission.json";
import notification from "./notificationGlobal";
import PermissionRole from "./PermissionRole";
import PermissionTree from "./PermissionTree";
interface MenuNode {
key: string;
label: string;
default: boolean;
children?: MenuNode[];
}
export default function UserRoleSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const {
data: dataRole,
mutate: mutateRole,
isLoading: isLoadingRole,
} = useSWR("user-role", () => apiFetch.api.user.role.get());
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const { data, mutate, isLoading } = useSWR("role-list", () =>
apiFetch.api.user.role.get(),
);
const list = data?.data || [];
const listRole = dataRole?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
name: "",
permissions: [],
});
const [dataTambah, setDataTambah] = useState({
name: "",
permissions: [],
});
const [error, setError] = useState({
name: false,
permissions: false,
});
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user["role-create"].post(dataTambah as any);
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah({
name: "",
permissions: [],
});
notification({
title: "Success",
message: "Your role have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create role",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create role",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user["role-update"].post(dataEdit as any);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your role have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit role",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit role",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user["role-delete"].post({ id: dataDelete });
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your role have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete role",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete role",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({ data }: { data: { id: string; name: string; permissions: []; }; }) {
setDataEdit({
id: data.id, name: data.name, permissions: data.permissions ? data.permissions : []
});
open();
}
function onValidation({ kat, value, aksi, }: { kat: "name" | "permission"; value: string | null; aksi: "edit" | "tambah"; }) {
if (value == null || value.length < 1) {
setBtnDisable(true);
setError({ ...error, [kat]: true });
} else {
setBtnDisable(false);
setError({ ...error, [kat]: false });
}
if (aksi === "edit") {
setDataEdit({ ...dataEdit, [kat]: value });
} else {
setDataTambah({ ...dataTambah, [kat]: value });
}
}
function buildOrderList(menus: MenuNode[]): string[] {
const list: string[] = [];
const traverse = (nodes: MenuNode[]) => {
nodes.forEach((node) => {
list.push(node.key);
if (node.children) traverse(node.children);
});
};
traverse(menus);
return list;
}
function sortByJsonOrder(arrayData: string[]): string[] {
const orderList = buildOrderList(listMenu.menus);
return arrayData.sort((a, b) => {
return orderList.indexOf(a) - orderList.indexOf(b);
});
}
useShallowEffect(() => {
if (dataEdit.name.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size={"lg"}
>
<Stack gap="ld">
<Input.Wrapper label="Nama Role">
<Input
value={dataEdit.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<PermissionTree
selected={dataEdit.permissions}
onChange={(permissions) => {
setDataEdit({ ...dataEdit, permissions: sortByJsonOrder(permissions) as never[] });
}}
/>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={
btnDisable ||
dataEdit.name.length < 1 ||
dataEdit.permissions?.length < 1
}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size={"lg"}
>
<Stack gap="ld">
<Input.Wrapper
label="Nama Role"
description=""
error={error.name ? "Field is required" : ""}
>
<Input
value={dataTambah.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<PermissionTree
selected={dataTambah.permissions}
onChange={(permissions) => {
setDataTambah({ ...dataTambah, permissions: sortByJsonOrder(permissions) as never[] });
}}
/>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
disabled={
btnDisable ||
dataTambah.name.length < 1 ||
dataTambah.permissions.length < 1
}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus role ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Daftar Role
</Title>
{
permissions.includes('setting.user_role.tambah') && (
<Tooltip label="Tambah Role">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Role</Table.Th>
<Table.Th>Permission</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list.length > 0 ? (
list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td w={"150"}>{v.name}</Table.Td>
<Table.Td>
<PermissionRole permissions={v.permissions} />
</Table.Td>
<Table.Td w={"100"}>
<Group>
<Tooltip label={permissions.includes('setting.user_role.edit') ? "Edit Role" : "Edit Role - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes('setting.user_role.edit') || v.id == "developer"}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label={permissions.includes('setting.user_role.delete') ? "Delete Role" : "Delete Role - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes('setting.user_role.delete') || v.id == "developer"}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={5} align="center">
Data Role Tidak Ditemukan
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,520 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Select,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function UserSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const {
data: dataRole,
mutate: mutateRole,
isLoading: isLoadingRole,
} = useSWR("user-role", () => apiFetch.api.user.role.get());
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const { data, mutate, isLoading } = useSWR("user-list", () =>
apiFetch.api.user.list.get(),
);
const list = data?.data || [];
const listRole = dataRole?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
name: "",
phone: "",
email: "",
roleId: "",
});
const [dataTambah, setDataTambah] = useState({
name: "",
email: "",
roleId: "",
password: "",
phone: "",
});
const [error, setError] = useState({
name: false,
email: false,
roleId: false,
password: false,
phone: false,
});
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user.create.post(dataTambah);
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah({
name: "",
email: "",
roleId: "",
password: "",
phone: "",
});
notification({
title: "Success",
message: "Your user have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create user ",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create user",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user.update.post(dataEdit);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your data have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit user",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit user2",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user.delete.post({ id: dataDelete });
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your user have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete user",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete user",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({
data,
}: {
data: {
id: string;
name: string;
phone: string;
email: string;
roleId: string;
};
}) {
setDataEdit(data);
open();
}
function onValidation({
kat,
value,
aksi,
}: {
kat: "name" | "email" | "roleId" | "password" | "phone";
value: string | null;
aksi: "edit" | "tambah";
}) {
if (value == null || value.length < 1) {
setBtnDisable(true);
setError({ ...error, [kat]: true });
} else {
setBtnDisable(false);
setError({ ...error, [kat]: false });
}
if (aksi === "edit") {
setDataEdit({ ...dataEdit, [kat]: value });
} else {
setDataTambah({ ...dataTambah, [kat]: value });
}
}
useShallowEffect(() => {
if (dataEdit.name.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label="Nama">
<Input
value={dataEdit.name}
error={error.name ? "Field is required" : ""}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Select
label="Role"
placeholder="Pilih Role"
data={listRole.map((r: any) => ({
value: r.id,
label: r.name,
}))}
value={dataEdit.roleId || null}
error={error.roleId ? "Field is required" : ""}
onChange={(_value, option) => {
onValidation({
kat: "roleId",
value: option?.value,
aksi: "edit",
});
}}
/>
<Input.Wrapper label="Phone" description="">
<Input
value={dataEdit.phone}
onChange={(e) =>
onValidation({
kat: "phone",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Email"
description=""
error={error.email ? "Field is required" : ""}
>
<Input
value={dataEdit.email}
onChange={(e) =>
onValidation({
kat: "email",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={btnDisable}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper
label="Nama"
description=""
error={error.name ? "Field is required" : ""}
>
<Input
value={dataTambah.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Select
label="Role"
placeholder="Pilih Role"
data={listRole.map((r: any) => ({
value: r.id,
label: r.name,
}))}
value={dataTambah.roleId || null}
error={error.roleId ? "Field is required" : ""}
onChange={(_value, option) => {
onValidation({
kat: "roleId",
value: option?.value,
aksi: "tambah",
});
}}
/>
<Input.Wrapper label="Phone" description="">
<Input
value={dataTambah.phone}
onChange={(e) =>
onValidation({
kat: "phone",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Email"
description=""
error={error.email ? "Field is required" : ""}
>
<Input
value={dataTambah.email}
onChange={(e) =>
onValidation({
kat: "email",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Password"
description=""
error={error.password ? "Field is required" : ""}
>
<Input
value={dataTambah.password}
onChange={(e) =>
onValidation({
kat: "password",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
disabled={
btnDisable ||
dataTambah.name.length < 1 ||
dataTambah.email.length < 1 ||
dataTambah.password.length < 1 ||
dataTambah.roleId.length < 1 ||
dataTambah.phone.length < 1
}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus user ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Daftar User
</Title>
{
permissions.includes('setting.user.tambah') && (
<Tooltip label="Tambah User">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>Telepon</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list && Array.isArray(list) && list.length > 0 ? (
list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>{v.phone}</Table.Td>
<Table.Td>{v.email}</Table.Td>
<Table.Td>{v.nameRole}</Table.Td>
<Table.Td>
<Group>
<Tooltip label={permissions.includes('setting.user.edit') ? "Edit User" : "Edit User - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes('setting.user.edit') || v.roleId == "developer"}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label={permissions.includes('setting.user.delete') ? "Delete User" : "Delete User - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes('setting.user.delete') || v.roleId == "developer"}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={5} align="center">
Data User Tidak Ditemukan
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,38 @@
import { showNotification } from "@mantine/notifications";
export default function notification({ title, message, type }: { title: string, message: string, type: "success" | "error" | "warning" | "info" }) {
switch (type) {
case "success":
return showNotification({
title,
message,
color: "green",
autoClose: 3000,
})
break;
case "error":
return showNotification({
title,
message,
color: "red",
autoClose: 3000,
})
break;
case "warning":
return showNotification({
title,
message,
color: "orange",
autoClose: 3000,
})
break;
case "info":
return showNotification({
title,
message,
color: "blue",
autoClose: 3000,
})
break;
}
}

View File

@@ -0,0 +1,159 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKBedaBiodataDiri({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>();
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.25" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "15px" }}>
<b>PEMERINTAH KABUPATEN {_.upperCase(data.setting.desaKabupaten)}</b><br />
<b>KECAMATAN {_.upperCase(data.setting.desaKecamatan)}</b><br />
<b>DESA / KELURAHAN {_.upperCase(data.setting.desaNama)}</b><br />
Alamat: {data.setting.desaAlamat}<br />
Kode Pos: {data.setting.desaPos}
</div>
{/* JUDUL */}
<div style={{ textAlign: "center" }}>
<b><u>SURAT KETERANGAN BEDA BIODATA DIRI</u></b><br />
Nomor: {data.surat.noSurat}
</div>
{/* YANG BERTANDA TANGAN */}
<div style={{ marginTop: "15px" }}>
Yang bertanda tangan di bawah ini:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr>
<td style={{ width: "160px" }}>Nama</td>
<td style={{ width: "10px" }}>:</td>
<td>{data.setting.perbekelNama}</td>
</tr>
<tr>
<td>Jabatan</td>
<td>:</td>
<td>{data.setting.perbekelJabatan + " " + data.setting.desaNama}</td>
</tr>
<tr>
<td>Kecamatan</td>
<td>:</td>
<td>{data.setting.desaKecamatan}</td>
</tr>
<tr>
<td>Kabupaten</td>
<td>:</td>
<td>{data.setting.desaKabupaten}</td>
</tr>
</tbody>
</table>
</div>
{/* IDENTITAS ORANG YG MEMINTA SURAT */}
<div style={{ marginTop: "15px" }}>
Dengan ini menerangkan bahwa berdasarkan keterangan dari yang bersangkutan:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Nama</td><td style={{ width: "10px" }}>:</td><td>{getValue("nama")}</td></tr>
<tr><td>Tempat/Tanggal Lahir</td><td>:</td><td>{getValue("tempat tanggal lahir")}</td></tr>
<tr><td>Jenis Kelamin</td><td>:</td><td>{getValue("jenis kelamin")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat")}</td></tr>
<tr><td>Pekerjaan</td><td>:</td><td>{getValue("pekerjaan")}</td></tr>
<tr><td>NIK</td><td>:</td><td>{getValue("nik")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "15px" }}>
Bahwa orang tersebut di atas <b>benar merupakan orang yang sama</b>, meskipun terdapat <b>perbedaan data pribadi (biodata)</b> pada beberapa dokumen, sebagai berikut:
</div>
<div style={{ marginTop: "15px" }}>
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>1. Nama</td><td style={{ width: "10px" }}>:</td><td>{getValue("nama")}</td></tr>
<tr><td>Tertulis pada dokumen A</td><td>:</td><td>{getValue("tertulis pada dokumen a")}</td></tr>
<tr><td>Tertulis pada dokumen B</td><td>:</td><td>{getValue("tertulis pada dokumen b")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "15px" }}>
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>2. Tempat/Tanggal Lahir</td><td style={{ width: "10px" }}>:</td><td>{getValue("tempat tanggal lahir")}</td></tr>
<tr><td>Tertulis pada dokumen A</td><td>:</td><td>{getValue("tertulis pada dokumen a")}</td></tr>
<tr><td>Tertulis pada dokumen B</td><td>:</td><td>{getValue("tertulis pada dokumen b")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "15px" }}>
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>3. Nama Orang Tua</td><td style={{ width: "10px" }}>:</td><td>{getValue("nama orang tua")}</td></tr>
<tr><td>Tertulis pada dokumen A</td><td>:</td><td>{getValue("tertulis pada dokumen a")}</td></tr>
<tr><td>Tertulis pada dokumen B</td><td>:</td><td>{getValue("tertulis pada dokumen b")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "15px" }}>
Perbedaan tersebut terjadi karena <b>kesalahan penulisan/pencatatan administratif</b>, namun yang bersangkutan adalah <b>orang yang sama</b>.
<br />
Dengan surat keterangan ini dibuat dengan sebenar-benarnya untuk dipergunakan sebagaimana mestinya.
</div>
<div style={{ marginTop: "15px" }}>
Dikeluarkan di {data.setting.desaNama} <br />
Pada tanggal {data.surat.createdAt}
</div>
{/* TANDA TANGAN */}
<div style={{ marginTop: "0px", display: "flex", justifyContent: "flex-end", width: "100%" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKBelumKawin({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>();
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "10px" }}>
<b>PEMERINTAH KABUPATEN {_.upperCase(data.setting.desaKabupaten)}</b><br />
<b>KECAMATAN {_.upperCase(data.setting.desaKecamatan)}</b><br />
<b>DESA / KELURAHAN {_.upperCase(data.setting.desaNama)}</b><br />
Alamat: {data.setting.desaAlamat}<br />
Kode Pos: {data.setting.desaPos}
</div>
{/* JUDUL */}
<div style={{ textAlign: "center", margin: "20px 0" }}>
<b><u>SURAT KETERANGAN BELUM KAWIN</u></b><br />
Nomor: {data.surat.noSurat}
</div>
{/* YANG BERTANDA TANGAN */}
<div style={{ marginTop: "15px" }}>
Yang bertanda tangan di bawah ini {data.setting.perbekelJabatan} {data.setting.desaNama}, Kecamatan {data.setting.desaKecamatan}, Kabupaten {data.setting.desaKabupaten}, dengan ini menerangkan bahwa:
</div>
{/* IDENTITAS ORANG YG MEMINTA SURAT */}
<div style={{ marginTop: "20px" }}>
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Nama</td><td style={{ width: "10px" }}>:</td><td>{getValue("nama")}</td></tr>
<tr><td>NIK</td><td>:</td><td>{getValue("nik")}</td></tr>
<tr><td>Tempat/Tanggal Lahir</td><td>:</td><td>{getValue("tempat tanggal lahir")}</td></tr>
<tr><td>Jenis Kelamin</td><td>:</td><td>{getValue("jenis kelamin")}</td></tr>
<tr><td>Agama</td><td>:</td><td>{getValue("agama")}</td></tr>
<tr><td>Pekerjaan</td><td>:</td><td>{getValue("pekerjaan")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "20px" }}>
Berdasarkan keterangan dari yang bersangkutan dan data administrasi kependudukan yang ada di Desa {data.setting.desaNama},
yang bersangkutan benar sampai saat ini belum pernah menikah, baik secara adat, agama, maupun hukum negara.
</div>
<div style={{ marginTop: "20px" }}>
Demikian surat keterangan ini dibuat dengan sebenarnya agar dapat digunakan sebagaimana mestinya.
</div>
<div style={{ marginTop: "20px" }}>
Dikeluarkan di {data.setting.desaNama} <br />
Pada tanggal {data.surat.createdAt}
</div>
{/* TANDA TANGAN */}
<div style={{ marginTop: "40px", display: "flex", justifyContent: "space-between", width: "100%" }}>
<div style={{ textAlign: "center" }}>
<br /><br />
Pemohon
<br /><br /><br /><br /><br /><br />
<u>{getValue("nama")}</u> <br />
</div>
<div style={{ textAlign: "center" }}>
<br /><br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKDomisiliOrganisasi({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "10px" }}>
<b>PEMERINTAH KABUPATEN {_.upperCase(data.setting.desaKabupaten)}</b><br />
<b>KECAMATAN {_.upperCase(data.setting.desaKecamatan)}</b><br />
<b>DESA / KELURAHAN {_.upperCase(data.setting.desaNama)}</b><br />
Alamat: {data.setting.desaAlamat}<br />
Kode Pos: {data.setting.desaPos}
</div>
{/* JUDUL */}
<div style={{ textAlign: "center", margin: "20px 0" }}>
<b><u>SURAT KETERANGAN DOMISILI ORGANISASI</u></b><br />
Nomor: {data.surat.noSurat}
</div>
{/* YANG BERTANDA TANGAN */}
<div style={{ marginTop: "15px" }}>
Yang bertanda tangan di bawah ini:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr>
<td style={{ width: "160px" }}>Nama</td>
<td style={{ width: "10px" }}>:</td>
<td>{data.setting.perbekelNama}</td>
</tr>
<tr>
<td>Jabatan</td>
<td>:</td>
<td>{data.setting.perbekelJabatan}</td>
</tr>
<tr>
<td>Alamat Kantor</td>
<td>:</td>
<td>{data.setting.desaAlamat}</td>
</tr>
</tbody>
</table>
</div>
{/* IDENTITAS ORANG YG MEMINTA SURAT */}
<div style={{ marginTop: "20px" }}>
Dengan ini menerangkan bahwa:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Nama Organisasi</td><td style={{ width: "10px" }}>:</td><td>{getValue("nama")}</td></tr>
<tr><td>Jenis Organisasi</td><td>:</td><td>{getValue("jenis kelamin")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("tempat tanggal lahir")}</td></tr>
<tr><td>Nomor Telepon</td><td>:</td><td>{getValue("negara")}</td></tr>
<tr><td>Nama Pimpinan</td><td>:</td><td>{getValue("agama")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "20px" }}>
Benar bahwa organisasi tersebut berdomisili di wilayah Desa / Kelurahan {data.setting.desaNama}, Kecamatan {data.setting.desaKecamatan}, Kabupaten {data.setting.desaKabupaten}.
Dan sampai saat ini masih aktif melakukan kegiatan sesuai dengan bidangnya.<br />
Surat keterangan ini dibuat untuk keperluan {getValue("keperluan")}.
</div>
<div style={{ marginTop: "20px" }}>
Demikian surat keterangan ini dibuat dengan sebenarnya agar dapat digunakan sebagaimana mestinya.
</div>
<div style={{ marginTop: "20px" }}>
Dikeluarkan di {data.setting.desaNama} <br />
Pada tanggal {data.surat.createdAt}
</div>
{/* TANDA TANGAN */}
<div style={{ marginTop: "40px", display: "flex", justifyContent: "flex-end", width: "100%" }}>
<div style={{ textAlign: "center" }}>
<br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKKelahiran({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.2" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "20px" }}>
<b>PEMERINTAH KABUPATEN/KOTA {_.upperCase(data.setting.desaKabupaten)}</b><br />
<b>KECAMATAN {_.upperCase(data.setting.desaKecamatan)}</b><br />
<b>DESA / KELURAHAN {_.upperCase(data.setting.desaNama)}</b><br />
Alamat: {data.setting.desaAlamat}
</div>
{/* JUDUL */}
<div style={{ textAlign: "center", margin: "20px 0" }}>
<b><u>SURAT KETERANGAN KELAHIRAN</u></b><br />
Nomor : {data.surat.noSurat}
</div>
{/* PEMBUKA */}
<div>
Yang bertanda tangan di bawah ini, {data.setting.perbekelJabatan}
{` ${data.setting.desaNama}, Kecamatan ${data.setting.desaKecamatan}, Kabupaten/Kota ${data.setting.desaKabupaten}`}
, dengan ini menerangkan bahwa:
</div>
{/* DATA KELAHIRAN ANAK */}
<div style={{ marginTop: "20px" }}>
Telah lahir seorang anak pada:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "200px" }}>Tanggal Lahir</td><td>:</td><td>{getValue("tanggal lahir anak")}</td></tr>
<tr><td>Pukul</td><td>:</td><td>{getValue("pukul lahir anak")}</td></tr>
<tr><td>Tempat Kelahiran</td><td>:</td><td>{getValue("tempat lahir anak")}</td></tr>
<tr><td>Jenis Kelamin</td><td>:</td><td>{getValue("jenis kelamin anak")}</td></tr>
<tr><td>Anak ke</td><td>:</td><td>{getValue("anak ke")}</td></tr>
<tr><td>Nama Anak</td><td>:</td><td>{getValue("nama anak")}</td></tr>
</tbody>
</table>
</div>
{/* DATA IBU */}
<div style={{ marginTop: "20px" }}>
Dari seorang ibu bernama:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "200px" }}>Nama Lengkap Ibu</td><td>:</td><td>{getValue("nama ibu")}</td></tr>
<tr><td>NIK</td><td>:</td><td>{getValue("nik ibu")}</td></tr>
<tr><td>Tempat & Tanggal Lahir</td><td>:</td><td>{getValue("tempat tanggal lahir ibu")}</td></tr>
<tr><td>Pekerjaan</td><td>:</td><td>{getValue("pekerjaan ibu")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat ibu")}</td></tr>
</tbody>
</table>
</div>
{/* DATA AYAH */}
<div style={{ marginTop: "20px" }}>
Dan seorang ayah bernama:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "200px" }}>Nama Lengkap Ayah</td><td>:</td><td>{getValue("nama ayah")}</td></tr>
<tr><td>NIK</td><td>:</td><td>{getValue("nik ayah")}</td></tr>
<tr><td>Tempat & Tanggal Lahir</td><td>:</td><td>{getValue("tempat tanggal lahir ayah")}</td></tr>
<tr><td>Pekerjaan</td><td>:</td><td>{getValue("pekerjaan ayah")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat ayah")}</td></tr>
</tbody>
</table>
</div>
{/* DATA PELAPOR */}
<div style={{ marginTop: "20px" }}>
Berdasarkan laporan dari:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "200px" }}>Nama Pelapor</td><td>:</td><td>{getValue("nama pelapor")}</td></tr>
<tr><td>Hubungan dengan Anak</td><td>:</td><td>{getValue("hubungan pelapor")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat pelapor")}</td></tr>
</tbody>
</table>
</div>
{/* PENUTUP */}
<div style={{ marginTop: "20px", textAlign: "justify" }}>
Demikian Surat Keterangan Kelahiran ini dibuat dengan sebenarnya agar dapat digunakan sebagaimana mestinya.
</div>
{/* TEMPAT TANGGAL */}
<table style={{ width: "100%", marginTop: "20px" }}>
<tbody>
<tr><td style={{ width: "200px" }}>Dikeluarkan di</td><td>:</td><td>{data.setting.desaNama}</td></tr>
<tr><td>Pada tanggal</td><td>:</td><td>{data.surat.createdAt}</td></tr>
</tbody>
</table>
{/* TANDA TANGAN */}
<div style={{ marginTop: "40px", width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa / Lurah {data.setting.desaNama}
<br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKKelakuanBaik({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "30px" }}>
<b style={{ fontSize: "18px" }}>SURAT KETERANGAN KELAKUAN BAIK</b><br />
(PENGANTAR SKCK)<br />
Nomor: {data.surat.noSurat}
</div>
{/* PEMBUKA */}
<div style={{ marginBottom: "15px" }}>
Yang bertanda tangan di bawah ini menerangkan dengan sebenarnya bahwa:
</div>
{/* IDENTITAS PENDUDUK */}
<table style={{ width: "100%", marginBottom: "15px" }}>
<tbody>
<tr>
<td style={{ width: "180px" }}>Nama lengkap</td>
<td style={{ width: "10px" }}>:</td>
<td>{getValue("nama")}</td>
</tr>
<tr>
<td>NIK</td>
<td>:</td>
<td>{getValue("nik")}</td>
</tr>
<tr>
<td>Tempat/Tgl Lahir</td>
<td>:</td>
<td>{getValue("tempat tanggal lahir")}</td>
</tr>
<tr>
<td>Jenis Kelamin</td>
<td>:</td>
<td>{getValue("jenis kelamin")}</td>
</tr>
<tr>
<td>Agama</td>
<td>:</td>
<td>{getValue("agama")}</td>
</tr>
<tr>
<td>Pekerjaan</td>
<td>:</td>
<td>{getValue("pekerjaan")}</td>
</tr>
<tr>
<td>Alamat</td>
<td>:</td>
<td>{getValue("alamat")}</td>
</tr>
</tbody>
</table>
{/* ISI */}
<div style={{ textAlign: "justify", marginBottom: "15px" }}>
Adalah benar penduduk yang berdomisili di wilayah kami dan selama tinggal di lingkungan
Desa {data.setting.desaNama}, berkelakuan baik, tidak pernah terlibat perbuatan melanggar hukum,
serta dikenal sopan dan aktif dalam kegiatan kemasyarakatan.
</div>
<div style={{ textAlign: "justify", marginBottom: "15px" }}>
Surat keterangan ini diberikan sebagai pengantar permohonan penerbitan Surat Keterangan
Catatan Kepolisian (SKCK) ke Polsek/Polres {getValue("polsek")}.
</div>
<div style={{ textAlign: "justify", marginBottom: "15px" }}>
Surat ini berlaku selama 6 (enam) bulan sejak tanggal diterbitkan, kecuali terdapat perubahan
data yang mendasar.
</div>
<div style={{ textAlign: "justify", marginBottom: "20px" }}>
Demikian surat keterangan ini dibuat dengan sebenarnya untuk dipergunakan sebagaimana mestinya.
</div>
{/* TANGGAL */}
<table style={{ width: "100%", marginBottom: "40px" }}>
<tbody>
<tr>
<td style={{ width: "180px" }}>Dikeluarkan di</td>
<td style={{ width: "10px" }}>:</td>
<td>{data.setting.desaNama}</td>
</tr>
<tr>
<td>Pada tanggal</td>
<td>:</td>
<td>{data.surat.createdAt}</td>
</tr>
</tbody>
</table>
{/* TANDA TANGAN */}
<div style={{ width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa {data.setting.desaNama}
<br /> <br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u><br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKKematian({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "10px" }}>
<b>PEMERINTAH KABUPATEN {_.upperCase(data.setting.desaKabupaten)}</b><br />
<b>KECAMATAN {_.upperCase(data.setting.desaKecamatan)}</b><br />
<b>DESA / KELURAHAN {_.upperCase(data.setting.desaNama)}</b><br />
Alamat: {data.setting.desaAlamat}<br />
Kode Pos: {data.setting.desaPos}
</div>
{/* JUDUL */}
<div style={{ textAlign: "center", margin: "20px 0" }}>
<b><u>SURAT KETERANGAN KEMATIAN</u></b><br />
Nomor: {data.surat.noSurat}
</div>
{/* YANG BERTANDA TANGAN */}
<div style={{ marginTop: "15px" }}>
Yang bertanda tangan di bawah ini:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Nama</td><td style={{ width: "10px" }}>:</td><td>{getValue("nama")}</td></tr>
<tr><td>NIK</td><td>:</td><td>{getValue("nik")}</td></tr>
<tr><td>Pekerjaan</td><td>:</td><td>{getValue("pekerjaan")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat")}</td></tr>
<tr><td>Hubungan dengan almarhum/almarhumah</td><td>:</td><td>{getValue("hubungan dengan almarhum")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "20px" }}>
Melaporkan bahwa:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Nama</td><td style={{ width: "10px" }}>:</td><td>{getValue("nama")}</td></tr>
<tr><td>NIK</td><td>:</td><td>{getValue("nik")}</td></tr>
<tr><td>Jenis Kelamin</td><td>:</td><td>{getValue("jenis kelamin")}</td></tr>
<tr><td>Tempat/Tanggal Lahir</td><td>:</td><td>{getValue("tempat tanggal lahir")}</td></tr>
<tr><td>Agama</td><td>:</td><td>{getValue("agama")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "20px" }}>
Telah meninggal dunia pada:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Tanggal Kematian</td><td style={{ width: "10px" }}>:</td><td>{getValue("tanggal kematian")}</td></tr>
<tr><td>Waktu Kematian</td><td>:</td><td>{getValue("waktu kematian")}</td></tr>
<tr><td>Tempat Kematian</td><td>:</td><td>{getValue("tempat kematian")}</td></tr>
<tr><td>Penyebab Kematian</td><td>:</td><td>{getValue("penyebab kematian")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "20px" }}>
Demikian surat keterangan ini dibuat dengan sebenarnya agar dapat digunakan sebagaimana mestinya.
</div>
{/* TANDA TANGAN */}
<div style={{ marginTop: "40px", display: "flex", justifyContent: "space-between", width: "100%" }}>
<div style={{ textAlign: "center" }}>
<br /><br />
Pemohon
<br /><br /><br /><br /> <br />
<u>{getValue("nama")}</u> <br />
</div>
<div style={{ textAlign: "center" }}>
<br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /><br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKPenghasilan({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "10px" }}>
<b>PEMERINTAH KABUPATEN {_.upperCase(data.setting.desaKabupaten)}</b><br />
<b>KECAMATAN {_.upperCase(data.setting.desaKecamatan)}</b><br />
<b>DESA / KELURAHAN {_.upperCase(data.setting.desaNama)}</b><br />
Alamat: {data.setting.desaAlamat}<br />
Kode Pos: {data.setting.desaPos}
</div>
{/* JUDUL */}
<div style={{ textAlign: "center", margin: "20px 0" }}>
<b><u>SURAT KETERANGAN PENGHASILAN</u></b><br />
Nomor: {data.surat.noSurat}
</div>
{/* YANG BERTANDA TANGAN */}
<div style={{ marginTop: "15px" }}>
Yang bertanda tangan di bawah ini:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr>
<td style={{ width: "160px" }}>Nama</td>
<td style={{ width: "10px" }}>:</td>
<td>{data.setting.perbekelNama}</td>
</tr>
<tr>
<td>Jabatan</td>
<td>:</td>
<td>{data.setting.perbekelJabatan}</td>
</tr>
<tr>
<td>Kecamatan</td>
<td>:</td>
<td>{data.setting.desaKecamatan}</td>
</tr>
<tr>
<td>Kabupaten</td>
<td>:</td>
<td>{data.setting.desaKabupaten}</td>
</tr>
</tbody>
</table>
</div>
{/* IDENTITAS */}
<div style={{ marginTop: "20px" }}>
Dengan ini menerangkan bahwa:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Nama</td><td>:</td><td>{getValue("nama")}</td></tr>
<tr><td>Jenis Kelamin</td><td>:</td><td>{getValue("jenis kelamin")}</td></tr>
<tr><td>Tempat / Tanggal Lahir</td><td>:</td><td>{getValue("tempat tanggal lahir")}</td></tr>
<tr><td>Pekerjaan</td><td>:</td><td>{getValue("pekerjaan")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat")}</td></tr>
</tbody>
</table>
</div>
{/* PENGHASILAN */}
<div style={{ marginTop: "20px" }}>
Berdasarkan keterangan yang bersangkutan, orang tersebut memiliki penghasilan rata-rata:
<table style={{ width: "100%", marginTop: "10px" }}>
<tbody>
<tr>
<td style={{ width: "160px" }}>Penghasilan</td>
<td style={{ width: "10px" }}>:</td>
<td>
Rp {getValue("penghasilan")}
{" "}
({getValue("penghasilan terbilang")}) per bulan
</td>
</tr>
</tbody>
</table>
</div>
{/* KEPERLUAN */}
<div style={{ marginTop: "20px" }}>
Surat keterangan ini dibuat untuk keperluan: <b>{getValue("alasan permohonan")}</b>.
</div>
<div style={{ marginTop: "20px" }}>
Demikian surat keterangan ini dibuat dengan sebenarnya untuk dapat dipergunakan sebagaimana mestinya.
</div>
{/* TANGGAL & TANDA TANGAN */}
<div style={{ marginTop: "20px" }}>
Dikeluarkan di {data.setting.desaNama} <br />
Pada tanggal {data.surat.createdAt}
</div>
<div style={{ marginTop: "40px", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa / Lurah {data.setting.desaNama}
<br /> <br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /><br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKTempatUsaha({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (key: string) =>
_.upperFirst(data.surat.dataText.find((i: any) => i.jenis === key)?.value || "");
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.5" }}>
{/* TITLE */}
<div style={{ textAlign: "center", marginBottom: "20px" }}>
<b style={{ fontSize: "16px" }}>SURAT KETERANGAN TEMPAT USAHA</b><br />
Nomor: {data.surat.noSurat}
</div>
{/* ISI */}
<div>
<div style={{ marginBottom: "10px" }}>
Yang bertanda tangan dibawah ini, saya:
</div>
{/* DATA PEJABAT */}
<div>
<Row label="Nama" value={data.setting.perbekelNama} />
<Row label="Jabatan" value={data.setting.perbekelJabatan} />
<Row label="Alamat" value={data.setting.desaAlamat} />
</div>
<br />
<div>Dengan ini menerangkan bahwa:</div>
{/* DATA WARGA */}
<div>
<Row label="Nama Pemilik Usaha" value={getValue("nama")} />
<Row label="Tempat/Tanggal Lahir" value={getValue("tempat tanggal lahir")} />
<Row label="Alamat Pemilik Usaha" value={getValue("alamat")} />
<Row label="Nomor KTP" value={getValue("nik")} />
</div>
<br />
<div>Benar yang bersangkutan memiliki tempat usaha dengan keterangan seperti berikut:</div>
<div>
<Row label="Nama Usaha" value={getValue("nama usaha")} />
<Row label="Bidang Usaha" value={getValue("bidang usaha")} />
<Row label="Alamat Usaha" value={getValue("alamat usaha")} />
<Row label="Status Tempat Usaha" value={getValue("status tempat usaha")} />
<Row label="Luas Tempat Usaha" value={getValue("luas tempat usaha")} />
<Row label="Jumlah Karyawan" value={getValue("jumlah karyawan")} />
</div>
<p style={{ textAlign: "justify" }}>
Surat keterangan ini dibuat untuk keperluan <b>{getValue("alasan permohonan")}.</b>
</p>
<p style={{ textAlign: "justify" }}>
Demikian surat keterangan ini dibuat dengan sebenarnya untuk dapat dipergunakan sebagaimana mestinya.
</p>
<div>
<Row label="Dikeluarkan di" value={data.setting.desaNama} />
<Row label="Pada tanggal" value={data.surat.createdAt} />
</div>
<br /><br />
{/* TANDA TANGAN */}
<div style={{ width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
{data.setting.desaKabupaten}, {data.surat.createdAt} <br /> <br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /><br />
<u>{data.setting.perbekelNama}</u><br />
{data.setting.perbekelJabatan + " " + data.setting.desaNama}
</div>
</div>
</div>
</div>
);
}
function Row({ label, value }: { label: string, value: string }) {
return (
<div style={{ display: "flex", marginBottom: "4px" }}>
<div style={{ width: "180px" }}>{label}</div>
<div style={{ width: "10px" }}>:</div>
<div>{value}</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKTidakMampu({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (key: string) =>
_.upperFirst(data.surat.dataText.find((i: any) => i.jenis === key)?.value || "");
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.5" }}>
{/* TITLE */}
<div style={{ textAlign: "center", marginBottom: "20px" }}>
<b style={{ fontSize: "16px" }}>SURAT KETERANGAN TIDAK MAMPU</b><br />
Nomor: {data.surat.noSurat}
</div>
{/* ISI */}
<div>
<div style={{ marginBottom: "10px" }}>
Yang bertanda tangan dibawah ini, saya
</div>
{/* DATA PEJABAT */}
<div>
<Row label="Nama" value={data.setting.perbekelNama} />
<Row label="Alamat" value={data.setting.desaAlamat} />
<Row label="Jabatan" value={data.setting.perbekelJabatan} />
</div>
<br />
<div>Dengan ini menerangkan bahwa:</div>
{/* DATA WARGA */}
<div>
<Row label="Nama" value={getValue("nama")} />
<Row label="Tempat Tgl Lahir" value={getValue("tempat tanggal lahir")} />
<Row label="Alamat" value={getValue("alamat")} />
<Row label="NIK" value={getValue("nik")} />
</div>
<br />
<p style={{ textAlign: "justify" }}>
Orang tersebut benar-benar penduduk desa {data.setting.desaNama} dan termasuk keluarga tidak mampu.
Surat keterangan ini dipergunakan untuk
<b>{getValue("alasan permohonan")}.</b>
</p>
<p style={{ textAlign: "justify" }}>
Demikian surat keterangan ini kami buat dengan sebenar-benarnya untuk dapat dipergunakan
sebagaimana mestinya.
</p>
<br /><br />
{/* TANDA TANGAN */}
<div style={{ width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
{data.setting.desaKabupaten}, {data.surat.createdAt} <br /> <br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /><br />
<u>{data.setting.perbekelNama}</u><br />
{data.setting.perbekelJabatan + " " + data.setting.desaNama}
</div>
</div>
</div>
</div>
);
}
function Row({ label, value }: { label: string, value: string }) {
return (
<div style={{ display: "flex", marginBottom: "4px" }}>
<div style={{ width: "180px" }}>{label}</div>
<div style={{ width: "10px" }}>:</div>
<div>{value}</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKUsaha({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "10px" }}>
<b>PEMERINTAH KABUPATEN {_.upperCase(data.setting.desaKabupaten)}</b><br />
<b>KECAMATAN {_.upperCase(data.setting.desaKecamatan)}</b><br />
<b>DESA / KELURAHAN {_.upperCase(data.setting.desaNama)}</b><br />
Alamat: {data.setting.desaAlamat}<br />
Kode Pos: {data.setting.desaPos}
</div>
{/* JUDUL */}
<div style={{ textAlign: "center", margin: "15px 0" }}>
<b><u>SURAT KETERANGAN USAHA</u></b><br />
Nomor: {data.surat.noSurat}
</div>
{/* YANG BERTANDA TANGAN */}
<div style={{ marginTop: "15px" }}>
Yang bertanda tangan di bawah ini:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr>
<td style={{ width: "160px" }}>Nama</td>
<td style={{ width: "10px" }}>:</td>
<td>{data.setting.perbekelNama}</td>
</tr>
<tr>
<td>Jabatan</td>
<td>:</td>
<td>{data.setting.perbekelJabatan}</td>
</tr>
<tr>
<td>Kecamatan</td>
<td>:</td>
<td>{data.setting.desaKecamatan}</td>
</tr>
<tr>
<td>Kabupaten</td>
<td>:</td>
<td>{data.setting.desaKabupaten}</td>
</tr>
</tbody>
</table>
</div>
{/* IDENTITAS ORANG YG MEMINTA SURAT */}
<div style={{ marginTop: "20px" }}>
Dengan ini menerangkan dengan sesungguhnya bahwa:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Nama</td><td style={{ width: "10px" }}>:</td><td>{getValue("nama")}</td></tr>
<tr><td>Jenis Kelamin</td><td>:</td><td>{getValue("jenis kelamin")}</td></tr>
<tr><td>Tempat / Tanggal Lahir</td><td>:</td><td>{getValue("tempat tanggal lahir")}</td></tr>
<tr><td>Warga Negara</td><td>:</td><td>{getValue("negara")}</td></tr>
<tr><td>Agama</td><td>:</td><td>{getValue("agama")}</td></tr>
<tr><td>Status</td><td>:</td><td>{getValue("status perkawinan")}</td></tr>
<tr><td>Pekerjaan</td><td>:</td><td>{getValue("pekerjaan")}</td></tr>
<tr><td>Alamat</td><td>:</td><td>{getValue("alamat")}</td></tr>
</tbody>
</table>
</div>
{/* DOMISILI */}
<div style={{ marginTop: "20px" }}>
Bahwa orang tersebut di atas benar-benar penduduk:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Desa / Kelurahan</td><td style={{ width: "10px" }}>:</td><td>{data.setting.desaNama}</td></tr>
<tr><td>Kecamatan</td><td>:</td><td>{data.setting.desaKecamatan}</td></tr>
<tr><td>Kabupaten</td><td>:</td><td>{data.setting.desaKabupaten}</td></tr>
</tbody>
</table>
</div>
{/* USAHA */}
<div style={{ marginTop: "20px" }}>
Dan yang bersangkutan benar memiliki usaha:
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr><td style={{ width: "160px" }}>Jenis Usaha</td><td style={{ width: "10px" }}>:</td><td>{getValue("jenis usaha")}</td></tr>
<tr><td>Alamat Usaha</td><td>:</td><td>{getValue("alamat usaha")}</td></tr>
</tbody>
</table>
</div>
<div style={{ marginTop: "20px" }}>
Surat keterangan ini dibuat dengan sebenarnya untuk dipergunakan sebagaimana mestinya.
</div>
<div style={{ marginTop: "20px" }}>
Dikeluarkan di {data.setting.desaNama} <br />
Pada tanggal {data.surat.createdAt}
</div>
{/* TANDA TANGAN */}
<div style={{ marginTop: "10px", display: "flex", justifyContent: "flex-end", width: "100%" }}>
<div style={{ textAlign: "center" }}>
<br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} />
<br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
import { useShallowEffect } from "@mantine/hooks";
import _ from "lodash";
import { useState } from "react";
import notification from "../notificationGlobal";
export default function SKYatim({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (key: string) =>
_.upperFirst(data.surat.dataText.find((i: any) => i.jenis === key)?.value || "");
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useShallowEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
<div style={{ textAlign: "center", marginBottom: "10px" }}>
<b>PEMERINTAH KABUPATEN {_.upperCase(data.setting.desaKabupaten)}</b><br />
<b>KECAMATAN {_.upperCase(data.setting.desaKecamatan)}</b><br />
<b>DESA {_.upperCase(data.setting.desaNama)}</b><br />
Alamat: {data.setting.desaAlamat}. Kode Pos: {data.setting.desaPos}
</div>
<div style={{ textAlign: "center", marginTop: "15px" }}>
<b><u>SURAT KETERANGAN YATIM / PIATU / YATIM PIATU</u></b><br />
Nomor: {data.surat.noSurat}
</div>
<br />
{/* BAGIAN PENANDATANGAN */}
<div>Yang bertanda tangan di bawah ini:</div>
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr>
<td style={{ width: "180px" }}>Nama</td>
<td>: {data.setting.perbekelNama}</td>
</tr>
<tr>
<td>Jabatan</td>
<td>: {data.setting.perbekelJabatan}</td>
</tr>
<tr>
<td>Alamat Kantor</td>
<td>: {data.setting.desaAlamat}</td>
</tr>
</tbody>
</table>
<br />
{/* BAGIAN IDENTITAS ANAK */}
<div>Dengan ini menerangkan bahwa:</div>
<table style={{ width: "100%", marginTop: "5px" }}>
<tbody>
<tr>
<td style={{ width: "180px" }}>Nama</td>
<td>: {getValue("nama")}</td>
</tr>
<tr>
<td>Tempat/Tanggal Lahir</td>
<td>: {getValue("tempat tanggal lahir")}</td>
</tr>
<tr>
<td>Jenis Kelamin</td>
<td>: {getValue("jenis kelamin")}</td>
</tr>
<tr>
<td>Alamat</td>
<td>: {getValue("alamat")}</td>
</tr>
<tr>
<td>NIK</td>
<td>: {getValue("nik")}</td>
</tr>
<tr>
<td>Pekerjaan</td>
<td>: {getValue("pekerjaan")}</td>
</tr>
</tbody>
</table>
<br />
{/* KETERANGAN ORANG TUA */}
<div>
Benar bahwa yang bersangkutan adalah <b>anak (Yatim / Piatu / Yatim Piatu)</b>,
dengan keterangan sebagai berikut:
</div>
<br />
<div><b>1. Nama Ayah</b></div>
<table style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ width: "180px" }}>Nama Ayah</td>
<td>: {getValue("nama ayah")}</td>
</tr>
<tr>
<td>Status</td>
<td>: {getValue("status ayah")}</td>
</tr>
</tbody>
</table>
<br />
<div><b>2. Nama Ibu</b></div>
<table style={{ width: "100%" }}>
<tbody>
<tr>
<td style={{ width: "180px" }}>Nama Ibu</td>
<td>: {getValue("nama ibu")}</td>
</tr>
<tr>
<td>Status</td>
<td>: {getValue("status ibu")}</td>
</tr>
</tbody>
</table>
<br />
<div>
Dengan demikian, berdasarkan keterangan pihak keluarga dan data di Kantor Desa,
maka benar bahwa yang bersangkutan adalah
<b> anak (Yatim / Piatu / Yatim Piatu).</b>
</div>
<br />
<div>
Surat keterangan ini dibuat dengan sebenar-benarnya untuk dipergunakan sebagaimana mestinya.
</div>
<br />
{/* TANGGAL & TEMPAT */}
<table style={{ width: "100%", marginTop: "10px" }}>
<tbody>
<tr>
<td style={{ width: "180px" }}>Dikeluarkan di</td>
<td>: {data.setting.desaNama}</td>
</tr>
<tr>
<td>Pada tanggal</td>
<td>: {data.surat.createdAt}</td>
</tr>
</tbody>
</table>
<br />
{/* TTD */}
<div style={{ width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa {data.setting.desaNama}
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import cors from "@elysiajs/cors";
import Swagger from "@elysiajs/swagger";
import Elysia from "elysia";
import html from "./index.html";
@@ -6,13 +7,18 @@ import { apiAuth } from "./server/middlewares/apiAuth";
import AduanRoute from "./server/routes/aduan_route";
import ApiKeyRoute from "./server/routes/apikey_route";
import Auth from "./server/routes/auth_route";
import ConfigurationDesaRoute from "./server/routes/configuration_desa_route";
import CredentialRoute from "./server/routes/credential_route";
import DarmasabaRoute from "./server/routes/darmasaba_route";
import DashboardRoute from "./server/routes/dashboard_route";
import LayananRoute from "./server/routes/layanan_route";
import { MCPRoute } from "./server/routes/mcp_route";
import PelayananRoute from "./server/routes/pelayanan_surat_route";
import PengaduanRoute from "./server/routes/pengaduan_route";
import SuratRoute from "./server/routes/surat_route";
import TestPengaduanRoute from "./server/routes/test_pengaduan";
import UserRoute from "./server/routes/user_route";
import WargaRoute from "./server/routes/warga_route";
const Docs = new Elysia({
tags: ["docs"],
@@ -26,8 +32,13 @@ const Api = new Elysia({
prefix: "/api",
tags: ["api"],
})
.use(DashboardRoute)
.use(PengaduanRoute)
.use(PelayananRoute)
.use(ConfigurationDesaRoute)
.use(WargaRoute)
.use(SuratRoute)
.use(TestPengaduanRoute)
.use(apiAuth)
.use(ApiKeyRoute)
.use(DarmasabaRoute)
@@ -40,6 +51,13 @@ const app = new Elysia()
.use(Api)
.use(Docs)
.use(Auth)
.use(
cors({
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type"],
}),
)
.get(
"/.well-known/mcp.json",
async () => {

View File

@@ -17,7 +17,7 @@ export const categoryPelayananSurat = [
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" },
{ name: "akta cerai", desc: "Fotokopi Akta Cerai bagi yang berstatus janda/duda" }
],
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "status perkawinan"]
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "agama", "pekerjaan"]
},
{
id: "skdomisiliorganisasi",
@@ -25,9 +25,9 @@ export const categoryPelayananSurat = [
syaratDokumen: [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "skt organisasi", desc: "Fotokopi Surat Keterangan Terdaftar (SKT) Organisasi atau Pengukuhan Kelompok" },
{name: "susunan pengurus", desc: "Jika Pengajuan baru pembuatan SKT maka melengkapi Susunan Pengurus lengkap denganKop Organisasi"}
{ name: "susunan pengurus", desc: "Jika Pengajuan baru pembuatan SKT maka melengkapi Susunan Pengurus lengkap denganKop Organisasi" }
],
dataText: ["nama organisasi", "alamat organisasi", "nama pemohon", "jabatan pemohon", "kontak", "penanggung jawab", "tanggal berdiri"]
dataText: ["nama organisasi", "jenis organisasi", "alamat organisasi/sekretariat", "no telepon", "nama pimpinan", "keperluan"]
},
{
id: "skkelahiran",
@@ -36,7 +36,7 @@ export const categoryPelayananSurat = [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "surat lahir", desc: "Fotokopi Surat Keterangan Lahir dari Bidan/Dokter (jika ada)" }
],
dataText: ["nama ayah", "nama ibu", "nama anak", "tanggal lahir", "tempat lahir", "jenis kelamin", "nama pelapor"]
dataText: ["nama ayah", "nama ibu", "nama anak", "tanggal lahir anak", "pukul lahir anak", "tempat lahir anak", "jenis kelamin anak", "anak ke", "nik ibu", "tempat tanggal lahir ibu", "pekerjaan ibu", "alamat ibu", "nik ayah", "tempat tanggal lahir ayah", "pekerjaan ayah", "alamat ayah", "nama pelapor", "hubungan pelapor", "alamat pelapor"]
},
{
id: "skkelakuanbaik",
@@ -45,7 +45,7 @@ export const categoryPelayananSurat = [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" }
],
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "keperluan"]
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "agama", "alamat", "pekerjaan", "polsek"]
},
{
id: "skkematian",
@@ -55,7 +55,7 @@ export const categoryPelayananSurat = [
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" },
{ name: "surat kematian", desc: "Surat Keterangan Kematian dari Rumah Sakit/Dokter (jika ada)" }
],
dataText: ["nama almarhum", "nik", "tempat tanggal lahir", "alamat", "tanggal kematian", "waktu kematian", "penyebab kematian"]
dataText: ["nik pelapor", "nama pelapor", "pekerjaan pelapor", "alamat pelapor", "hubungan pelapor dengan almarhum", "nama almarhum", "nik almarhum", "tempat tanggal lahir almarhum", "alamat almarhum", "agama almarhum", "tanggal kematian", "waktu kematian", "tempat kematian", "penyebab kematian"]
},
{
id: "skpenghasilan",
@@ -65,7 +65,7 @@ export const categoryPelayananSurat = [
{ name: "ktp ortu/kk", desc: "Fotokopi KTP orang tua atau Kartu Keluarga" },
{ name: "surat pernyataan", desc: "Surat Pernyataan Penghasilan bermaterai" }
],
dataText: ["nama", "nik", "alamat", "pekerjaan", "jenis usaha", "penghasilan"]
dataText: ["nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "pekerjaan", "penghasilan", "alasan permohonan"]
},
{
id: "sktempatusaha",
@@ -76,7 +76,7 @@ export const categoryPelayananSurat = [
{ name: "foto lokasi", desc: "Foto lokasi usaha dicetak dalam selembar kertas, diparaf dan distempel oleh Kelian" },
{ name: "sppt/sertifikat/sewa", desc: "Fotokopi SPPT, Sertifikat Hak Milik, Surat Perjanjian Sewa, atau Kwitansi Pembayaran Sewa 3 bulan terakhir" }
],
dataText: ["nama usaha", "bidang usaha", "alamat usaha", "status tempat usaha", "luas tempat usaha", "jumlah karyawan", "tujuan pembuatan surat"]
dataText: ["nik", "nama pemilik", "tempat tanggal lahir", "alamat pemilik", "nama usaha", "bidang usaha", "alamat usaha", "status tempat usaha", "luas tempat usaha", "jumlah karyawan", "tujuan pembuatan surat"]
},
{
id: "sktidakmampu",
@@ -95,7 +95,7 @@ export const categoryPelayananSurat = [
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" },
{ name: "foto lokasi", desc: "Foto lokasi usaha dicetak dalam selembar kertas, diparaf dan distempel oleh Kelian" }
],
dataText: ["jenis usaha", "alamat usaha"]
dataText: ["nama", "jenis kelamin", "tempat tanggal lahir", "negara", "agama", "status perkawinan", "alamat", "pekerjaan", "jenis usaha", "alamat usaha"]
},
{
id: "skyatimpiatu",
@@ -104,6 +104,6 @@ export const categoryPelayananSurat = [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "ktp/kia/kk", desc: "Fotokopi KTP, KIA, atau Kartu Keluarga" }
],
dataText: ["nama anak", "nama ayah", "status ayah", "nama ibu", "status ibu"]
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "pekerjaan", "nama ayah", "status ayah", "nama ibu", "status ibu"]
}
];

View File

@@ -47,7 +47,7 @@ export const confDesa = [
{
id: "perbekelNIP",
name: "NIP",
value: ""
value: "1122334455"
},
{
id: "perbekelTTD",

View File

@@ -0,0 +1,59 @@
import config from "@/lib/listPermission.json";
export interface PermissionNode {
key: string;
label: string;
children?: PermissionNode[];
}
interface Grouped {
[key: string]: {
label: string;
children: Grouped;
actions: string[];
};
}
/* --- Build lookup table --- */
const permissionMap: Record<string, string[]> = {};
function walk(nodes: PermissionNode[], path: string[] = []) {
nodes.forEach((n) => {
const full = [...path, n.label];
permissionMap[n.key] = full;
if (n.children) walk(n.children, full);
});
}
walk(config.menus);
/* --- Convert keys → hierarchical grouped --- */
export function groupPermissions(keys: string[]) {
const tree: Grouped = {};
keys.forEach((key) => {
const path = permissionMap[key];
if (!path) return;
let pointer = tree;
path.forEach((label, idx) => {
if (!pointer[label]) {
pointer[label] = {
label,
children: {},
actions: []
};
}
// last item = actual permission action
if (idx === path.length - 1) {
pointer[label].actions.push(label);
}
pointer = pointer[label].children;
});
});
return tree;
}

310
src/lib/listPermission.json Normal file
View File

@@ -0,0 +1,310 @@
{
"menus": [
{
"key": "dashboard",
"label": "Dashboard",
"default": true,
"children": [
{
"key": "dashboard.view",
"label": "Melihat Dashboard",
"default": true
}
]
},
{
"key": "pengaduan",
"label": "Pengaduan",
"default": true,
"children": [
{
"key": "pengaduan.view",
"label": "Melihat List & Detail",
"default": true
},
{
"key": "pengaduan.antrian",
"label": "Detail pengaduan dengan status antrian",
"default": true,
"children": [
{
"key": "pengaduan.antrian.tolak",
"label": "Menolak pengaduan",
"default": true
},
{
"key": "pengaduan.antrian.terima",
"label": "Menerima pengaduan",
"default": true
}
]
},
{
"key": "pengaduan.diterima",
"label": "Detail pengaduan dengan status diterima",
"default": true,
"children": [
{
"key": "pengaduan.diterima.dikerjakan",
"label": "Menegerjakan pengaduan",
"default": true
}
]
},
{
"key": "pengaduan.dikerjakan",
"label": "Detail pengaduan dengan status dikerjakan",
"default": true,
"children": [
{
"key": "pengaduan.dikerjakan.selesai",
"label": "Menyelesaikan pengaduan",
"default": true
}
]
}
]
},
{
"key": "pelayanan",
"label": "Pelayanan",
"default": true,
"children": [
{
"key": "pelayanan.view",
"label": "Melihat List & Detail",
"default": true
},
{
"key": "pelayanan.antrian",
"label": "Detail pelayanan dengan status antrian",
"default": true,
"children": [
{
"key": "pelayanan.antrian.tolak",
"label": "Menolak pelayanan",
"default": true
},
{
"key": "pelayanan.antrian.terima",
"label": "Menerima pelayanan",
"default": true
}
]
},
{
"key": "pelayanan.diterima",
"label": "Detail pelayanan dengan status diterima",
"default": true,
"children": [
{
"key": "pelayanan.diterima.tolak",
"label": "Menolak pelayanan",
"default": true
},
{
"key": "pelayanan.diterima.setujui",
"label": "Menyetujui pelayanan",
"default": true
}
]
}
]
},
{
"key": "warga",
"label": "Warga",
"default": true,
"children": [
{
"key": "warga.view",
"label": "Melihat List & Detail",
"default": true
}
]
},
{
"key": "setting",
"label": "Setting",
"default": true,
"children": [
{
"key": "setting.profile",
"label": "Profile",
"default": true,
"children": [
{
"key": "setting.profile.view",
"label": "View",
"default": true
},
{
"key": "setting.profile.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.profile.password",
"label": "Ubah Password",
"default": true
}
]
},
{
"key": "setting.user",
"label": "User",
"default": true,
"children": [
{
"key": "setting.user.view",
"label": "View List",
"default": true
},
{
"key": "setting.user.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.user.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.user.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.user_role",
"label": "User Role",
"default": true,
"children": [
{
"key": "setting.user_role.view",
"label": "View List",
"default": true
},
{
"key": "setting.user_role.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.user_role.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.user_role.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.kategori_pengaduan",
"label": "Kategori Pengaduan",
"default": true,
"children": [
{
"key": "setting.kategori_pengaduan.view",
"label": "View List",
"default": true
},
{
"key": "setting.kategori_pengaduan.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.kategori_pengaduan.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.kategori_pengaduan.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.kategori_pelayanan",
"label": "Kategori Pelayanan Surat",
"default": true,
"children": [
{
"key": "setting.kategori_pelayanan.view",
"label": "View List",
"default": true
},
{
"key": "setting.kategori_pelayanan.detail",
"label": "View Detail",
"default": true
},
{
"key": "setting.kategori_pelayanan.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.kategori_pelayanan.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.kategori_pelayanan.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.desa",
"label": "Desa",
"default": true,
"children": [
{
"key": "setting.desa.view",
"label": "View List",
"default": true
},
{
"key": "setting.desa.edit",
"label": "Edit",
"default": true
}
]
}
]
},
{
"key": "api_key",
"label": "API Key",
"default": true,
"children": [
{
"key": "api_key.view",
"label": "View List",
"default": true
}
]
},
{
"key": "credential",
"label": "Credential",
"default": true,
"children": [
{
"key": "credential.view",
"label": "View List",
"default": true
}
]
}
]
}

View File

@@ -1,20 +1,50 @@
import clientRoutes from "@/clientRoutes";
import {
Button,
Container,
Group,
PasswordInput,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useState } from "react";
import apiFetch from "../lib/apiFetch";
import clientRoutes from "@/clientRoutes";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
function navigateToRoute(akses: string) {
switch (akses) {
case "dashboard":
window.location.href = clientRoutes["/scr/dashboard/dashboard-home"];
break;
case "pengaduan":
window.location.href = clientRoutes["/scr/dashboard/pengaduan/list"];
break;
case "warga":
window.location.href = clientRoutes["/scr/dashboard/warga/list-warga"];
break;
case "credential":
window.location.href = clientRoutes["/scr/dashboard/credential/credential"];
break;
case "setting":
window.location.href = clientRoutes["/scr/dashboard/setting/detail-setting"];
break;
case "api_key":
window.location.href = clientRoutes["/scr/dashboard/apikey/apikey"];
break;
case "pelayanan":
window.location.href = clientRoutes["/scr/dashboard/pelayanan-surat/list-pelayanan"];
break;
default:
window.location.href = clientRoutes["/scr/dashboard"];
break;
}
}
const handleSubmit = async () => {
setLoading(true);
try {
@@ -25,7 +55,7 @@ export default function Login() {
if (response.data?.token) {
localStorage.setItem("token", response.data.token);
window.location.href = clientRoutes["/scr/dashboard"];
navigateToRoute(response.data.akses || "dashboard");
return;
}
@@ -48,7 +78,7 @@ export default function Login() {
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextInput
<PasswordInput
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}

View File

@@ -1,25 +1,16 @@
import DashboardCountData from "@/components/DashboardCountData";
import DashboardGrafik from "@/components/DashboardGrafik";
import DashboardLastData from "@/components/DashboardLastData";
import {
Card,
Badge,
Container,
Flex,
Group,
Progress,
Stack,
Text,
Title,
Progress,
Badge,
Button,
Grid,
Divider,
Title
} from "@mantine/core";
import {
IconActivity,
IconUsers,
IconServer,
IconDatabase,
IconSettings,
IconArrowRight,
} from "@tabler/icons-react";
export default function Dashboard() {
return (
@@ -43,144 +34,15 @@ export default function Dashboard() {
Live
</Badge>
</Group>
<Button
variant="gradient"
gradient={{ from: "teal", to: "cyan", deg: 45 }}
radius="md"
rightSection={<IconArrowRight size={18} />}
style={{
boxShadow: "0 0 12px rgba(0,255,200,0.3)",
}}
>
View Details
</Button>
</Flex>
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MetricCard
icon={<IconUsers size={28} />}
label="Active Users"
value="1,248"
change="+12%"
color="teal"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MetricCard
icon={<IconServer size={28} />}
label="Server Uptime"
value="99.98%"
change="+0.02%"
color="cyan"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MetricCard
icon={<IconDatabase size={28} />}
label="Database Ops"
value="82.4K"
change="+5.6%"
color="blue"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MetricCard
icon={<IconActivity size={28} />}
label="System Health"
value="Stable"
change=""
color="green"
/>
</Grid.Col>
</Grid>
<Card
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 25px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.0">
System Performance
</Title>
<IconSettings size={20} color="gray" />
</Flex>
<Divider my="xs" />
<Text size="sm" c="dimmed">
Resource usage and performance indicators.
</Text>
<Stack gap="sm" mt="md">
<ProgressSection label="CPU Usage" value={68} color="teal" />
<ProgressSection label="Memory Usage" value={75} color="cyan" />
<ProgressSection label="Network Load" value={42} color="blue" />
<ProgressSection label="Disk Space" value={88} color="red" />
</Stack>
</Stack>
</Card>
<DashboardCountData />
<DashboardGrafik />
<DashboardLastData />
</Stack>
</Container>
);
}
function MetricCard({
icon,
label,
value,
change,
color,
}: {
icon: React.ReactNode;
label: string;
value: string;
change?: string;
color: string;
}) {
return (
<Card
radius="lg"
p="md"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(30,30,30,0.95), rgba(55,55,55,0.9))",
borderColor: "rgba(100,100,100,0.2)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.boxShadow = "0 0 10px rgba(0,255,200,0.2)")
}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
>
<Stack gap={6}>
<Group gap={6}>
{icon}
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Flex align="center" justify="space-between">
<Text fw={600} size="xl" c="gray.0">
{value}
</Text>
{change && (
<Text size="sm" c={color}>
{change}
</Text>
)}
</Flex>
</Stack>
</Card>
);
}
function ProgressSection({
label,
value,

View File

@@ -35,6 +35,7 @@ import {
IconUsersGroup,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
@@ -212,49 +213,69 @@ function HostView() {
function NavigationDashboard() {
const navigate = useNavigate();
const location = useLocation();
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchPermissions() {
const { data } = await apiFetch.api.user.find.get();
if (Array.isArray(data?.permissions)) {
setPermissions(data.permissions);
} else {
setPermissions([]);
}
}
fetchPermissions();
}, []);
const isActive = (path: keyof typeof clientRoute) =>
location.pathname.startsWith(clientRoute[path]);
const navItems = [
{
key: "dashboard",
path: "/scr/dashboard/dashboard-home",
icon: <IconDashboard size={20} />,
label: "Dashboard Overview",
description: "Quick summary and insights",
},
{
key: "pengaduan",
path: "/scr/dashboard/pengaduan/list",
icon: <IconMessageReport size={20} />,
label: "Pengaduan Warga",
description: "Manage pengaduan warga",
},
{
path: "/scr/dashboard/pelayanan",
key: "pelayanan",
path: "/scr/dashboard/pelayanan-surat/list-pelayanan",
icon: <IconFileCertificate size={20} />,
label: "Pelayanan Surat",
description: "Manage pelayanan surat",
},
{
path: "/scr/dashboard/user",
key: "warga",
path: "/scr/dashboard/warga/list-warga",
icon: <IconUsersGroup size={20} />,
label: "User",
description: "Manage user",
label: "Warga",
description: "Manage warga",
},
{
path: "/scr/dashboard/setting",
key: "setting",
path: "/scr/dashboard/setting/detail-setting",
icon: <IconSettings size={20} />,
label: "Setting",
description:
"Manage setting (category pengaduan dan pelayanan surat, desa, etc)",
},
{
key: "api_key",
path: "/scr/dashboard/apikey/apikey",
icon: <IconKey size={20} />,
label: "API Key Manager",
description: "Create and manage API keys",
},
{
key: "credential",
path: "/scr/dashboard/credential/credential",
icon: <IconLock size={20} />,
label: "Credentials",
@@ -264,7 +285,7 @@ function NavigationDashboard() {
return (
<Stack gap="xs" p="sm">
{navItems.map((item) => (
{navItems.filter((item) => permissions.includes(item.key)).map((item) => (
<NavLink
key={item.path}
active={isActive(item.path as keyof typeof clientRoute)}

View File

@@ -0,0 +1,488 @@
import ModalFile from "@/components/ModalFile";
import ModalSurat from "@/components/ModalSurat";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
import {
Anchor,
Badge,
Button,
Card,
Container,
Divider,
Flex,
Grid,
Group,
List,
Modal,
Stack,
Table,
Text,
Textarea,
ThemeIcon,
Title
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import {
IconAlignJustified,
IconCheck,
IconFileCertificate,
IconFileCheck,
IconMessageReport,
IconPhone,
IconUser
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import useSwr from "swr";
export default function DetailPengajuanPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pelayanan.detail.get({
query: {
id: id!,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPengajuan data={data?.data?.pengajuan} syaratDokumen={data?.data?.syaratDokumen} dataText={data?.data?.dataText} onAction={() => { mutate(); }} />
<DetailDataHistori data={data?.data?.history} />
</Stack>
</Grid.Col>
<Grid.Col span={4}>
<DetailUserPengajuan data={data?.data?.warga} />
</Grid.Col>
</Grid>
</Container>
);
}
function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data: any, syaratDokumen: any, dataText: any, onAction: () => void }) {
const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
const [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
const [noSurat, setNoSurat] = useState("");
const [openedPreview, setOpenedPreview] = useState(false);
const [openedPreviewFile, setOpenedPreviewFile] = useState(false);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
const [viewImg, setViewImg] = useState("");
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
if (data?.permissions && Array.isArray(data.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("pelayanan"));
setPermissions(onlySetting);
}
}
fetchHost();
}, []);
const handleKonfirmasi = async (cat: "terima" | "tolak") => {
try {
const res = await apiFetch.api.pelayanan["update-status"].post({
id: data?.id,
status: cat == 'tolak' ? 'ditolak' : data.status == 'antrian' ? 'diterima' : 'selesai',
keterangan: keterangan,
idUser: host?.id ?? "",
noSurat: noSurat
});
if (res?.status === 200) {
onAction();
close();
notification({
title: "Success",
message: "Success update pengajuan surat",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to update pengajuan surat",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to update pengajuan surat",
type: "error",
});
}
}
useShallowEffect(() => {
if (viewImg) {
setOpenedPreviewFile(true);
}
}, [viewImg]);
return (
<>
<ModalFile
open={openedPreviewFile && !_.isEmpty(viewImg)}
onClose={() => {
setOpenedPreviewFile(false)
}}
folder="syarat-dokumen"
fileName={viewImg}
/>
{/* MODAL KONFIRMASI */}
<Modal
opened={opened}
onClose={close}
title={"Konfirmasi"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="sm">
{catModal === "tolak" ? (
<>
<Text>
Anda yakin ingin menolak pengajuan surat ini? Berikan alasan penolakan
</Text>
<Textarea size="md" minRows={5} value={keterangan} onChange={(e) => setKeterangan(e.target.value)} />
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button variant="filled" color="red" disabled={keterangan.length < 1} onClick={() => handleKonfirmasi("tolak")}>
Tolak
</Button>
</Group>
</>
) : (
<>
<Text>
Anda yakin ingin {data?.status == 'antrian' ? 'menerima' : 'menyetujui'} pengajuan surat ini?
{
data.status == 'diterima' && 'Masukkan nomer surat yang akan dibuat'
}
</Text>
{
data.status == 'diterima' && (
<Textarea size="md" minRows={5} value={noSurat} onChange={(e) => setNoSurat(e.target.value)} placeholder="Contoh : 08/D-IV/11/2025" />
)
}
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Tidak
</Button>
<Button variant="filled" color="green" onClick={() => handleKonfirmasi("terima")} disabled={data.status == 'diterima' && noSurat.length < 1}>
Ya
</Button>
</Group>
</>
)}
</Stack>
</Modal>
{
data?.status == "selesai" &&
(<ModalSurat open={openedPreview} onClose={() => setOpenedPreview(false)} surat={data?.idSurat} />)
}
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Group gap="xs">
<Title order={4} c="gray.2">
Pengajuan {data?.category}
</Title>
<Title order={4} c="dimmed">
#{data?.noPengajuan}
</Title>
</Group>
<Badge
size="xl"
variant="light"
radius="sm"
color={
data?.status === "diterima"
? "green"
: data?.status === "ditolak"
? "red"
: data?.status === "selesai"
? "blue"
: data?.status === "dikerjakan"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }}
>
{data?.status}
</Badge>
</Flex>
<Divider my={0} />
<Grid>
<Grid.Col span={12}>
<Stack gap="lg">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconFileCheck size={20} />
<Text size="md">Syarat Dokumen</Text>
</Group>
<List
spacing="sm"
pt={10}
icon={
<ThemeIcon variant="default" size={20} radius="xl">
<IconCheck size={13} />
</ThemeIcon>
}
>
{syaratDokumen?.map((v: any) => (
<List.Item key={v.id}>
<Anchor onClick={() => { setViewImg(v.value) }}>
{v.jenis}
</Anchor>
</List.Item>
))}
</List>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">Data Pelengkap</Text>
</Group>
<Table withRowBorders={false}>
<Table.Tbody>
{
dataText?.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td style={{ whiteSpace: "nowrap", width: "10%" }}>{_.upperFirst(item.jenis)}</Table.Td>
<Table.Td>:</Table.Td>
<Table.Td style={{ width: "85%" }}>{_.upperFirst(item.value)}</Table.Td>
</Table.Tr>
))
}
</Table.Tbody>
</Table>
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={12}>
{
data?.status === "antrian" ? (
<Group justify="center" grow>
<Button
disabled={!permissions.includes("pelayanan.antrian.tolak")}
variant="light"
onClick={() => {
setCatModal("tolak");
open();
}}
>
Tolak
</Button>
<Button
disabled={!permissions.includes("pelayanan.antrian.terima")}
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Terima
</Button>
</Group>
) : data?.status === "diterima" ? (
<Group justify="center" grow>
<Button
disabled={!permissions.includes("pelayanan.diterima.tolak")}
variant="light"
onClick={() => {
setCatModal("tolak");
open();
}}
>
Tolak
</Button>
<Button
disabled={!permissions.includes("pelayanan.diterima.setujui")}
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Setujui
</Button>
</Group>
) :
data?.status === "selesai" ?
(
<Group justify="center" grow>
<Button
variant="light"
onClick={() => setOpenedPreview(!openedPreview)}
>
Surat
</Button>
</Group>
)
: <></>
}
</Grid.Col>
</Grid>
</Stack>
</Card>
</>
);
}
function DetailDataHistori({ data }: { data: any }) {
return (
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Histori Pengajuan Surat
</Title>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Tanggal</Table.Th>
<Table.Th>Deskripsi</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>User</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{
data?.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td style={{ whiteSpace: "nowrap" }}>
{
item.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false
})
}</Table.Td>
<Table.Td>{item.deskripsi}</Table.Td>
<Table.Td>{item.status}</Table.Td>
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.nameUser ? item.nameUser : "-"}</Table.Td>
</Table.Tr>
))
}
</Table.Tbody>
</Table>
</Stack>
</Card>
);
}
function DetailUserPengajuan({ data }: { data: any }) {
return (
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={4} c="gray.2">
Warga
</Title>
</Flex>
</Flex>
<Divider my={0} />
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<IconUser size={20} />
<Text size="md">Nama</Text>
</Group>
<Text size="md" c={"white"}>
{data?.name}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconPhone size={20} />
<Text size="md">Telepon</Text>
</Group>
<Text size="md" c="white">
{data?.phone}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconMessageReport size={20} />
<Text size="md">Jumlah Pengaduan</Text>
</Group>
<Text size="md" c="white">
{data?.pengaduan}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconFileCertificate size={20} />
<Text size="md">Jumlah Pelayanan Surat</Text>
</Group>
<Text size="md" c="white">
{data?.pelayanan}
</Text>
</Group>
</Stack>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,287 @@
import apiFetch from "@/lib/apiFetch";
import {
Badge,
Card,
CloseButton,
Container,
Divider,
Flex,
Grid,
Group,
Input,
Pagination,
Stack,
Tabs,
Text,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import {
IconClockHour3,
IconFileSad,
IconSearch,
IconUser
} from "@tabler/icons-react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr";
import { proxy, subscribe } from "valtio";
const state = proxy({ reload: "" });
function reloadState() {
state.reload = Math.random().toString();
}
export default function PelayananSuratListPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const status = query.get("status") as StatusKey;
return (
<Container size="xl" py="xl" w={"100%"}>
<Stack gap="xl">
<TabListPelayananSurat status={status || "semua"} />
<ListPelayananSurat status={status || "semua"} />
</Stack>
</Container>
);
}
function TabListPelayananSurat({ status }: { status: string }) {
const navigate = useNavigate();
const { data, mutate, isLoading } = useSwr("/pelayanan-surat/count", () =>
apiFetch.api.pelayanan.count.get().then((res) => res.data),
);
useShallowEffect(() => {
mutate();
}, []);
return (
<Tabs defaultValue={status || "semua"} color="teal">
<Tabs.List grow>
<Tabs.Tab
value="semua"
onClick={() => {
navigate("?status=semua");
}}
>
Semua ({data?.semua || 0})
</Tabs.Tab>
<Tabs.Tab
value="antrian"
onClick={() => {
navigate("?status=antrian");
}}
>
Antrian ({data?.antrian || 0})
</Tabs.Tab>
<Tabs.Tab
value="diterima"
onClick={() => {
navigate("?status=diterima");
}}
>
Diterima ({data?.diterima || 0})
</Tabs.Tab>
<Tabs.Tab
value="selesai"
onClick={() => {
navigate("?status=selesai");
}}
>
Selesai ({data?.selesai || 0})
</Tabs.Tab>
<Tabs.Tab
value="ditolak"
onClick={() => {
navigate("?status=ditolak");
}}
>
Ditolak ({data?.ditolak || 0})
</Tabs.Tab>
</Tabs.List>
</Tabs>
);
}
type StatusKey =
| "antrian"
| "diterima"
| "dikerjakan"
| "ditolak"
| "selesai"
| "semua";
function ListPelayananSurat({ status }: { status: StatusKey }) {
const [page, setPage] = useState(1);
const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", async () =>
apiFetch.api.pelayanan.list.get({
query: {
status,
search: value,
take: "",
page: page.toString(),
},
})
);
useShallowEffect(() => {
setPage(1);
mutate();
}, [status, value]);
useShallowEffect(() => {
mutate();
}, [page]);
useShallowEffect(() => {
const unsubscribe = subscribe(state, () => mutate());
return () => unsubscribe();
}, []);
const navigate = useNavigate();
if (isLoading)
return (
<Card
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
}}
>
<Text size="sm" c="dimmed">
Loading pelayanan surat...
</Text>
</Card>
);
const list = data?.data?.data || [];
const total = data?.data?.total || 0;
const totalPage = data?.data?.totalPages || 1;
const pageSize = data?.data?.pageSize || 10;
const pageNow = data?.data?.page || 1;
const toDate = (d: any) => new Date(d);
return (
<Stack gap="xl">
<Grid>
<Grid.Col span={9}>
<Input
value={value}
placeholder="Cari pengajuan..."
onChange={(event) => setValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{ display: value ? undefined : "none" }}
/>
}
/>
</Grid.Col>
<Grid.Col span={3}>
<Group justify="flex-end">
<Text size="sm" c="gray.5">{`${pageSize * (page - 1) + 1} ${Math.min(total, pageSize * page)} of ${total}`}</Text>
<Pagination total={totalPage} value={page} onChange={setPage} withPages={false} />
</Group>
</Grid.Col>
</Grid>
{Array.isArray(list) && list?.length === 0 ? (
<Flex justify="center" align="center" py={"xl"}>
<Stack gap={4} align="center">
<IconFileSad size={32} color="gray" />
<Text c="dimmed" size="sm">
No pelayanan surat have been added yet.
</Text>
</Stack>
</Flex>
) : (
Array.isArray(list) && list?.map((v: any) => (
<Card
key={v.id}
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
onClick={() => {
navigate(
`/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${v.id}`,
);
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={3} c="gray.2">
{v.category}
</Title>
<Group>
<Title order={6} c="gray.5">
#{v.noPengajuan}
</Title>
<Text size="sm" c="dimmed">
{String(v.updatedAt)}
</Text>
</Group>
</Flex>
<Badge
size="xl"
variant="light"
radius="sm"
color={
v.status === "diterima"
? "green"
: v.status === "ditolak"
? "red"
: v.status === "selesai"
? "blue"
: v.status === "dikerjakan"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }}
>
{v.status}
</Badge>
</Flex>
<Divider my={0} />
<Stack gap="sm">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconClockHour3 size={20} color="white" />
<Text size="md" c="white">
Tanggal Ajuan
</Text>
</Group>
<Text size="md">{toDate(v.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })}</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconUser size={20} color="white" />
<Text size="md" c="white">
Warga
</Text>
</Group>
<Text size="md">{v.warga}</Text>
</Flex>
</Stack>
</Stack>
</Card>
))
)}
</Stack>
);
}

View File

@@ -0,0 +1,483 @@
import ModalFile from "@/components/ModalFile";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
import {
Anchor,
Badge,
Button,
Card,
Container,
Divider,
Flex,
Grid,
Group,
Modal,
Stack,
Table,
Text,
Textarea,
Title
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import {
IconAlignJustified,
IconCategory,
IconFileCertificate,
IconInfoTriangle,
IconMapPin,
IconMessageReport,
IconPhone,
IconPhotoScan,
IconUser,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import useSwr from "swr";
export default function DetailPengaduanPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pengaduan.detail.get({
query: {
id: id!,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPengaduan data={data?.data?.pengaduan} onAction={() => { mutate(); }} />
<DetailDataHistori data={data?.data?.history} />
</Stack>
</Grid.Col>
<Grid.Col span={4}>
<DetailUserPengaduan data={data?.data?.warga} />
</Grid.Col>
</Grid>
</Container>
);
}
function DetailDataPengaduan({ data, onAction }: { data: any | null, onAction: () => void }) {
const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
const [openedPreview, setOpenedPreview] = useState(false);
const [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
if (data?.permissions && Array.isArray(data.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("pengaduan"));
setPermissions(onlySetting);
}
}
fetchHost();
}, []);
const handleKonfirmasi = async (cat: "terima" | "tolak") => {
try {
const res = await apiFetch.api.pengaduan["update-status"].post({
id: data?.id,
status: cat == 'tolak' ? 'ditolak' : data.status == 'antrian' ? 'diterima' : data.status == 'diterima' ? 'dikerjakan' : 'selesai',
keterangan: keterangan,
idUser: host?.id ?? ""
});
if (res?.status === 200) {
onAction();
close();
notification({
title: "Success",
message: "Success update pengaduan",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to update pengaduan",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to update pengaduan",
type: "error",
});
}
}
return (
<>
{/* MODAL KONFIRMASI */}
<Modal
opened={opened}
onClose={close}
title={"Konfirmasi"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="sm">
{catModal === "tolak" ? (
<>
<Text>
Anda yakin ingin menolak pengaduan ini? Berikan alasan penolakan
</Text>
<Textarea size="md" minRows={5} value={keterangan} onChange={(e) => setKeterangan(e.target.value)} />
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button variant="filled" color="red" disabled={keterangan.length < 1} onClick={() => handleKonfirmasi("tolak")}>
Tolak
</Button>
</Group>
</>
) : (
<>
<Text>Anda yakin ingin {data?.status == 'antrian' ? 'menerima' : data.status == 'diterima' ? 'mengerjakan' : 'menyelesaikan'} pengaduan ini?</Text>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Tidak
</Button>
<Button variant="filled" color="green" onClick={() => handleKonfirmasi("terima")}>
Ya
</Button>
</Group>
</>
)}
</Stack>
</Modal>
{/* MODAL GAMBAR */}
<ModalFile
open={openedPreview && !_.isEmpty(data?.image)}
onClose={() => setOpenedPreview(false)}
folder="pengaduan"
fileName={data?.image}
/>
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Group gap="xs">
<Title order={4} c="gray.2">
Pengaduan
</Title>
<Title order={4} c="dimmed">
#{data?.noPengaduan}
</Title>
</Group>
<Badge
size="xl"
variant="light"
radius="sm"
color={
data?.status === "diterima"
? "green"
: data?.status === "ditolak"
? "red"
: data?.status === "selesai"
? "blue"
: data?.status === "dikerjakan"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }}
>
{data?.status}
</Badge>
</Flex>
<Divider my={0} />
<Grid>
<Grid.Col span={6}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">Judul</Text>
</Group>
<Text size="md" c={"white"}>
{_.upperFirst(data?.title)}
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} />
<Text size="md">Lokasi</Text>
</Group>
<Text size="md" c="white">
{_.upperFirst(data?.location)}
</Text>
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={6}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconCategory size={20} />
<Text size="md">Kategori</Text>
</Group>
<Text size="md" c="white">
{_.upperFirst(data?.category)}
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconPhotoScan size={20} />
<Text size="md">Gambar</Text>
</Group>
{
data?.image != null && data?.image != ""
?
<Anchor href="#" onClick={() => { setOpenedPreview(true) }}>
Lihat Gambar
</Anchor>
:
<Text size="md" c="white">
-
</Text>
}
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={12}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">Detail</Text>
</Group>
<Text size="md" c="white">
{_.upperFirst(data?.detail)}
</Text>
</Flex>
{
data?.keterangan && (
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconInfoTriangle size={20} />
<Text size="md">Keterangan</Text>
</Group>
<Text size="md" c={"white"}>
{_.upperFirst(data?.keterangan)}
</Text>
</Flex>
)
}
</Stack>
</Grid.Col>
<Grid.Col span={12}>
{
data?.status === "antrian" ? (
<Group justify="center" grow>
<Button
variant="light"
disabled={!permissions.includes("pengaduan.antrian.tolak")}
onClick={() => {
setCatModal("tolak");
open();
}}
>
Tolak
</Button>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.antrian.terima")}
onClick={() => {
setCatModal("terima");
open();
}}
>
Terima
</Button>
</Group>
) : data?.status === "diterima" ? (
<Group justify="center" grow>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.diterima.dikerjakan")}
onClick={() => {
setCatModal("terima");
open();
}}
>
Kerjakan
</Button>
</Group>
) : data?.status === "dikerjakan" ? (
<Group justify="center" grow>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.dikerjakan.selesai")}
onClick={() => {
setCatModal("terima");
open();
}}
>
Selesai
</Button>
</Group>
) : <></>
}
</Grid.Col>
</Grid>
</Stack>
</Card>
</>
);
}
function DetailDataHistori({ data }: { data: any }) {
return (
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Histori Pengaduan
</Title>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Tanggal</Table.Th>
<Table.Th>Deskripsi</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>User</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{
data?.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td style={{ whiteSpace: "nowrap" }}>{
item.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false
})
}</Table.Td>
<Table.Td>{item.deskripsi}</Table.Td>
<Table.Td>{item.status}</Table.Td>
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.nameUser ? item.nameUser : "-"}</Table.Td>
</Table.Tr>
))
}
</Table.Tbody>
</Table>
</Stack>
</Card>
);
}
function DetailUserPengaduan({ data }: { data: any }) {
return (
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={4} c="gray.2">
Warga
</Title>
</Flex>
</Flex>
<Divider my={0} />
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<IconUser size={20} />
<Text size="md">Nama</Text>
</Group>
<Text size="md" c={"white"}>
{data?.name}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconPhone size={20} />
<Text size="md">Telepon</Text>
</Group>
<Text size="md" c="white">
{data?.phone}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconMessageReport size={20} />
<Text size="md">Jumlah Pengaduan</Text>
</Group>
<Text size="md" c="white">
{data?.pengaduan}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconFileCertificate size={20} />
<Text size="md">Jumlah Pelayanan Surat</Text>
</Group>
<Text size="md" c="white">
{data?.pelayanan}
</Text>
</Group>
</Stack>
</Stack>
</Card>
);
}

View File

@@ -2,18 +2,28 @@ import apiFetch from "@/lib/apiFetch";
import {
Badge,
Card,
CloseButton,
Container,
Divider,
Flex,
Grid,
Group,
Input,
Pagination,
Stack,
Tabs,
Text,
Title
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications";
import { IconAlignJustified, IconClockHour3, IconMapPin } from "@tabler/icons-react";
import {
IconAlignJustified,
IconClockHour3,
IconFileSad,
IconMapPin,
IconSearch,
} from "@tabler/icons-react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr";
import { proxy, subscribe } from "valtio";
@@ -26,19 +36,13 @@ function reloadState() {
export default function PengaduanListPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const status = query.get("status");
console.log(status, "status");
const status = query.get("status") as StatusKey;
return (
<Container
size="xl"
py="xl"
w={"100%"}
>
<Container size="xl" py="xl" w={"100%"}>
<Stack gap="xl">
<TabListPengaduan status={status || "all"} />
<ListPengaduan />
<TabListPengaduan status={status || "semua"} />
<ListPengaduan status={status || "semua"} />
</Stack>
</Container>
);
@@ -46,48 +50,107 @@ export default function PengaduanListPage() {
function TabListPengaduan({ status }: { status: string }) {
const navigate = useNavigate();
const { data, mutate, isLoading } = useSwr("/pengaduan/count", () =>
apiFetch.api.pengaduan.count.get().then((res) => res.data),
);
useShallowEffect(() => {
mutate();
}, []);
return (
<Tabs defaultValue={status || "all"} color="teal">
<Tabs defaultValue={status || "semua"} color="teal">
<Tabs.List grow>
<Tabs.Tab value="all" onClick={() => { navigate("?status=all") }}>Semua</Tabs.Tab>
<Tabs.Tab value="antrian" onClick={() => { navigate("?status=antrian") }}>Antrian</Tabs.Tab>
<Tabs.Tab value="diterima" onClick={() => { navigate("?status=diterima") }}>Diterima</Tabs.Tab>
<Tabs.Tab value="dikerjakan" onClick={() => { navigate("?status=dikerjakan") }}>Dikerjakan</Tabs.Tab>
<Tabs.Tab value="ditolak" onClick={() => { navigate("?status=ditolak") }}>Ditolak</Tabs.Tab>
<Tabs.Tab value="selesai" onClick={() => { navigate("?status=selesai") }}>Selesai</Tabs.Tab>
<Tabs.Tab
value="semua"
onClick={() => {
navigate("?status=semua");
}}
>
Semua ({data?.semua || 0})
</Tabs.Tab>
<Tabs.Tab
value="antrian"
onClick={() => {
navigate("?status=antrian");
}}
>
Antrian ({data?.antrian || 0})
</Tabs.Tab>
<Tabs.Tab
value="diterima"
onClick={() => {
navigate("?status=diterima");
}}
>
Diterima ({data?.diterima || 0})
</Tabs.Tab>
<Tabs.Tab
value="dikerjakan"
onClick={() => {
navigate("?status=dikerjakan");
}}
>
Dikerjakan ({data?.dikerjakan || 0})
</Tabs.Tab>
<Tabs.Tab
value="selesai"
onClick={() => {
navigate("?status=selesai");
}}
>
Selesai ({data?.selesai || 0})
</Tabs.Tab>
<Tabs.Tab
value="ditolak"
onClick={() => {
navigate("?status=ditolak");
}}
>
Ditolak ({data?.ditolak || 0})
</Tabs.Tab>
</Tabs.List>
</Tabs>
);
}
function ListPengaduan() {
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.credential.list.get(),
type StatusKey =
| "antrian"
| "diterima"
| "dikerjakan"
| "ditolak"
| "selesai"
| "semua";
function ListPengaduan({ status }: { status: StatusKey }) {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", async () =>
apiFetch.api.pengaduan.list.get({
query: {
status,
search: value,
take: "",
page: page.toString(),
},
})
);
useShallowEffect(() => {
setPage(1);
mutate();
}, [status, value]);
useShallowEffect(() => {
mutate();
}, [page]);
useShallowEffect(() => {
const unsubscribe = subscribe(state, () => mutate());
return () => unsubscribe();
}, []);
async function handleRemove(id: string) {
try {
await apiFetch.api.credential.rm.delete({ id });
showNotification({
color: "teal",
title: "Credential Deleted",
message: "The credential was successfully removed.",
});
reloadState();
} catch {
showNotification({
color: "red",
title: "Error",
message: "Failed to delete credential. Please try again.",
});
}
}
if (isLoading)
return (
<Card
@@ -100,87 +163,140 @@ function ListPengaduan() {
}}
>
<Text size="sm" c="dimmed">
Loading credentials...
Loading pengaduan...
</Text>
</Card>
);
const list = data?.data?.list || [];
const list = data?.data?.data || [];
const total = data?.data?.total || 0;
const totalPage = data?.data?.totalPages || 1;
const pageSize = data?.data?.pageSize || 10;
const pageNow = data?.data?.page || 1;
const toDate = (d: any) => new Date(d);
return (
<Card
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={3} c="gray.2">
Dompet Hilang
</Title>
<Group>
<Title order={6} c="gray.5">
#PGD-061125-001
</Title>
<Text size="sm" c="dimmed">
updated 2 minutes ago
</Text>
</Group>
</Flex>
<Badge
size="xl"
variant="light"
radius="sm"
color="gray"
style={{ textTransform: "none" }}
>
Antrian
</Badge>
<Stack gap="xl">
<Grid>
<Grid.Col span={9}>
<Input
value={value}
placeholder="Cari pengaduan..."
onChange={(event) => setValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{ display: value ? undefined : "none" }}
/>
}
/>
</Grid.Col>
<Grid.Col span={3}>
<Group justify="flex-end">
<Text size="sm" c="gray.5">{`${pageSize * (page - 1) + 1} ${Math.min(total, pageSize * page)} of ${total}`}</Text>
<Pagination total={totalPage} value={page} onChange={setPage} withPages={false} />
</Group>
</Grid.Col>
</Grid>
{Array.isArray(list) && list.length === 0 ? (
<Flex justify="center" align="center" py={"xl"}>
<Stack gap={4} align="center">
<IconFileSad size={32} color="gray" />
<Text c="dimmed" size="sm">
No pengaduan have been added yet.
</Text>
</Stack>
</Flex>
<Divider my={0} />
<Stack gap="sm">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconClockHour3 size={20} color="white" />
<Text size="md" c="white">
Tanggal Aduan
</Text>
</Group>
<Text size="md">
05 November 2025
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} color="white" />
<Text size="md" c="white">
Lokasi
</Text>
</Group>
<Text size="md">
Jalan Darmasaba Raya no 77
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} color="white" />
<Text size="md" c="white">
Detail
</Text>
</Group>
<Text size="md">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Quis, obcaecati. Sint natus culpa temporibus neque quasi expedita ratione, facere optio incidunt quibusdam suscipit nam nemo delectus beatae similique velit obcaecati?
</Text>
</Flex>
</Stack>
</Stack>
</Card>
) : (
Array.isArray(list) && list?.map((v: any) => (
<Card
key={v.id}
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
onClick={() =>
navigate(`/scr/dashboard/pengaduan/detail?id=${v.id}`)
}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={3} c="gray.2">
{v.title}
</Title>
<Group>
<Title order={6} c="gray.5">
#{v.noPengaduan}
</Title>
<Text size="sm" c="dimmed">
{String(v.updatedAt)}
</Text>
</Group>
</Flex>
<Badge
size="xl"
variant="light"
radius="sm"
color={
v.status === "diterima"
? "green"
: v.status === "ditolak"
? "red"
: v.status === "selesai"
? "blue"
: v.status === "dikerjakan"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }}
>
{v.status}
</Badge>
</Flex>
<Divider my={0} />
<Stack gap="sm">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconClockHour3 size={20} color="white" />
<Text size="md" c="white">
Tanggal Aduan
</Text>
</Group>
<Text size="md">{toDate(v.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })}</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} color="white" />
<Text size="md" c="white">
Lokasi
</Text>
</Group>
<Text size="md">{v.location}</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} color="white" />
<Text size="md" c="white">
Detail
</Text>
</Group>
<Text size="md">{v.detail}</Text>
</Flex>
</Stack>
</Stack>
</Card>
))
)}
</Stack>
);
}

View File

@@ -0,0 +1,150 @@
import DesaSetting from "@/components/DesaSetting";
import KategoriPelayananSurat from "@/components/KategoriPelayananSurat";
import KategoriPengaduan from "@/components/KategoriPengaduan";
import ProfileUser from "@/components/ProfileUser";
import UserRoleSetting from "@/components/UserRoleSetting";
import UserSetting from "@/components/UserSetting";
import apiFetch from "@/lib/apiFetch";
import {
Card,
Container,
Grid,
NavLink
} from "@mantine/core";
import {
IconBuildingBank,
IconCategory2,
IconMailSpark,
IconUserCog,
IconUserScreen,
IconUsersGroup
} from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
export default function DetailSettingPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const type = query.get("type");
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchPermissions() {
const { data } = await apiFetch.api.user.find.get();
if (Array.isArray(data?.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("setting"));
setPermissions(onlySetting);
} else {
setPermissions([]);
}
}
fetchPermissions();
}, []);
const navItems = [
{
key: "setting.profile",
path: "profile",
icon: <IconUserCog size={20} />,
label: "Profile",
description: "Manage profile settings",
},
{
key: "setting.user",
path: "user",
icon: <IconUsersGroup size={20} />,
label: "User",
description: "Manage user accounts",
},
{
key: "setting.user_role",
path: "role",
icon: <IconUserScreen size={20} />,
label: "Role",
description: "Manage user roles",
},
{
key: "setting.kategori_pengaduan",
path: "cat-pengaduan",
icon: <IconCategory2 size={20} />,
label: "Kategori Pengaduan",
description: "Manage complaint categories",
},
{
key: "setting.kategori_pelayanan",
path: "cat-pelayanan",
icon: <IconMailSpark size={20} />,
label: "Kategori Pelayanan Surat",
description: "Manage letter service categories",
},
{
key: "setting.desa",
path: "desa",
icon: <IconBuildingBank size={20} />,
label: "Desa",
description: "Manage desa information",
}
];
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={3}>
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
{
navItems.filter((item) => permissions.includes(item.key)).map((item) => (
<NavLink
key={item.key}
href={'?type=' + item.path}
label={item.label}
leftSection={item.icon}
active={type === item.path || (!type && item.path === 'profile')}
/>
))
}
</Card>
</Grid.Col>
<Grid.Col span={9}>
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
{type === "cat-pengaduan" ? (
<KategoriPengaduan permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.kategori_pengaduan"))} />
) : type === "cat-pelayanan" ? (
<KategoriPelayananSurat permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.kategori_pelayanan"))} />
) : type === "desa" ? (
<DesaSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.desa"))} />
) : type === "user" ? (
<UserSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.user."))} />
) : type === "role" ? (
<UserRoleSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.user_role"))} />
) : (
<ProfileUser permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.profile"))} />
)}
</Card>
</Grid.Col>
</Grid>
</Container>
);
}

View File

@@ -0,0 +1,191 @@
import apiFetch from "@/lib/apiFetch";
import {
Avatar,
Box,
Button,
Card,
Container,
Divider,
Flex,
Grid,
Group,
LoadingOverlay,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconPhone } from "@tabler/icons-react";
import _ from "lodash";
import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr";
export default function DetailWargaPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.warga.detail.get({
query: {
id: id!,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
return (
<>
<LoadingOverlay visible={isLoading} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={4}>
<DetailWarga data={data?.data?.warga} />
</Grid.Col>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataHistori data={data?.data?.pengaduan} kategori="pengaduan" />
<DetailDataHistori data={data?.data?.pelayanan} kategori="pelayanan" />
</Stack>
</Grid.Col>
</Grid>
</Container>
</>
);
}
function DetailDataHistori({ data, kategori }: { data: any, kategori: 'pengaduan' | 'pelayanan' }) {
const navigate = useNavigate();
return (
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Histori {_.upperFirst(kategori)}
</Title>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>No {_.upperFirst(kategori)}</Table.Th>
<Table.Th>{kategori == "pengaduan" ? "Judul" : "Kategori"}</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{
data?.length > 0 ? (
data?.map((item: any, index: number) => (
<Table.Tr key={index}>
<Table.Td>{item.noPengaduan}</Table.Td>
<Table.Td>{kategori == "pengaduan" ? item.title : item.category}</Table.Td>
<Table.Td>{item.status}</Table.Td>
<Table.Td>
<Button
variant="outline"
onClick={() => {
kategori == "pengaduan" ?
navigate(
`/scr/dashboard/pengaduan/detail?id=${item.id}`,
) :
navigate(
`/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${item.id}`,
)
}}
>
Detail
</Button>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={4} align="center">Tidak ada data</Table.Td>
</Table.Tr>
)
}
</Table.Tbody>
</Table>
</Stack>
</Card>
);
}
function DetailWarga({ data }: { data: any }) {
return (
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px #00ffc814",
}}
>
<Box
style={{
background:
"linear-gradient(to left top, #23633a, #00685b, #006984, #0065a5, #0059b1, #114ca3, #193f94, #1d3285, #202864, #1d1f45, #171628, #0b0b0b)",
height: 100,
borderRadius: "12px",
position: "relative",
}}
/>
<Group>
<Avatar
radius={100}
size={90}
style={{
position: "absolute",
top: 80,
left: 30,
border: "3x solid white",
backgroundColor: "#099268",
}}
>
A
</Avatar>
{/* Main content */}
<Stack ml={115} gap={4}>
<Text fw={700} fz="lg">
{data?.name}
</Text>
<Text fz="sm" c="dimmed">
Warga Desa
</Text>
</Stack>
</Group>
{/* Contact info */}
<Card radius="md" mt="md" p="md" withBorder={false}>
<Stack gap="xs">
<Group gap="xs">
<IconPhone size={18} />
<Text size="sm">{data?.phone}</Text>
</Group>
</Stack>
</Card>
</Card>
);
}

View File

@@ -0,0 +1,130 @@
import apiFetch from "@/lib/apiFetch";
import {
Button,
Card,
CloseButton,
Container,
Divider,
Flex,
Group,
Input,
Pagination,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
export default function ListWargaPage() {
const navigate = useNavigate();
const [pages, setPages] = useState(1);
const [value, setValue] = useState("");
const { data, mutate } = useSWR("/", () =>
apiFetch.api.warga.list.get({
query: {
search: value,
page: pages,
},
}),
);
const list = data?.data?.data || [];
const total = data?.data?.total || 0;
const totalPage = data?.data?.totalPages || 1;
const pageSize = data?.data?.pageSize || 10;
const pageNow = data?.data?.page || 1;
useShallowEffect(() => {
setPages(1);
mutate();
}, [value]);
useShallowEffect(() => {
mutate();
}, [pages]);
return (
<Container size="xl" py="xl" w={"100%"}>
<Card
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
}}
>
<Stack gap="md">
<Title order={3} c="gray.2">
List Data Warga
</Title>
<Flex align="center" justify="space-between">
<Input
value={value}
placeholder="Cari warga..."
onChange={(event) => setValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{ display: value ? undefined : "none" }}
/>
}
/>
<Group>
<Text size="sm">{`${pageSize * (pages - 1) + 1} ${Math.min(total, pageSize * pages)} of ${total}`}</Text>
<Pagination total={totalPage} value={pages} onChange={setPages} withPages={false} />
</Group>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>No Telepon</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{
Array.isArray(list) && list?.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3} align="center">Tidak ada data</Table.Td>
</Table.Tr>
) : (
Array.isArray(list) && list?.map((item, i) => (
<Table.Tr key={i}>
<Table.Td>{item.name}</Table.Td>
<Table.Td w={250}>{item.phone}</Table.Td>
<Table.Td w={150}>
<Button
variant="outline"
onClick={() => {
navigate(
`/scr/dashboard/warga/detail-warga?id=${item.id}`,
);
}}
>
Detail
</Button>
</Table.Td>
</Table.Tr>
))
)
}
</Table.Tbody>
</Table>
</Stack>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,24 @@
import { prisma } from "./prisma"
export async function createSurat({ idPengajuan, idCategory, idWarga, noSurat }: { idPengajuan: string, idCategory: string, idWarga: string, noSurat: string }) {
try {
const surat = await prisma.suratPelayanan.create({
data: {
idPengajuanLayanan: idPengajuan,
idCategory,
idWarga,
noSurat,
}
})
if (!surat.id) {
return { success: false, message: 'gagal membuat surat' }
}
return { success: true, message: 'surat sudah dibuat' }
} catch (error) {
console.log(error)
return { success: false, message: 'gagal membuat surat' }
}
}

View File

@@ -0,0 +1,25 @@
function getExtension(fileName: string): string | null {
if (!fileName || typeof fileName !== "string") return null;
const parts = fileName.split(".");
if (parts.length <= 1) return null;
return parts.pop()?.toLowerCase() || null;
}
export function detectFileType(fileName: string) {
const ext = getExtension(fileName);
if (!ext) return { ext: null, type: "unknown" };
if (["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) {
return { ext, type: "image" };
}
if (ext === "pdf") {
return { ext, type: "pdf" };
}
return { ext, type: "other" };
}

View File

@@ -0,0 +1,21 @@
export function getLastUpdated(date: string | Date): string {
const now = new Date();
const updated = new Date(date);
const diffMs = now.getTime() - updated.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) return "baru saja";
if (diffMinutes < 60) return `${diffMinutes} menit lalu`;
if (diffHours < 24) return `${diffHours} jam lalu`;
if (diffDays < 7) return `${diffDays} hari lalu`;
// kalau sudah lebih dari seminggu, tampilkan tanggal
return updated.toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
});
}

View File

@@ -1,5 +1,4 @@
import _ from "lodash";
import { v4 as uuidv4 } from "uuid";
interface McpTool {
name: string;
@@ -16,57 +15,70 @@ interface McpTool {
}
/**
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions (without run()).
* Hanya menyertakan endpoint yang memiliki tag berisi "mcp".
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions.
* * @param openApiJson OpenAPI JSON specification object.
* @param filterTag A string or array of strings. Operations must match at least one tag
* (case-insensitive partial match).
*/
export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] {
export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string | string[]): McpTool[] {
const tools: McpTool[] = [];
if (!openApiJson || typeof openApiJson !== "object") {
console.warn("Invalid OpenAPI JSON");
return tools;
}
// Cast filterTag to an array and normalize to lowercase for comparison
const filterTags = _.castArray(filterTag)
.filter(t => typeof t === "string" && t.trim() !== "")
.map(t => t.toLowerCase());
if (filterTags.length === 0) {
console.warn("Filter tag is empty or invalid. Returning all tools with tags.");
}
const paths = openApiJson.paths || {};
if (Object.keys(paths).length === 0) {
console.warn("No paths found in OpenAPI spec");
return tools;
}
for (const [path, methods] of Object.entries(paths)) {
// ✅ skip semua path internal MCP
if (path.startsWith("/mcp")) continue;
if (!path || typeof path !== "string") continue;
if (!methods || typeof methods !== "object") continue;
for (const [method, operation] of Object.entries<any>(methods)) {
const validMethods = ["get", "post", "put", "delete", "patch", "head", "options"];
if (!validMethods.includes(method.toLowerCase())) continue;
if (!operation || typeof operation !== "object") continue;
for (const [method, operation] of Object.entries<any>(methods as any)) {
const tags: string[] = Array.isArray(operation.tags) ? operation.tags : [];
const lowerCaseTags = tags.map(t => typeof t === "string" ? t.toLowerCase() : "");
// ✅ exclude semua yang tidak punya tag atau tag-nya tidak mengandung "mcp"
if (!tags.length || !tags.some(t => t.toLowerCase().includes("mcp"))) continue;
// ✅ MODIFIKASI: Pengecekan filterTags
if (filterTags.length > 0) {
const isTagMatch = lowerCaseTags.some(opTag =>
filterTags.some(fTag => opTag.includes(fTag))
);
if (!isTagMatch) continue;
} else if (tags.length === 0) {
// Jika tidak ada filter, hanya proses operation yang memiliki tags
continue;
}
const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool";
const name = cleanToolName(rawName);
const description =
operation.description ||
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
const schema =
operation.requestBody?.content?.["application/json"]?.schema || {
type: "object",
properties: {},
additionalProperties: true,
};
const tool: McpTool = {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
},
inputSchema: {
...schema,
additionalProperties: true,
$schema: "http://json-schema.org/draft-07/schema#",
},
};
tools.push(tool);
try {
const tool = createToolFromOperation(path, method, operation, tags);
if (tool) {
tools.push(tool);
}
} catch (error) {
console.error(`Error creating tool for ${method.toUpperCase()} ${path}:`, error);
continue;
}
}
}
@@ -74,35 +86,321 @@ export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] {
}
/**
* Bersihkan nama agar valid untuk digunakan sebagai tool name
* - hapus karakter spesial
* - ubah slash jadi underscore
* - hilangkan prefix umum (get_, post_, api_, dll)
* - rapikan underscore berganda
* Buat MCP tool dari operation OpenAPI
*/
function cleanToolName(name: string): string {
return name
.replace(/[{}]/g, "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.replace(/^(get|post|put|delete|patch|api)_/i, "")
.replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "")
.replace(/(^_|_$)/g, "");
function createToolFromOperation(
path: string,
method: string,
operation: any,
tags: string[]
): McpTool | null {
try {
const rawName = _.snakeCase(`${operation.operationId}` || `${method}_${path}`) || "unnamed_tool";
const name = _.snakeCase(cleanToolName(operation.summary)) || cleanToolName(rawName);
if (!name || name === "unnamed_tool") {
console.warn(`Invalid tool name for ${method} ${path}`);
return null;
}
let description =
operation.description ||
operation.summary;
description += `\n
Execute ${method.toUpperCase()} ${path}`;
// ✅ Extract schema berdasarkan method
let schema;
if (method.toLowerCase() === "get") {
// ✅ Untuk GET, ambil dari parameters (query/path)
schema = extractParametersSchema(operation.parameters || []);
} else {
// ✅ Untuk POST/PUT/etc, ambil dari requestBody
schema = extractRequestBodySchema(operation);
}
const inputSchema = createInputSchema(schema);
return {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
},
inputSchema,
};
} catch (error) {
console.error(`Failed to create tool from operation:`, error);
return null;
}
}
/**
* Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP
* Extract schema dari parameters (untuk GET requests)
*/
export async function getMcpTools() {
const data = await fetch(`${process.env.BUN_PUBLIC_BASE_URL}/docs/json`);
const openApiJson = await data.json();
const tools = convertOpenApiToMcpTools(openApiJson);
return tools;
function extractParametersSchema(parameters: any[]): any {
if (!Array.isArray(parameters) || parameters.length === 0) {
return null;
}
const properties: any = {};
const required: string[] = [];
for (const param of parameters) {
if (!param || typeof param !== "object") continue;
// ✅ Support path, query, dan header parameters
if (["path", "query", "header"].includes(param.in)) {
const paramName = param.name;
if (!paramName || typeof paramName !== "string") continue;
properties[paramName] = {
type: param.schema?.type || "string",
description: param.description || `${param.in} parameter: ${paramName}`,
};
// ✅ Copy field tambahan dari schema
if (param.schema) {
const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"];
for (const field of allowedFields) {
if (param.schema[field] !== undefined) {
properties[paramName][field] = param.schema[field];
}
}
}
if (param.required === true) {
required.push(paramName);
}
}
}
if (Object.keys(properties).length === 0) {
return null;
}
return {
type: "object",
properties,
required,
};
}
// === CLI Mode ===
if (import.meta.main) {
const tools = await getMcpTools();
await Bun.write("./tools.json", JSON.stringify(tools, null, 2));
/**
* Extract schema dari requestBody (untuk POST/PUT/etc requests)
*/
function extractRequestBodySchema(operation: any): any {
if (!operation.requestBody?.content) {
return null;
}
const content = operation.requestBody.content;
const contentTypes = [
"application/json",
"multipart/form-data",
"application/x-www-form-urlencoded",
"text/plain",
];
for (const contentType of contentTypes) {
if (content[contentType]?.schema) {
return content[contentType].schema;
}
}
for (const [_, value] of Object.entries<any>(content)) {
if (value?.schema) {
return value.schema;
}
}
return null;
}
/**
* Buat input schema yang valid untuk MCP
*/
function createInputSchema(schema: any): any {
const defaultSchema = {
type: "object",
properties: {},
additionalProperties: false,
};
if (!schema || typeof schema !== "object") {
return defaultSchema;
}
try {
const properties: any = {};
const required: string[] = [];
const originalRequired = Array.isArray(schema.required) ? schema.required : [];
if (schema.properties && typeof schema.properties === "object") {
for (const [key, prop] of Object.entries<any>(schema.properties)) {
if (!key || typeof key !== "string") continue;
try {
const cleanProp = cleanProperty(prop);
if (cleanProp) {
properties[key] = cleanProp;
// ✅ PERBAIKAN: Check optional flag dengan benar
const isOptional = prop?.optional === true || prop?.optional === "true";
const isInRequired = originalRequired.includes(key);
// ✅ Hanya masukkan ke required jika memang required DAN bukan optional
if (isInRequired && !isOptional) {
required.push(key);
}
}
} catch (error) {
console.error(`Error cleaning property ${key}:`, error);
continue;
}
}
}
return {
type: "object",
properties,
required,
additionalProperties: false,
};
} catch (error) {
console.error("Error creating input schema:", error);
return defaultSchema;
}
}
/**
* Bersihkan property dari field custom
*/
function cleanProperty(prop: any): any | null {
if (!prop || typeof prop !== "object") {
return { type: "string" };
}
try {
const cleaned: any = {
type: prop.type || "string",
};
const allowedFields = [
"description",
"examples",
"example",
"default",
"enum",
"pattern",
"minLength",
"maxLength",
"minimum",
"maximum",
"format",
"multipleOf",
"exclusiveMinimum",
"exclusiveMaximum",
];
for (const field of allowedFields) {
if (prop[field] !== undefined && prop[field] !== null) {
cleaned[field] = prop[field];
}
}
if (prop.properties && typeof prop.properties === "object") {
cleaned.properties = {};
for (const [key, value] of Object.entries(prop.properties)) {
const cleanedNested = cleanProperty(value);
if (cleanedNested) {
cleaned.properties[key] = cleanedNested;
}
}
if (Array.isArray(prop.required)) {
cleaned.required = prop.required.filter((r: any) => typeof r === "string");
}
}
if (prop.items) {
cleaned.items = cleanProperty(prop.items);
}
if (Array.isArray(prop.oneOf)) {
cleaned.oneOf = prop.oneOf.map(cleanProperty).filter(Boolean);
}
if (Array.isArray(prop.anyOf)) {
cleaned.anyOf = prop.anyOf.map(cleanProperty).filter(Boolean);
}
if (Array.isArray(prop.allOf)) {
cleaned.allOf = prop.allOf.map(cleanProperty).filter(Boolean);
}
return cleaned;
} catch (error) {
console.error("Error cleaning property:", error);
return null;
}
}
/**
* Bersihkan nama tool
*/
function cleanToolName(name: string): string {
if (!name || typeof name !== "string") {
return "unnamed_tool";
}
try {
return name
.replace(/[{}]/g, "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
// ❗️ METHOD PREFIX TIDAK DIHAPUS LAGI (agar tidak duplicate)
.toLowerCase()
|| "unnamed_tool";
} catch (error) {
console.error("Error cleaning tool name:", error);
return "unnamed_tool";
}
}
/**
* Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP
* * @param url URL of the OpenAPI spec.
* @param filterTag A string or array of strings. Operations must match at least one tag
* (case-insensitive partial match).
*/
export async function getMcpTools(url: string, filterTag: string | string[]): Promise<McpTool[]> {
try {
console.log(`Fetching OpenAPI spec from: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const openApiJson = await response.json();
const tools = convertOpenApiToMcpTools(openApiJson, filterTag);
const filterStr = _.castArray(filterTag).join(", ");
console.log(`✅ Successfully generated ${tools.length} MCP tools for tags: [${filterStr}]`);
return tools;
} catch (error) {
console.error("Error fetching MCP tools:", error);
throw error;
}
}

View File

@@ -0,0 +1,108 @@
import _ from "lodash";
import { v4 as uuidv4 } from "uuid";
interface McpTool {
name: string;
description: string;
inputSchema: any;
"x-props": {
method: string;
path: string;
operationId?: string;
tag?: string;
deprecated?: boolean;
summary?: string;
};
}
/**
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions (without run()).
* Hanya menyertakan endpoint yang memiliki tag berisi "mcp".
*/
export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] {
const tools: McpTool[] = [];
const paths = openApiJson.paths || {};
for (const [path, methods] of Object.entries(paths)) {
// ✅ skip semua path internal MCP
if (path.startsWith("/mcp")) continue;
for (const [method, operation] of Object.entries<any>(methods as any)) {
const tags: string[] = Array.isArray(operation.tags) ? operation.tags : [];
// ✅ exclude semua yang tidak punya tag atau tag-nya tidak mengandung "mcp"
if (!tags.length || !tags.some(t => t.toLowerCase().includes("mcp"))) continue;
const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool";
const name = cleanToolName(rawName);
const description =
operation.description ||
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
const schema =
operation.requestBody?.content?.["application/json"]?.schema || {
type: "object",
properties: {},
additionalProperties: true,
};
const tool: McpTool = {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
},
inputSchema: {
...schema,
additionalProperties: true,
$schema: "http://json-schema.org/draft-07/schema#",
},
};
tools.push(tool);
}
}
return tools;
}
/**
* Bersihkan nama agar valid untuk digunakan sebagai tool name
* - hapus karakter spesial
* - ubah slash jadi underscore
* - hilangkan prefix umum (get_, post_, api_, dll)
* - rapikan underscore berganda
*/
function cleanToolName(name: string): string {
return name
.replace(/[{}]/g, "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.replace(/^(get|post|put|delete|patch|api)_/i, "")
.replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "")
.replace(/(^_|_$)/g, "");
}
/**
* Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP
*/
export async function getMcpTools() {
const data = await fetch(`${process.env.BUN_PUBLIC_BASE_URL}/docs/json`);
const openApiJson = await data.json();
const tools = convertOpenApiToMcpTools(openApiJson);
return tools;
}
// === CLI Mode ===
if (import.meta.main) {
const tools = await getMcpTools();
await Bun.write("./tools.json", JSON.stringify(tools, null, 2));
}

View File

@@ -0,0 +1,42 @@
export function mimeToExtension(mimeType: string): string {
const map: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg",
"image/bmp": "bmp",
"image/tiff": "tiff",
"video/mp4": "mp4",
"video/webm": "webm",
"video/ogg": "ogv",
"video/quicktime": "mov",
"audio/mpeg": "mp3",
"audio/wav": "wav",
"audio/ogg": "ogg",
"audio/webm": "weba",
"application/pdf": "pdf",
"application/zip": "zip",
"application/x-zip-compressed": "zip",
"application/json": "json",
"application/javascript": "js",
"application/x-httpd-php": "php",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"text/plain": "txt",
"text/html": "html",
"text/css": "css",
"text/csv": "csv",
"text/xml": "xml",
};
return map[mimeType.toLowerCase()] || "bin"; // default jika tidak dikenal
}

View File

@@ -1,6 +1,12 @@
export function isValidPhone(number: string): boolean {
const clean = number.replace(/[\s.-]/g, ""); // hapus spasi, titik, strip
const regex = /^(?:\+62|62|0)8\d{7,12}$/;
return regex.test(clean);
}
export function normalizePhoneNumber({ phone }: { phone: string }) {
// Hapus semua spasi, tanda hubung, atau karakter non-digit (+ tetap dipertahankan untuk dicek)
let cleaned = phone.trim().replace(/[\s-]/g, "");
let cleaned = phone.trim().replace(/[\s.-]/g, "");
// Jika diawali dengan +62 → ganti jadi 62
if (cleaned.startsWith("+62")) {

View File

@@ -0,0 +1,12 @@
import { v4 as uuidv4 } from "uuid";
import { mimeToExtension } from "./mimetypeToExtension";
export function renameFile({ oldFile, newName }: { oldFile: File; newName: string }) {
const ext = mimeToExtension(oldFile.type)
const nameFix = newName == 'random' ? `${uuidv4()}.${ext}` : newName
return new File([oldFile], nameFix, {
type: oldFile.type,
lastModified: oldFile.lastModified,
});
}

View File

@@ -94,7 +94,6 @@ export async function fetchWithAuth(config: Config, url: string, options: Reques
} catch {
console.error('🔍 Could not read response body');
}
process.exit(1);
}
return response;
}
@@ -128,44 +127,114 @@ export async function listFiles(config: Config): Promise<{ name: string }[]> {
}
}
export async function catFile(config: Config, fileName: string): Promise<string> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`);
export async function catFile(config: Config, folder: string, fileName: string): Promise<ArrayBuffer> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`);
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
const content = await (await fetchWithAuth(config, downloadUrl)).text();
return content
// Download file sebagai binary, BUKAN text
const fileResponse = await fetchWithAuth(config, downloadUrl);
const buffer = await fileResponse.arrayBuffer();
return buffer;
}
export async function uploadFile(config: Config, file: File): Promise<string> {
const remoteName = path.basename(file.name);
export async function uploadFile(config: Config, file: File, folder: string): Promise<string> {
const remoteName = path.basename(file.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
// 2. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
// 3. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
// 3. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${file.name} successfully`;
if (!res.ok) return 'gagal'
return `✅ Uploaded ${file.name} successfully`;
}
export async function uploadFileBase64(config: Config, base64File: { name: string; data: string; }): Promise<string> {
const remoteName = path.basename(base64File.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Konversi base64 ke Blob
const binary = Buffer.from(base64File.data, "base64");
const blob = new Blob([binary]);
// 3. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
formData.append("file", blob, remoteName);
// 4. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${base64File.name} successfully`;
}
export async function uploadFileToFolder(config: Config, base64File: { name: string; data: string; }, folder: 'syarat-dokumen' | 'pengaduan'): Promise<string> {
const remoteName = path.basename(base64File.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Konversi base64 ke Blob
const binary = Buffer.from(base64File.data, "base64");
const blob = new Blob([binary]);
// 3. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", blob, remoteName);
// 4. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${base64File.name} successfully`;
}
export async function removeFile(config: Config, fileName: string): Promise<string> {
await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`, { method: 'DELETE' });
export async function removeFile(config: Config, fileName: string, folder: string): Promise<string> {
const res = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`, { method: 'DELETE' });
if (!res.ok) return 'gagal menghapus file';
return `🗑️ Removed ${fileName}`
}
@@ -192,49 +261,4 @@ export async function downloadFile(config: Config, remoteFile: string, localFile
export async function getFileLink(config: Config, fileName: string): Promise<string> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`);
return `🔗 Link for ${fileName}:\n${(await downloadUrlResponse.text()).replace(/"/g, '')}`
}
// function showHelp(): void {
// return `note - simple CLI for wibu not
// Usage:
// not3 ls List files
// not3 cat <file> Show file content
// not3 cp <local> [remote] Upload file
// not3 rm <remote> Remove file
// not3 mv <old> <new> Rename/move file
// not3 get <remote> [local] Download file
// not3 link <file> Get file link/URL
// not3 test Test API connection
// not3 config Edit config (~/.note.conf)
// Config (~/.note.conf):
// TOKEN=your_seafile_token
// REPO=repos/<repo-id>
// URL=your_seafile_url/api2
// Version: ${version}`);
// }
// --- Main ---
// async function not3(): Promise<void> {
// const [cmd, ...args] = process.argv.slice(2);
// if (cmd === 'config') return editConfig();
// const config = await loadConfig();
// switch (cmd) {
// case 'test': return testConnection(config);
// case 'ls': return listFiles(config);
// case 'cat': return args[0] ? catFile(config, args[0]) : console.error('Usage: bun note.ts cat <file>');
// case 'cp': return args[0] ? uploadFile(config, args[0], args[1]) : console.error('Usage: bun note.ts cp <local_file> [remote_file]');
// case 'rm': return args[0] ? removeFile(config, args[0]) : console.error('Usage: bun note.ts rm <remote_file>');
// case 'mv': return args[1] ? moveFile(config, args[0]!, args[1]) : console.error('Usage: bun note.ts mv <old_name> <new_name>');
// case 'get': return args[0] ? downloadFile(config, args[0], args[1]) : console.error('Usage: bun note.ts get <remote_file> [local_file]');
// case 'link': return args[0] ? getFileLink(config, args[0]) : console.error('Usage: bun note.ts link <file>');
// default: return showHelp();
// }
// }
// not3().catch((error) => {
// console.error('❌ Error:', error);
// process.exit(1);
// });
}

View File

@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { prisma } from '@/server/lib/prisma'
import { jwt as jwtPlugin, type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia, { t, type Cookie, type HTTPHeaders, type StatusMap } from 'elysia'
import { type ElysiaCookie } from 'elysia/cookies'
import { prisma } from '@/server/lib/prisma'
const secret = process.env.JWT_SECRET
if (!secret) {
@@ -75,6 +75,15 @@ async function login({
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
password: true,
Role: {
select: {
permissions: true
}
}
}
})
if (!user) {
@@ -87,6 +96,12 @@ async function login({
return { message: 'Invalid password' }
}
const rawPermissions = user.Role?.permissions;
const akses = Array.isArray(rawPermissions)
? rawPermissions[0]?.toString()
: undefined;
const token = await issueToken({
jwt,
cookie,
@@ -94,7 +109,7 @@ async function login({
role: 'user',
expiresAt: Math.floor(Date.now() / 1000) + NINETY_YEARS,
})
return { token }
return { token, akses }
} catch (error) {
console.error('Error logging in:', error)
return {
@@ -146,7 +161,7 @@ const Auth = new Elysia({
detail: {
summary: 'logout',
description: 'Logout (clear token cookie)',
},
}
)

View File

@@ -0,0 +1,48 @@
import Elysia, { t } from "elysia";
import { prisma } from "../lib/prisma";
const ConfigurationDesaRoute = new Elysia({
prefix: "configuration-desa",
tags: ["configuration-desa"],
})
.get("/list", async () => {
const data = await prisma.configuration.findMany({
orderBy: {
name: "asc"
}
})
return data
}, {
detail: {
summary: "List Konfigurasi",
description: `tool untuk mendapatkan list konfigurasi`,
}
})
.post("/edit", async ({ body }) => {
const { id, value } = body
await prisma.configuration.update({
where: {
id,
},
data: {
value,
}
})
return { success: true, message: 'konfigurasi sudah diperbarui' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
value: t.String({ minLength: 1, error: "value harus diisi" }),
}),
detail: {
summary: "edit konfigurasi desa",
description: `tool untuk edit konfigurasi desa`
}
})
;
export default ConfigurationDesaRoute

View File

@@ -0,0 +1,278 @@
import Elysia from "elysia";
import { getLastUpdated } from "../lib/get-last-updated";
import { prisma } from "../lib/prisma";
const DashboardRoute = new Elysia({
prefix: "dashboard",
tags: ["dashboard"],
})
.get("/count", async () => {
// ---- RANGE HARI INI ----
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const endOfToday = new Date(now);
endOfToday.setHours(23, 59, 59, 999);
// ---- RANGE KEMARIN ----
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const startOfYesterday = new Date(yesterday);
startOfYesterday.setHours(0, 0, 0, 0);
const endOfYesterday = new Date(yesterday);
endOfYesterday.setHours(23, 59, 59, 999);
// ---- QUERY ----
const dataWarga = await prisma.warga.count();
// Pengaduan
const dataPengaduanToday = await prisma.pengaduan.count({
where: {
isActive: true,
status: "antrian",
createdAt: {
gte: startOfToday,
lte: endOfToday,
},
},
});
const dataPengaduanYesterday = await prisma.pengaduan.count({
where: {
isActive: true,
status: "antrian",
createdAt: {
gte: startOfYesterday,
lte: endOfYesterday,
},
},
});
const kenaikanPengaduan =
dataPengaduanYesterday === 0
? dataPengaduanToday > 0
? dataPengaduanToday * 100
: 0
: ((dataPengaduanToday - dataPengaduanYesterday) / dataPengaduanYesterday) * 100;
// Pelayanan
const dataPelayananToday = await prisma.pelayananAjuan.count({
where: {
isActive: true,
status: "antrian",
createdAt: {
gte: startOfToday,
lte: endOfToday,
},
},
});
const dataPelayananYesterday = await prisma.pelayananAjuan.count({
where: {
isActive: true,
status: "antrian",
createdAt: {
gte: startOfYesterday,
lte: endOfYesterday,
},
},
});
const kenaikanPelayanan =
dataPelayananYesterday === 0
? dataPelayananToday > 0
? dataPelayananToday * 100
: 0
: ((dataPelayananToday - dataPelayananYesterday) / dataPelayananYesterday) * 100;
// ---- FINAL OUTPUT ----
const dataFix = {
warga: dataWarga,
pengaduan: {
today: dataPengaduanToday,
yesterday: dataPengaduanYesterday,
kenaikan: Number(kenaikanPengaduan.toFixed(2)), // dalam persen
},
pelayanan: {
today: dataPelayananToday,
yesterday: dataPelayananYesterday,
kenaikan: Number(kenaikanPelayanan.toFixed(2)), // dalam persen
},
};
return dataFix;
}, {
detail: {
summary: "Dashboard - Menghitung Data",
description: `tool untuk menghitung data pengaduan dan pelayanan yg masuk hari ini dan data warga`,
}
})
.get("/last-update", async () => {
const dataPengaduan = await prisma.pengaduan.findMany({
skip: 0,
take: 5,
orderBy: {
updatedAt: "desc",
},
})
const dataPengaduanFix = dataPengaduan.map((item) => {
return {
noPengaduan: item.noPengaduan,
id: item.id,
title: item.title,
status: item.status,
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
}
})
const dataPelayanan = await prisma.pelayananAjuan.findMany({
skip: 0,
take: 5,
orderBy: {
updatedAt: "desc",
},
select: {
id: true,
status: true,
noPengajuan: true,
updatedAt: true,
CategoryPelayanan: {
select: {
name: true,
}
}
}
})
const dataPelayananFix = dataPelayanan.map((item) => {
return {
id: item.id,
noPengajuan: item.noPengajuan,
title: item.CategoryPelayanan.name,
status: item.status,
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
}
})
const dataFix = {
pengaduan: dataPengaduanFix,
pelayanan: dataPelayananFix,
}
return dataFix;
}, {
detail: {
summary: "Dashboard - List data pengaduan dan pelayanan terupdate",
description: `tool untuk mendapatkan list data pengaduan dan pelayanan yg terupdate`,
}
})
.get("/grafik", async () => {
const now = new Date();
const start7Days = new Date(now);
start7Days.setDate(start7Days.getDate() - 7);
start7Days.setHours(0, 0, 0, 0);
const endToday = new Date(now);
endToday.setHours(23, 59, 59, 999);
// Ambil semua data pengaduan & pelayanan dalam 7 hari
const pengaduan = await prisma.pengaduan.findMany({
where: {
createdAt: {
gte: start7Days,
lte: endToday,
},
isActive: true
},
select: {
createdAt: true
}
});
const pelayanan = await prisma.pelayananAjuan.findMany({
where: {
createdAt: {
gte: start7Days,
lte: endToday,
},
isActive: true
},
select: {
createdAt: true
}
});
// --- BUAT RANGE TANGGAL 7 HARI ---
const resultMap: Record<string, { pengaduan: number; pelayanan: number }> = {};
for (let i = 0; i < 8; i++) {
const d = new Date(start7Days);
d.setDate(d.getDate() + i);
const formatted = d.toLocaleDateString("id-ID", {
day: "numeric",
month: "long"
});
resultMap[formatted] = { pengaduan: 0, pelayanan: 0 };
}
// --- HITUNG PENGADUAN PER HARI ---
pengaduan.forEach((item) => {
const t = item.createdAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "long"
});
if (resultMap[t]) {
resultMap[t].pengaduan += 1;
}
});
// --- HITUNG PELAYANAN PER HARI ---
pelayanan.forEach((item) => {
const t = item.createdAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "long"
});
if (resultMap[t]) {
resultMap[t].pelayanan += 1;
}
});
// --- KONVERSI KE FORMAT FINAL ---
const source = Object.keys(resultMap).map((tanggal) => ({
tanggal,
pengaduan: resultMap[tanggal]?.pengaduan,
pelayanan: resultMap[tanggal]?.pelayanan,
}));
return {
dimensions: ["tanggal", "pengaduan", "pelayanan"],
source,
};
}, {
detail: {
summary: "Dashboard - Grafik data pengaduan dan pelayanan",
description: `tool untuk mendapatkan grafik data pengaduan dan pelayanan`,
}
})
;
;
export default DashboardRoute

View File

@@ -1,243 +1,485 @@
// server/mcpServer.ts
import { Elysia } from "elysia";
import { getMcpTools } from "../lib/mcp_tool_convert";
// import tools from "./../../../tools.json";
var tools = [] as any[];
/**
* Refactored Elysia-based MCP server
* - Fixes inconsistent "text/json" handling by normalizing response extraction
* - Robust executeTool: supports path/query/header/cookie/body params (if provided in x-props)
* - Proper baseUrl/path normalization and URLSearchParams building (repeated keys for arrays)
* - Consistent MCP content conversion: always returns either { type: 'json', data } or { type: 'text', text }
* - Safer error handling, batch support, Promise.allSettled to avoid full failure on single-item error
* - Lightweight in-memory tools cache with explicit init endpoint (keeps original behavior)
*/
// =====================
// MCP Protocol Types
// =====================
/* -------------------------
Environment & Globals
------------------------- */
if (!process.env.BUN_PUBLIC_BASE_URL) {
throw new Error("BUN_PUBLIC_BASE_URL environment variable is not set");
}
const OPENAPI_URL = `${process.env.BUN_PUBLIC_BASE_URL.replace(/\/+$/, "")}/docs/json`;
const FILTER_TAG = "mcp";
let tools: any[] = [];
/* -------------------------
MCP Types
------------------------- */
type JSONRPCRequest = {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: any;
jsonrpc: "2.0";
id: string | number;
method: string;
params?: any;
credentials?: any;
};
type JSONRPCResponse = {
jsonrpc: "2.0";
id: string | number;
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
jsonrpc: "2.0";
id: string | number | null;
result?: any;
error?: { code: number; message: string; data?: any };
};
// =====================
// Tool Executor
// =====================
/* -------------------------
Helpers
------------------------- */
/** Ensure baseUrl doesn't end with slash; ensure path begins with slash */
function joinBasePath(base: string, path: string) {
const normalizedBase = base.replace(/\/+$/, "");
const normalizedPath = path ? (path.startsWith("/") ? path : `/${path}`) : "";
return `${normalizedBase}${normalizedPath}`;
}
/** Serialize query object to repeated-key QS when arrays provided */
function buildQueryString(q: Record<string, any>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(q)) {
if (v === undefined || v === null) continue;
if (Array.isArray(v)) {
for (const item of v) {
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(item))}`);
}
} else if (typeof v === "object") {
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(JSON.stringify(v))}`);
} else {
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
}
return parts.length ? `?${parts.join("&")}` : "";
}
/** Safely extract "useful" payload from a fetch result:
* Prefer resp.data if present, otherwise resp itself.
* If resp is a string, keep as string.
*/
function extractRaw(result: { data: any } | any) {
// If result shaped as { data: ... } prefer inner .data
if (result && typeof result === "object" && "data" in result) {
return result.data;
}
return result;
}
/** Convert various payloads into MCP content shape */
function convertToMcpContent(payload: any) {
if (typeof payload === "string") {
return { type: "text", text: payload };
}
if (payload == null) {
return { type: "text", text: String(payload) };
}
// If payload looks like an image/audio wrapper produced by converter
if (payload?.__mcp_type === "image" && payload.base64) {
return { type: "image", data: payload.base64, mimeType: payload.mimeType || "image/png" };
}
if (payload?.__mcp_type === "audio" && payload.base64) {
return { type: "audio", data: payload.base64, mimeType: payload.mimeType || "audio/mpeg" };
}
// If already an object/array → return JSON
if (typeof payload === "object") {
return { type: "json", data: payload };
}
// Fallback — stringify
try {
return { type: "text", text: JSON.stringify(payload) };
} catch {
return { type: "text", text: String(payload) };
}
}
/* -------------------------
executeTool (robust)
------------------------- */
/**
* Execute a tool converted from OpenAPI -> expected x-props shape:
* x-props may contain:
* - method, path
* - parameters: [{ name, in, required? }]
*
* If x.parameters present, we inspect args and place them accordingly.
*/
export async function executeTool(
tool: any,
args: Record<string, any> = {},
baseUrl: string
tool: any,
args: Record<string, any> = {},
baseUrl: string,
xPayload: Record<string, any> = {}
) {
const x = tool["x-props"] || {};
const x = tool["x-props"] || {};
const method = (x.method || "GET").toUpperCase();
const method = (x.method || "GET").toUpperCase();
const path = x.path || `/${tool.name}`;
const url = `${baseUrl}${path}`;
// Start with provided path (may contain {param})
let path = x.path ?? `/${tool.name}`;
const opts: RequestInit = {
method,
headers: { "Content-Type": "application/json" },
};
// Headers, cookies, query, body collection
const headers: Record<string, any> = {
"Content-Type": "application/json",
...(x.defaultHeaders || {}),
};
const query: Record<string, any> = {};
const cookies: string[] = [];
let bodyPayload: any = undefined;
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
opts.body = JSON.stringify(args || {});
// If parameters described, map args accordingly
if (Array.isArray(x.parameters)) {
for (const p of x.parameters) {
try {
const name: string = p.name;
const value = args?.[name];
// skip undefined unless required — we let API validate required semantics
if (value === undefined) continue;
switch (p.in) {
case "path":
if (path.includes(`{${name}}`)) {
path = path.replace(new RegExp(`{${name}}`, "g"), encodeURIComponent(String(value)));
} else {
// fallback to query
query[name] = value;
}
break;
case "query":
query[name] = value;
break;
case "header":
headers[name] = value;
break;
case "cookie":
cookies.push(`${name}=${value}`);
break;
case "body":
case "requestBody":
bodyPayload = value;
break;
default:
// unknown location -> place into body
bodyPayload = bodyPayload ?? {};
bodyPayload[name] = value;
break;
}
} catch (err) {
// best-effort: skip problematic param
console.warn(`[MCP] Skipping parameter ${String(p?.name)} due to error:`, err);
}
}
} else {
// no param descriptions: assume all args are body
bodyPayload = Object.keys(args || {}).length ? args : undefined;
}
const res = await fetch(url, opts);
const contentType = res.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await res.json()
: await res.text();
if (cookies.length) {
headers["Cookie"] = cookies.join("; ");
}
return {
success: res.ok,
status: res.status,
method,
path,
data,
};
// Build full URL
const urlBase = baseUrl || process.env.BUN_PUBLIC_BASE_URL!;
let url = joinBasePath(urlBase, path);
const qs = buildQueryString(query);
if (qs) url += qs;
// Build RequestInit
const opts: RequestInit & { headers?: Record<string, any> } = { method, headers };
// Body handling for applicable methods
const bodyMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const contentTypeLower = (headers["Content-Type"] || "").toLowerCase();
if (bodyMethods.has(method) && bodyPayload !== undefined) {
// Support tiny formdata marker shape: { __formdata: true, entries: [ [k,v], ... ] }
if (bodyPayload && bodyPayload.__formdata === true && Array.isArray(bodyPayload.entries)) {
const form = new FormData();
for (const [k, v] of bodyPayload.entries) {
form.append(k, v as any);
}
// Let fetch set boundary
delete opts.headers!["Content-Type"];
opts.body = form as any;
} else if (contentTypeLower.includes("application/x-www-form-urlencoded")) {
opts.body = new URLSearchParams(bodyPayload as Record<string, string>).toString();
} else if (contentTypeLower.includes("multipart/form-data")) {
// If caller explicitly requested multipart but didn't pass FormData — convert object to form
const form = new FormData();
if (typeof bodyPayload === "object") {
for (const [k, v] of Object.entries(bodyPayload)) {
form.append(k, (v as any) as any);
}
} else {
form.append("payload", String(bodyPayload));
}
delete opts.headers!["Content-Type"];
opts.body = form as any;
} else {
// Default JSON
opts.body = JSON.stringify(bodyPayload);
}
}
// Execute fetch
console.log(`[MCP] → ${method} ${url}`);
for(const [key, value] of Object.entries(xPayload)) {
opts.headers![key] = value;
}
const res = await fetch(url, opts);
const resContentType = (res.headers.get("content-type") || "").toLowerCase();
let data: any;
try {
if (resContentType.includes("application/json")) {
data = await res.json();
} else {
data = await res.text();
}
} catch (err) {
// fallback to text
try {
data = await res.text();
} catch {
data = null;
}
}
return {
success: res.ok,
status: res.status,
method,
url,
path,
headers: res.headers,
data,
};
}
// =====================
// MCP Handler (Async)
// =====================
async function handleMCPRequestAsync(
request: JSONRPCRequest
): Promise<JSONRPCResponse> {
const { id, method, params } = request;
/* -------------------------
JSON-RPC Handler
------------------------- */
async function handleMCPRequestAsync(request: JSONRPCRequest, xPayload: Record<string, any>): Promise<JSONRPCResponse> {
const { id, method, params } = request;
switch (method) {
case "initialize":
const makeError = (code: number, message: string, data?: any): JSONRPCResponse => ({
jsonrpc: "2.0",
id: id ?? null,
error: { code, message, data },
});
switch (method) {
case "initialize":
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "elysia-mcp-server", version: "1.0.0" },
},
};
case "tools/list":
return {
jsonrpc: "2.0",
id,
result: {
tools: tools.map((t) => {
const inputSchema =
typeof t.inputSchema === "object" && t.inputSchema?.type === "object"
? t.inputSchema
: { type: "object", properties: {}, required: [] };
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "elysia-mcp-server", version: "1.0.0" },
},
name: t.name,
description: t.description || "No description provided",
inputSchema,
"x-props": t["x-props"],
};
}),
},
};
case "tools/list":
return {
jsonrpc: "2.0",
id,
result: {
tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({
name,
description,
inputSchema,
"x-props": x,
})),
},
};
case "tools/call": {
const toolName = params?.name;
const tool = tools.find((t) => t.name === toolName);
if (!tool) return makeError(-32601, `Tool '${toolName}' not found`);
case "tools/call": {
const toolName = params?.name;
const tool = tools.find((t) => t.name === toolName);
try {
const baseUrl = (params?.credentials?.baseUrl as string) || process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000";
const args = params?.arguments || {};
if (!tool) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Tool '${toolName}' not found` },
};
}
const result = await executeTool(tool, args, baseUrl, xPayload);
try {
const baseUrl =
process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000";
const result = await executeTool(tool, params?.arguments || {}, baseUrl);
// Extract the meaningful payload (prefer nested .data if present)
const raw = extractRaw(result.data);
return {
jsonrpc: "2.0",
id,
result: {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
},
};
} catch (error: any) {
return {
jsonrpc: "2.0",
id,
error: { code: -32603, message: error.message },
};
}
}
// Normalize content shape consistently:
const contentItem = convertToMcpContent(raw ?? result.data ?? result);
case "ping":
return { jsonrpc: "2.0", id, result: {} };
default:
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method '${method}' not found` },
};
return {
jsonrpc: "2.0",
id,
result: {
content: [contentItem],
},
};
} catch (err: any) {
// avoid leaking secrets — small debug
const dbg = { message: err?.message };
return makeError(-32603, err?.message ?? "Internal error", dbg);
}
}
case "ping":
return { jsonrpc: "2.0", id, result: {} };
default:
return makeError(-32601, `Method '${method}' not found`);
}
}
// =====================
// Elysia MCP Server
// =====================
export const MCPRoute = new Elysia({
tags: ["MCP Server"]
})
.post("/mcp", async ({ request, set }) => {
if (!tools.length) {
tools = await getMcpTools();
}
set.headers["Content-Type"] = "application/json";
set.headers["Access-Control-Allow-Origin"] = "*";
/* -------------------------
Elysia App & Routes
------------------------- */
export const MCPRoute = new Elysia({ tags: ["MCP Server"] })
.post("/mcp", async ({ request, set, headers }) => {
set.headers["Content-Type"] = "application/json";
set.headers["Access-Control-Allow-Origin"] = "*";
try {
const body = await request.json();
// Lazy load the tools (keeps previous behavior)
if (!tools.length) {
try {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
} catch (err) {
console.error("[MCP] Failed to load tools during lazy init:", err);
}
}
if (!Array.isArray(body)) {
const res = await handleMCPRequestAsync(body);
return res;
}
const xPayload = {
['x-user']: headers['x-user'] || "",
['x-phone']: headers['x-phone'] || ""
}
const results = await Promise.all(
body.map((req) => handleMCPRequestAsync(req))
);
return results;
} catch (error: any) {
set.status = 400;
return {
try {
const body = await request.json();
// If batch array -> allSettled for resilience
if (Array.isArray(body)) {
const promises = body.map((req: JSONRPCRequest) => handleMCPRequestAsync(req, xPayload));
const settled = await Promise.allSettled(promises);
const responses = settled.map((s) =>
s.status === "fulfilled"
? s.value
: ({
jsonrpc: "2.0",
id: null,
error: {
code: -32700,
message: "Parse error",
data: error.message,
code: -32000,
message: "Unhandled handler error",
data: String((s as PromiseRejectedResult).reason),
},
};
}
})
} as JSONRPCResponse)
);
return responses;
}
// Tools list (debug)
.get("/mcp/tools", async ({ set }) => {
if (!tools.length) {
tools = await getMcpTools();
}
set.headers["Access-Control-Allow-Origin"] = "*";
return {
tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({
name,
description,
inputSchema,
"x-props": x,
})),
};
})
const single = await handleMCPRequestAsync(body as JSONRPCRequest, xPayload);
return single;
} catch (err: any) {
set.status = 400;
return {
jsonrpc: "2.0",
id: null,
error: { code: -32700, message: "Parse error", data: err?.message ?? String(err) },
} as JSONRPCResponse;
}
})
// MCP status
.get("/mcp/status", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "active", timestamp: Date.now() };
})
/* Debug / management endpoints */
.get("/mcp/tools", async ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
if (!tools.length) {
try {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
} catch (err) {
console.error("[MCP] Failed to load tools for /mcp/tools:", err);
}
}
return {
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
"x-props": t["x-props"],
})),
};
})
// Health check
.get("/health", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "ok", timestamp: Date.now(), tools: tools.length };
})
.get("/mcp/init", async ({ set }) => {
.get("/mcp/status", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "active", timestamp: Date.now() };
})
const _tools = await getMcpTools();
tools = _tools;
return {
success: true,
message: "MCP initialized",
tools: tools.length,
};
})
.get("/health", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "ok", timestamp: Date.now(), tools: tools.length };
})
// CORS
.options("/mcp", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS";
set.headers["Access-Control-Allow-Headers"] =
"Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
})
.options("/mcp/tools", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS";
set.headers["Access-Control-Allow-Headers"] =
"Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
});
// Force re-init (useful for admin / CI)
.get("/mcp/init", async ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
try {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
return { success: true, message: "MCP initialized", tools: tools.length };
} catch (err) {
set.status = 500;
return { success: false, message: "Failed to initialize tools", error: String(err) };
}
})
/* CORS preflight */
.options("/mcp", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS";
set.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
})
.options("/mcp/tools", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS";
set.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
});
/* -------------------------
End
------------------------- */

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
import Elysia, { t } from "elysia";
import { prisma } from "../lib/prisma";
const SuratRoute = new Elysia({
prefix: "surat",
tags: ["surat"],
})
.get("/detail", async ({ query }) => {
const { id } = query
const dataSurat = await prisma.suratPelayanan.findUnique({
where: {
id
},
select: {
id: true,
noSurat: true,
idCategory: true,
createdAt: true,
PelayananAjuan: {
select: {
DataTextPelayanan: true,
}
},
CategoryPelayanan: {
select: {
name: true,
}
}
}
})
const dataSetting = await prisma.configuration.findMany()
const toObject = (arr: any[]) =>
dataSetting.reduce((acc: any, item: any) => {
acc[item.id] = item.value;
return acc;
}, {});
return {
surat: {
id: dataSurat?.id,
idCategory: dataSurat?.idCategory,
nameCategory: dataSurat?.CategoryPelayanan?.name,
noSurat: dataSurat?.noSurat,
dataText: dataSurat?.PelayananAjuan?.DataTextPelayanan,
createdAt: dataSurat?.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),
},
setting: toObject(dataSetting)
}
}, {
query: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" })
}),
detail: {
summary: "Detail Surat",
description: `tool untuk mendapatkan detail surat`,
}
})
;
export default SuratRoute

View File

@@ -0,0 +1,126 @@
import Elysia, { t } from "elysia";
import { prisma } from "../lib/prisma";
import { generateNoPengaduan } from "../lib/no-pengaduan";
import { normalizePhoneNumber } from "../lib/normalizePhone";
const TestPengaduanRoute = new Elysia({
prefix: "online-pengaduan",
tags: ["test"]
})
.get("/category", async () => {
const data = await prisma.categoryPengaduan.findMany({
where: {
isActive: true
},
orderBy: {
name: "asc"
},
select: {
id: true,
name: true,
}
})
return { data }
}, {
detail: {
summary: "List Kategori Pengaduan",
description: `tool untuk mendapatkan list kategori pengaduan`,
tags: ["test"]
}
})
.post("/create", async ({ body }) => {
const { judulPengaduan, detailPengaduan, lokasi, kategoriId, noTelepon, image } = body
const noPengaduan = await generateNoPengaduan()
let idCategoryFix = kategoriId
if (idCategoryFix) {
const category = await prisma.categoryPengaduan.findUnique({
where: {
id: idCategoryFix,
}
})
if (!category) {
const cariCategory = await prisma.categoryPengaduan.findFirst({
where: {
name: kategoriId,
}
})
if (!cariCategory) {
idCategoryFix = "lainnya"
} else {
idCategoryFix = cariCategory.id
}
}
} else {
idCategoryFix = "lainnya"
}
const nomorHP = normalizePhoneNumber({ phone: "089697338821" })
const cariWarga = await prisma.warga.upsert({
where: {
phone: nomorHP,
},
create: {
name: "malik",
phone: nomorHP,
},
update: {
name: "malik",
phone: nomorHP,
},
})
const pengaduan = await prisma.pengaduan.create({
data: {
title: judulPengaduan,
detail: detailPengaduan,
idCategory: idCategoryFix,
idWarga: cariWarga.id,
location: lokasi,
image: body.image || "",
noPengaduan,
},
select: {
id: true,
}
})
if (!pengaduan.id) {
return { success: false, message: 'gagal membuat pengaduan' }
}
await prisma.historyPengaduan.create({
data: {
idPengaduan: pengaduan.id,
deskripsi: "Pengaduan dibuat",
}
})
return { success: true, message: 'pengaduan sudah dibuat dengan nomer ' + noPengaduan + ', nomer ini akan digunakan untuk mengakses pengaduan ini' }
}, {
body: t.Object({
judulPengaduan: t.String(),
detailPengaduan: t.String(),
lokasi: t.String(),
kategoriId: t.String(),
noTelepon: t.Optional(t.String()),
image: t.Optional(t.String()),
}),
detail: {
summary: "Buat Pengaduan Warga",
description: `
Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga.`
}
})
export default TestPengaduanRoute

View File

@@ -1,15 +1,22 @@
import Elysia, { t } from "elysia";
import type { User } from "generated/prisma";
import _ from "lodash";
import { prisma } from "../lib/prisma";
const UserRoute = new Elysia({
prefix: "user",
tags: ["user"],
})
.get('/find', (ctx) => {
.get('/find', async (ctx) => {
const { user } = ctx as any
const permissions = await prisma.role.findFirst({
where: { id: user?.roleId },
select: { permissions: true }
});
return {
user: user as User
user: user as User,
permissions: permissions?.permissions || []
}
}, {
detail: {
@@ -47,5 +54,243 @@ const UserRoute = new Elysia({
description: "upsert user",
}
})
.post("/update-password", async ({ body }) => {
const { password, id } = body
const update = await prisma.user.update({
where: {
id
},
data: {
password
}
})
return {
success: true,
message: "Password updated successfully",
}
}, {
body: t.Object({
password: t.String({ minLength: 1, error: "password is required" }),
id: t.String({ minLength: 1, error: "id is required" })
}),
detail: {
summary: "update password",
description: "update password user",
}
})
.post("/update", async ({ body }) => {
const { name, phone, id, roleId } = body
const update = await prisma.user.update({
where: {
id
},
data: {
name,
phone,
roleId
}
})
return {
success: true,
message: "User updated successfully",
}
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name is required" }),
phone: t.String({ minLength: 1, error: "phone is required" }),
id: t.String({ minLength: 1, error: "id is required" }),
roleId: t.String({ minLength: 1, error: "roleId is required" })
}),
detail: {
summary: "update",
description: "update user",
}
})
.post("/create", async ({ body }) => {
const { name, phone, roleId, email, password } = body
const create = await prisma.user.create({
data: {
name,
phone,
roleId,
email,
password
}
})
return {
success: true,
message: "User created successfully",
}
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name is required" }),
phone: t.String({ minLength: 1, error: "phone is required" }),
roleId: t.String({ minLength: 1, error: "roleId is required" }),
email: t.String({ minLength: 1, error: "email is required" }),
password: t.String({ minLength: 1, error: "password is required" })
}),
detail: {
summary: "create",
description: "create user",
}
})
.get("/list", async (ctx) => {
const { user } = ctx as any
const data = await prisma.user.findMany({
where: {
isActive: true,
NOT: {
id: user.id
}
},
select: {
id: true,
name: true,
phone: true,
email: true,
roleId: true,
Role: {
select: {
name: true
}
}
}
})
const dataFix = data.map((item: any) => ({
..._.omit(item, ["Role"]),
nameRole: item.Role?.name,
name: String(item.name),
phone: String(item.phone),
email: String(item.email),
roleId: String(item.roleId),
}))
return dataFix
}, {
detail: {
summary: "list",
description: "list user",
}
})
.get("/role", async () => {
const data = await prisma.role.findMany({
where: {
isActive: true
},
orderBy: {
name: "asc"
}
})
return data
}, {
detail: {
summary: "role",
description: "role user",
}
})
.post("/delete", async ({ body }) => {
const { id } = body
const deleteData = await prisma.user.update({
where: {
id
},
data: {
isActive: false
}
})
return {
success: true,
message: "User deleted successfully",
}
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id is required" })
}),
detail: {
summary: "delete",
description: "delete user",
}
})
.post("role-create", async ({ body }) => {
const { name, permissions } = body;
const create = await prisma.role.create({
data: {
name,
permissions: permissions
}
});
return {
success: true,
message: "Role created successfully",
};
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name is required" }),
permissions: t.Any(),
}),
detail: {
summary: "create-role",
description: "create role",
}
})
.post("/role-update", async ({ body }) => {
const { id, name, permissions } = body;
const update = await prisma.role.update({
where: {
id
},
data: {
name,
permissions
}
});
return {
success: true,
message: "User role updated successfully",
};
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id is required" }),
name: t.String({ minLength: 1, error: "name is required" }),
permissions: t.Any()
}),
detail: {
summary: "update-role",
description: "update role",
}
})
.post("role-delete", async ({ body }) => {
const { id } = body;
await prisma.role.update({
where: {
id
},
data: {
isActive: false
}
});
return {
success: true,
message: "Role deleted successfully",
};
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id is required" })
}),
detail: {
summary: "delete-role",
description: "delete role",
}
})
;
export default UserRoute

View File

@@ -0,0 +1,171 @@
import Elysia, { t } from "elysia";
import _ from "lodash";
import { normalizePhoneNumber } from "../lib/normalizePhone";
import { prisma } from "../lib/prisma";
const WargaRoute = new Elysia({
prefix: "warga",
tags: ["warga"],
})
.get("/list", async ({ query }) => {
const { search, page = 1 } = query
const dataSkip = page == null || page == undefined ? 0 : Number(page) * 10 - 10;
const totalData = await prisma.warga.count({
where: {
OR: [
{
name: {
contains: search,
mode: "insensitive"
}
},
{
phone: {
contains: search,
mode: "insensitive"
}
}
]
}
});
const data = await prisma.warga.findMany({
skip: dataSkip,
take: 10,
where: {
OR: [
{
name: {
contains: search,
mode: "insensitive"
}
},
{
phone: {
contains: search,
mode: "insensitive"
}
}
]
},
orderBy: {
name: "asc"
}
})
const dataFix = {
data,
total: totalData,
page: Number(page) || 1,
pageSize: 10,
totalPages: Math.ceil(totalData / 10)
};
return dataFix
}, {
detail: {
summary: "List Warga",
description: `tool untuk mendapatkan list warga`,
}
})
.post("/edit", async ({ body }) => {
const { id, name, phone } = body
const nomorHP = normalizePhoneNumber({ phone })
await prisma.warga.update({
where: {
id,
},
data: {
name,
phone: nomorHP
}
})
return { success: true, message: 'data warga sudah diperbarui' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
name: t.String({ minLength: 1, error: "value harus diisi" }),
phone: t.String({ minLength: 1 })
}),
detail: {
summary: "Edit Warga",
description: `tool untuk edit warga`
}
})
.get("/detail", async ({ query }) => {
const { id } = query
const dataWarga = await prisma.warga.findUnique({
where: {
id
}
})
const dataPengaduan = await prisma.pengaduan.findMany({
orderBy: {
createdAt: "desc"
},
where: {
isActive: true,
idWarga: id
},
select: {
id: true,
status: true,
noPengaduan: true,
title: true
}
})
const dataPelayanan = await prisma.pelayananAjuan.findMany({
orderBy: {
createdAt: "desc"
},
where: {
isActive: true,
idWarga: id
},
select: {
id: true,
noPengajuan: true,
status: true,
CategoryPelayanan: {
select: {
name: true
}
}
}
})
const dataPelayanFix = dataPelayanan.map((v: any) => ({
..._.omit(v, ["CategoryPelayanan"]),
id: v.id,
noPengaduan: v.noPengajuan,
status: v.status,
category: v.CategoryPelayanan.name
}))
return {
warga: dataWarga,
pengaduan: dataPengaduan,
pelayanan: dataPelayanFix
}
}, {
query: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" })
}),
detail: {
summary: "Detail Warga",
description: `tool untuk mendapatkan detail warga`,
}
})
;
export default WargaRoute

5
upload_base64.sh Normal file
View File

@@ -0,0 +1,5 @@
IMAGE_BASE64=$(base64 image.png | tr -d '\n')
curl -X POST http://localhost:3000/api/pengaduan/upload-base64 \
-H "Content-Type: application/json" \
-d "{\"file\": \"$IMAGE_BASE64\"}"

154
x.ts
View File

@@ -1,133 +1,39 @@
/**
* src/utils/swagger-to-mcp.ts
*
* Auto-converter: Swagger (OpenAPI) → MCP manifest (real-time)
*
* - Fetch swagger JSON dynamically from process.env.BUN_PUBLIC_BASE_URL + "/docs/json"
* - Generate MCP manifest for AI discovery (/.well-known/mcp.json)
* - Can be used as Bun CLI or integrated in Elysia route
*/
import fs from "fs";
import { writeFileSync } from "fs"
// 1⃣ File yang mau diupload
const filePath = "image.png";
const apiUrl = "http://localhost:3000/api/pengaduan/upload-base64";
interface OpenAPI {
info: { title?: string; description?: string; version?: string }
paths: Record<string, any>
}
// 2⃣ Baca file dan ubah ke base64
const fileBuffer = fs.readFileSync(filePath);
const base64Data = fileBuffer.toString("base64");
interface McpManifest {
schema_version: string
name: string
description: string
version?: string
endpoints: Record<string, string>
capabilities: Record<string, any>
contact?: { email?: string }
}
// 3⃣ Buat payload JSON
const payload = {
data: base64Data,
mimetype: "image/png"
};
/**
* Convert OpenAPI JSON to MCP manifest format
*/
async function convertOpenApiToMcp(baseUrl: string): Promise<McpManifest> {
const res = await fetch(`${baseUrl}/docs/json`)
if (!res.ok) throw new Error(`Failed to fetch Swagger JSON from ${baseUrl}/docs/json`)
// 4⃣ Kirim ke server pakai fetch
async function uploadBase64() {
try {
const res = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const openapi: OpenAPI = await res.json()
const manifest: McpManifest = {
schema_version: "1.0",
name: openapi.info?.title ?? "MCP Server",
description: openapi.info?.description ?? "Auto-generated MCP manifest from Swagger",
version: openapi.info?.version ?? "0.0.0",
endpoints: {
openapi: `${baseUrl}/docs/json`,
mcp: `${baseUrl}/.well-known/mcp.json`
},
capabilities: {}
if (!res.ok) {
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
for (const [path, methods] of Object.entries(openapi.paths || {})) {
for (const [method, def] of Object.entries<any>(methods)) {
const tags = def.tags || ["default"]
const tag = tags[0]
const operationId = def.operationId || `${method}_${path.replace(/[\/{}]/g, "_")}`
manifest.capabilities[tag] ??= {}
// Extract parameters and body schema
const params: Record<string, string> = {}
const required: string[] = []
if (Array.isArray(def.parameters)) {
for (const p of def.parameters) {
const type = p.schema?.type || "string"
params[p.name] = type
if (p.required) required.push(p.name)
}
}
const bodySchema = def.requestBody?.content?.["application/json"]?.schema
if (bodySchema?.properties) {
for (const [key, prop] of Object.entries<any>(bodySchema.properties)) {
params[key] = prop.type || "string"
}
if (Array.isArray(bodySchema.required))
required.push(...bodySchema.required)
}
// Generate example cURL
const sampleCurl = [
`curl -X ${method.toUpperCase()} ${baseUrl}${path}`,
Object.keys(params).length > 0
? ` -H 'Content-Type: application/json' -d '${JSON.stringify(
Object.fromEntries(Object.keys(params).map(k => [k, params[k] === "string" ? k : "value"]))
)}'`
: ""
]
.filter(Boolean)
.join(" \\\n")
manifest.capabilities[tag][operationId] = {
method: method.toUpperCase(),
path,
summary: def.summary || def.description || "",
parameters: Object.keys(params).length > 0 ? params : undefined,
required: required.length > 0 ? required : undefined,
command: sampleCurl
}
}
}
return manifest
const result = await res.json();
console.log("✅ Upload sukses:", result);
} catch (err) {
console.error("❌ Upload gagal:", err);
}
}
/**
* CLI entry
* bun run src/utils/swagger-to-mcp.ts
*/
if (import.meta.main) {
const baseUrl = process.env.BUN_PUBLIC_BASE_URL
if (!baseUrl) {
console.error("❌ Missing BUN_PUBLIC_BASE_URL environment variable.")
process.exit(1)
}
convertOpenApiToMcp(baseUrl)
.then(manifest => {
writeFileSync(".well-known/mcp.json", JSON.stringify(manifest, null, 2))
console.log("✅ Generated .well-known/mcp.json")
})
.catch(err => console.error("❌ Failed to convert Swagger → MCP:", err))
}
/**
* Optional: Elysia integration
* Automatically serve /.well-known/mcp.json
*/
// import Elysia from "elysia"
// new Elysia()
// .get("/.well-known/mcp.json", async () => {
// const baseUrl = process.env.BUN_PUBLIC_BASE_URL!
// return await convertOpenApiToMcp(baseUrl)
// })
// .listen(3000)
uploadBase64();

108
xx.ts
View File

@@ -1,65 +1,45 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Elysia } from 'elysia'
import jwt, { type JWTPayloadSpec } from '@elysiajs/jwt'
import bearer from '@elysiajs/bearer'
import { prisma } from '../lib/prisma'
// =========================================================
// JWT Secret Validation
// =========================================================
const secret = process.env.JWT_SECRET
if (!secret) throw new Error('JWT_SECRET environment variable is missing')
// =========================================================
// Auth Middleware Plugin
// =========================================================
export default function apiAuth(app: Elysia) {
if (!secret) throw new Error('JWT_SECRET environment variable is missing')
return app
// Register Bearer and JWT plugins
.use(bearer()) // ✅ Extracts Bearer token automatically (case-insensitive)
.use(
jwt({
name: 'jwt',
secret,
})
)
// Derive user from JWT or cookie
.derive(async ({ bearer, cookie, jwt }) => {
// Normalize token type to string or undefined
const token =
(typeof bearer === 'string' ? bearer : undefined) ??
(typeof cookie?.token?.value === 'string' ? cookie.token.value : undefined)
let user: Awaited<ReturnType<typeof prisma.user.findUnique>> | null = null
if (token) {
try {
const decoded = (await jwt.verify(token)) as JWTPayloadSpec
if (decoded?.sub && typeof decoded.sub === 'string') {
user = await prisma.user.findUnique({
where: { id: decoded.sub },
})
}
} catch (err) {
console.warn('[SERVER][apiAuth] Invalid token:', (err as Error).message)
}
}
return { user }
})
// Protect all routes by default
.onBeforeHandle(({ user, set, request }) => {
// Whitelist public routes if needed
const publicPaths = ['/auth/login', '/auth/register', '/public']
if (publicPaths.some((path) => request.url.includes(path))) return
if (!user) {
set.status = 401
return { error: 'Unauthorized' }
}
})
{
"response": [
{
"type": "json",
"data": {
"success": true,
"status": 200,
"method": "GET",
"path": "/api/pengaduan/category",
"data": {
"data": [
{
"id": "infrastruktur",
"name": "Infrastruktur"
},
{
"id": "cmhslcvcy0000mg0810l7zx8x",
"name": "keamanan"
},
{
"id": "keamanan",
"name": "Keamanan"
},
{
"id": "kebersihan",
"name": "Kebersihan"
},
{
"id": "lainnya",
"name": "Lainnya"
},
{
"id": "pelayanan",
"name": "Pelayanan"
},
{
"id": "cmhsl5ijj0000mg08pru6kom4",
"name": "sampah"
}
]
}
}
}
]
}