Compare commits

...

34 Commits

Author SHA1 Message Date
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
47 changed files with 4353 additions and 671 deletions

View File

@@ -22,6 +22,8 @@
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"add": "^2.0.6", "add": "^2.0.6",
"elysia": "^1.4.15", "elysia": "^1.4.15",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^19.2.0", "react": "^19.2.0",
@@ -139,10 +141,16 @@
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], "@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": ["@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/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=="], "@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=="], "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
@@ -175,6 +183,8 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "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=="], "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=="], "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 +207,8 @@
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "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=="], "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=="], "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 +243,7 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "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=="], "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
@@ -239,6 +251,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=="], "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=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -265,6 +279,8 @@
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "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=="], "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=="], "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=="],
@@ -323,6 +339,8 @@
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "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=="], "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=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -379,6 +397,8 @@
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "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-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=="], "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 +415,8 @@
"inquirer-promise": ["inquirer-promise@0.0.3", "", { "dependencies": { "earlgrey-runtime": ">=0.0.11", "inquirer": "^0.11.3" } }, "sha512-82CQX586JAV9GAgU9yXZsMDs+NorjA0nLhkfFx9+PReyOnuoHRbHrC1Z90sS95bFJI1Tm1gzMObuE0HabzkJpg=="], "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=="], "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=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "^1.0.0" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
@@ -423,6 +445,8 @@
"jsonfile": ["jsonfile@2.4.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw=="], "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=="], "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=="], "jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
@@ -487,6 +511,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=="], "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=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
@@ -539,6 +565,8 @@
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "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=="], "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=="], "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 +599,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=="], "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=="], "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 +609,8 @@
"restore-cursor": ["restore-cursor@1.0.1", "", { "dependencies": { "exit-hook": "^1.0.0", "onetime": "^1.0.0" } }, "sha512-reSjH4HuiFlxlaBaFCiS6O76ZGG2ygKoSlCsipKdaZuKSPx/+bt9mULkn4l0asVzbEfQQmXRg6Wp6gv6m0wElw=="], "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=="], "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=="], "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=="],
@@ -619,6 +649,8 @@
"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=="], "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=="], "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=="], "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 +663,14 @@
"supports-color": ["supports-color@2.0.0", "", {}, "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g=="], "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=="], "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=="], "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": ["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=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -687,6 +723,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "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=="], "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=="], "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=="],
@@ -711,6 +749,10 @@
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "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=="], "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=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],

View File

@@ -29,6 +29,8 @@
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"add": "^2.0.6", "add": "^2.0.6",
"elysia": "^1.4.15", "elysia": "^1.4.15",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^19.2.0", "react": "^19.2.0",

View File

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

View File

@@ -1,5 +1,6 @@
import { categoryPelayananSurat } from "@/lib/categoryPelayananSurat"; import { categoryPelayananSurat } from "@/lib/categoryPelayananSurat";
import { confDesa } from "@/lib/configurationDesa"; import { confDesa } from "@/lib/configurationDesa";
import permissionConfig from "@/lib/listPermission.json"; // JSON yang kita buat
import { prisma } from "@/server/lib/prisma"; import { prisma } from "@/server/lib/prisma";
const category = [ const category = [
@@ -29,14 +30,6 @@ const role = [
{ {
id: "developer", id: "developer",
name: "developer" name: "developer"
},
{
id: "admin",
name: "admin"
},
{
id: "pelaksana",
name: "pelaksana"
} }
] ]
@@ -51,11 +44,30 @@ const user = [
]; ];
(async () => { (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) { for (const r of role) {
await prisma.role.upsert({ await prisma.role.upsert({
where: { id: r.id }, where: { id: r.id },
create: r, create: {
update: r 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`) console.log(`✅ Role ${r.name} seeded successfully`)

View File

@@ -1,8 +1,10 @@
import apiFetch from "@/lib/apiFetch"; import apiFetch from "@/lib/apiFetch";
import { import {
ActionIcon, ActionIcon,
Anchor,
Button, Button,
Divider, Divider,
FileInput,
Flex, Flex,
Group, Group,
Input, Input,
@@ -10,18 +12,24 @@ import {
Stack, Stack,
Table, Table,
Title, Title,
Tooltip, Tooltip
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks"; import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useState } from "react"; import { useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import ModalFile from "./ModalFile";
import notification from "./notificationGlobal"; import notification from "./notificationGlobal";
export default function DesaSetting() { export default function DesaSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(false); const [btnDisable, setBtnDisable] = useState(false);
const [btnLoading, setBtnLoading] = useState(false); const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(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("/", () => const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api["configuration-desa"].list.get(), apiFetch.api["configuration-desa"].list.get(),
); );
@@ -39,7 +47,32 @@ export default function DesaSetting() {
async function handleEdit() { async function handleEdit() {
try { try {
setBtnLoading(true); setBtnLoading(true);
const res = await apiFetch.api["configuration-desa"].edit.post(dataEdit);
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) { if (res.status === 200) {
mutate(); mutate();
close(); close();
@@ -67,11 +100,8 @@ export default function DesaSetting() {
} }
} }
function chooseEdit({
data, function chooseEdit({ data }: { data: { id: string; value: string; name: string }; }) {
}: {
data: { id: string; value: string; name: string };
}) {
setDataEdit(data); setDataEdit(data);
open(); open();
} }
@@ -100,18 +130,35 @@ export default function DesaSetting() {
opened={opened} opened={opened}
onClose={close} onClose={close}
title={"Edit"} title={"Edit"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }} overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
> >
<Stack gap="ld"> <Stack gap="ld">
<Input.Wrapper label={dataEdit.name}> {
<Input dataEdit.name == "TTD"
value={dataEdit.value} ?
onChange={(e) => (
onValidation({ kat: "value", value: e.target.value }) <Input.Wrapper label={dataEdit.name}>
} <FileInput
/> clearable
</Input.Wrapper> 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> <Group justify="center" grow>
<Button variant="light" onClick={close}> <Button variant="light" onClick={close}>
Batal Batal
@@ -119,7 +166,7 @@ export default function DesaSetting() {
<Button <Button
variant="filled" variant="filled"
onClick={handleEdit} onClick={handleEdit}
disabled={btnDisable} disabled={btnDisable || (dataEdit.name == "TTD" && !img)}
loading={btnLoading} loading={btnLoading}
> >
Simpan Simpan
@@ -127,6 +174,14 @@ export default function DesaSetting() {
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
<ModalFile
open={openedPreview && !_.isEmpty(viewImg)}
onClose={() => setOpenedPreview(false)}
folder="lainnya"
fileName={viewImg}
/>
<Stack gap={"md"}> <Stack gap={"md"}>
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Title order={4} c="gray.2"> <Title order={4} c="gray.2">
@@ -147,14 +202,25 @@ export default function DesaSetting() {
{list?.map((v: any) => ( {list?.map((v: any) => (
<Table.Tr key={v.id}> <Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td> <Table.Td>{v.name}</Table.Td>
<Table.Td>{v.value}</Table.Td>
<Table.Td> <Table.Td>
<Tooltip label="Edit Setting"> {
v.name == "TTD"
?
<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 <ActionIcon
variant="light" variant="light"
size="sm" size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }} style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })} onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes("setting.desa.edit")}
> >
<IconEdit size={20} /> <IconEdit size={20} />
</ActionIcon> </ActionIcon>

View File

@@ -18,11 +18,12 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks"; import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconEye, IconPlus, IconTrash } from "@tabler/icons-react"; import { IconEdit, IconEye, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react"; import { useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import notification from "./notificationGlobal"; import notification from "./notificationGlobal";
export default function KategoriPelayananSurat() { export default function KategoriPelayananSurat({ permissions }: { permissions: JsonValue[] }) {
const [openedDelete, { open: openDelete, close: closeDelete }] = const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false); useDisclosure(false);
const [openedDetail, { open: openDetail, close: closeDetail }] = const [openedDetail, { open: openDetail, close: closeDetail }] =
@@ -52,6 +53,7 @@ export default function KategoriPelayananSurat() {
mutate(); mutate();
}, []); }, []);
async function handleCreate() { async function handleCreate() {
try { try {
setBtnLoading(true); setBtnLoading(true);
@@ -533,15 +535,19 @@ export default function KategoriPelayananSurat() {
<Title order={4} c="gray.2"> <Title order={4} c="gray.2">
Kategori Pelayanan Surat Kategori Pelayanan Surat
</Title> </Title>
<Tooltip label="Tambah Kategori Pelayanan Surat"> {
<Button permissions.includes("setting.kategori_pelayanan.tambah") && (
variant="light" <Tooltip label="Tambah Kategori Pelayanan Surat">
leftSection={<IconPlus size={20} />} <Button
onClick={openTambah} variant="light"
> leftSection={<IconPlus size={20} />}
Tambah onClick={openTambah}
</Button> >
</Tooltip> Tambah
</Button>
</Tooltip>
)
}
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />
<Stack gap={"md"}> <Stack gap={"md"}>
@@ -572,7 +578,7 @@ export default function KategoriPelayananSurat() {
<IconEye size={20} /> <IconEye size={20} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Edit Kategori"> <Tooltip label={permissions.includes("setting.kategori_pelayanan.edit") ? "Edit Kategori" : "Edit Kategori - Anda tidak memiliki akses"}>
<ActionIcon <ActionIcon
variant="light" variant="light"
size="sm" size="sm"
@@ -581,11 +587,12 @@ export default function KategoriPelayananSurat() {
setDataChoose(v); setDataChoose(v);
open(); open();
}} }}
disabled={!permissions.includes("setting.kategori_pelayanan.edit")}
> >
<IconEdit size={20} /> <IconEdit size={20} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Delete Kategori"> <Tooltip label={permissions.includes("setting.kategori_pelayanan.delete") ? "Hapus Kategori" : "Hapus Kategori - Anda tidak memiliki akses"}>
<ActionIcon <ActionIcon
variant="light" variant="light"
size="sm" size="sm"
@@ -595,6 +602,7 @@ export default function KategoriPelayananSurat() {
setDataDelete(v.id); setDataDelete(v.id);
openDelete(); openDelete();
}} }}
disabled={!permissions.includes("setting.kategori_pelayanan.delete")}
> >
<IconTrash size={20} /> <IconTrash size={20} />
</ActionIcon> </ActionIcon>

View File

@@ -15,11 +15,12 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks"; import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react"; import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react"; import { useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import notification from "./notificationGlobal"; import notification from "./notificationGlobal";
export default function KategoriPengaduan() { export default function KategoriPengaduan({ permissions }: { permissions: JsonValue[] }) {
const [openedDelete, { open: openDelete, close: closeDelete }] = const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false); useDisclosure(false);
const [btnDisable, setBtnDisable] = useState(true); const [btnDisable, setBtnDisable] = useState(true);
@@ -293,15 +294,19 @@ export default function KategoriPengaduan() {
<Title order={4} c="gray.2"> <Title order={4} c="gray.2">
Kategori Pengaduan Kategori Pengaduan
</Title> </Title>
<Tooltip label="Tambah Kategori Pengaduan"> {
<Button permissions.includes("setting.kategori_pengaduan.tambah") && (
variant="light" <Tooltip label="Tambah Kategori Pengaduan">
leftSection={<IconPlus size={20} />} <Button
onClick={openTambah} variant="light"
> leftSection={<IconPlus size={20} />}
Tambah onClick={openTambah}
</Button> >
</Tooltip> Tambah
</Button>
</Tooltip>
)
}
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />
<Stack gap={"md"}> <Stack gap={"md"}>
@@ -318,17 +323,18 @@ export default function KategoriPengaduan() {
<Table.Td>{v.name}</Table.Td> <Table.Td>{v.name}</Table.Td>
<Table.Td> <Table.Td>
<Group> <Group>
<Tooltip label="Edit Kategori"> <Tooltip label={permissions.includes("setting.kategori_pengaduan.edit") ? "Edit Kategori" : "Edit Kategori - Anda tidak memiliki akses"}>
<ActionIcon <ActionIcon
variant="light" variant="light"
size="sm" size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }} style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })} onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes("setting.kategori_pengaduan.edit")}
> >
<IconEdit size={20} /> <IconEdit size={20} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Delete Kategori"> <Tooltip label={permissions.includes("setting.kategori_pengaduan.delete") ? "Hapus Kategori" : "Hapus Kategori - Anda tidak memiliki akses"}>
<ActionIcon <ActionIcon
variant="light" variant="light"
size="sm" size="sm"
@@ -338,6 +344,7 @@ export default function KategoriPengaduan() {
setDataDelete(v.id); setDataDelete(v.id);
openDelete(); openDelete();
}} }}
disabled={!permissions.includes("setting.kategori_pengaduan.delete")}
> >
<IconTrash size={20} /> <IconTrash size={20} />
</ActionIcon> </ActionIcon>

View File

@@ -0,0 +1,80 @@
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>("");
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)
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) {
console.error("Gagal load gambar:", err);
} finally {
setLoading(false);
}
};
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,57 @@
import { groupPermissions } from "@/lib/groupPermission";
import { Button, 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 fw={600}>- {node.label}</Text>
{/* Children */}
{sub.map((child: any, i) => (
<RenderNode key={i} node={child} />
))}
</Stack>
);
}
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="lg">
{
showAll ?
rootNodes.map((node: any, idx) => (
<RenderNode key={idx} node={node} />
))
:
rootNodes.slice(0, 2).map((node: any, idx) => (
<RenderNode key={idx} node={node} />
))
}
<Button
variant="subtle"
size="xs"
onClick={() => setShowAll(!showAll)}
w="fit-content"
ml="md"
>
{showAll ? "View less" : "View more"}
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,144 @@
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;
}) {
const [open, setOpen] = useState<Record<string, boolean>>({});
const toggle = (key: string) => {
setOpen((prev) => ({ ...prev, [key]: !prev[key] }));
};
// Ambil semua key dari node termasuk semua keturunannya
const collectKeys = (n: Node): string[] => {
if (!n.children) return [n.key];
return [n.key, ...n.children.flatMap(collectKeys)];
};
const checkState = (node: Node): { all: boolean; some: boolean } => {
const children = node.children || [];
// Jika tidak ada anak → nilai hanya berdasarkan dirinya sendiri
if (children.length === 0) {
const checked = selected.includes(node.key);
return { all: checked, some: checked };
}
// Rekursif ke anak
let all = selected.includes(node.key);
let some = selected.includes(node.key);
for (const c of children) {
const childState = checkState(c);
if (!childState.all) all = false;
if (childState.some) some = true;
}
return { all, some };
};
// Untuk ordering sesuai urutan JSON
const getOrderedKeys = (nodes: Node[]): string[] =>
nodes.flatMap((n) => [n.key, ...getOrderedKeys(n.children || [])]);
const RenderNode = ({ node }: { node: Node }) => {
const children = node.children || [];
const state = checkState(node); // ← gunakan recursive evaluator
const isChecked = state.all;
const isIndeterminate = !state.all && state.some;
const showChildren = open[node.key] ?? false;
// Ambil semua key anak + parent
const collectKeys = (n: Node): string[] => {
if (!n.children) return [n.key];
return [n.key, ...n.children.flatMap(collectKeys)];
};
const allKeys = collectKeys(node);
const toggleCheck = (checked: boolean) => {
let updated = new Set(selected);
if (checked) {
// parent + semua child
allKeys.forEach((k) => updated.add(k));
} else {
// hilangkan parent + semua child
allKeys.forEach((k) => updated.delete(k));
}
// ⬇⬇⬇ PERBAIKAN PENTING ⬇⬇⬇
//
// Jika node indeterminate → parent harus tetap ada di selected
//
if (isIndeterminate) {
updated.add(node.key);
}
// Jika semua child tercentang → parent harus checked
if (isChecked) {
updated.add(node.key);
}
onChange([...updated]);
};
return (
<Stack gap={4} pl="xs">
<Group wrap="nowrap">
{children.length > 0 ? (
<ActionIcon variant="subtle" onClick={() => toggle(node.key)}>
{showChildren ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
</ActionIcon>
) : (
<div style={{ width: 24 }} />
)}
<Checkbox
label={node.label}
checked={isChecked}
indeterminate={isIndeterminate}
onChange={(e) => toggleCheck(e.target.checked)}
/>
</Group>
{children.length > 0 && (
<Collapse in={showChildren}>
<Stack gap={4} pl="md">
{children.map((c) => (
<RenderNode key={c.key} node={c} />
))}
</Stack>
</Collapse>
)}
</Stack>
);
};
return (
<Stack>
<Text size="sm">Hak Akses</Text>
{permissionConfig.menus.map((menu: Node) => (
<RenderNode key={menu.key} node={menu} />
))}
</Stack>
);
}

View File

@@ -9,10 +9,11 @@ import {
Stack, Stack,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import notification from "./notificationGlobal"; import notification from "./notificationGlobal";
export default function ProfileUser() { export default function ProfileUser({ permissions }: { permissions: JsonValue[] }) {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [openedPassword, setOpenedPassword] = useState(false); const [openedPassword, setOpenedPassword] = useState(false);
const [pwdBaru, setPwdBaru] = useState(""); const [pwdBaru, setPwdBaru] = useState("");
@@ -126,12 +127,21 @@ export default function ProfileUser() {
Profile Pengguna Profile Pengguna
</Title> </Title>
<Group gap="md"> <Group gap="md">
<Button variant="light" onClick={() => setOpened(true)}> {
Edit permissions.includes("setting.profile.edit") && (
</Button> <Button variant="light" onClick={() => setOpened(true)}>
<Button variant="light" onClick={() => setOpenedPassword(true)}> Edit
Ubah Password </Button>
</Button> )
}
{
permissions.includes("setting.profile.password") && (
<Button variant="light" onClick={() => setOpenedPassword(true)}>
Ubah Password
</Button>
)
}
</Group> </Group>
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />

View File

@@ -0,0 +1,394 @@
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";
import PermissionRole from "./PermissionRole";
import PermissionTree from "./PermissionTree";
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 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.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);
}
}
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(data);
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 });
}
}
console.log("dataTambah", dataTambah);
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="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"}
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>
<PermissionTree
selected={dataTambah.permissions}
onChange={(permissions) => {
setDataTambah({ ...dataTambah, permissions: 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>{v.name}</Table.Td>
<Table.Td>
<PermissionRole permissions={v.permissions} />
</Table.Td>
<Table.Td>
<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')}
>
<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')}
>
<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

@@ -16,11 +16,12 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks"; import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react"; import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react"; import { useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import notification from "./notificationGlobal"; import notification from "./notificationGlobal";
export default function UserSetting() { export default function UserSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(true); const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false); const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
@@ -390,15 +391,20 @@ export default function UserSetting() {
<Title order={4} c="gray.2"> <Title order={4} c="gray.2">
Daftar User Daftar User
</Title> </Title>
<Tooltip label="Tambah User"> {
<Button permissions.includes('setting.user.tambah') && (
variant="light" <Tooltip label="Tambah User">
leftSection={<IconPlus size={20} />} <Button
onClick={openTambah} variant="light"
> leftSection={<IconPlus size={20} />}
Tambah onClick={openTambah}
</Button> >
</Tooltip> Tambah
</Button>
</Tooltip>
)
}
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />
<Stack gap={"md"}> <Stack gap={"md"}>
@@ -422,17 +428,18 @@ export default function UserSetting() {
<Table.Td>{v.roleId}</Table.Td> <Table.Td>{v.roleId}</Table.Td>
<Table.Td> <Table.Td>
<Group> <Group>
<Tooltip label="Edit User"> <Tooltip label={permissions.includes('setting.user.edit') ? "Edit User" : "Edit User - Anda tidak memiliki akses"}>
<ActionIcon <ActionIcon
variant="light" variant="light"
size="sm" size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }} style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })} onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes('setting.user.edit')}
> >
<IconEdit size={20} /> <IconEdit size={20} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Delete User"> <Tooltip label={permissions.includes('setting.user.delete') ? "Delete User" : "Delete User - Anda tidak memiliki akses"}>
<ActionIcon <ActionIcon
variant="light" variant="light"
size="sm" size="sm"
@@ -442,6 +449,7 @@ export default function UserSetting() {
setDataDelete(v.id); setDataDelete(v.id);
openDelete(); openDelete();
}} }}
disabled={!permissions.includes('setting.user.delete')}
> >
<IconTrash size={20} /> <IconTrash size={20} />
</ActionIcon> </ActionIcon>

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

@@ -17,6 +17,7 @@ import PengaduanRoute from "./server/routes/pengaduan_route";
import TestPengaduanRoute from "./server/routes/test_pengaduan"; import TestPengaduanRoute from "./server/routes/test_pengaduan";
import UserRoute from "./server/routes/user_route"; import UserRoute from "./server/routes/user_route";
import WargaRoute from "./server/routes/warga_route"; import WargaRoute from "./server/routes/warga_route";
import SuratRoute from "./server/routes/surat_route";
const Docs = new Elysia({ const Docs = new Elysia({
tags: ["docs"], tags: ["docs"],
@@ -33,6 +34,8 @@ const Api = new Elysia({
.use(PengaduanRoute) .use(PengaduanRoute)
.use(PelayananRoute) .use(PelayananRoute)
.use(ConfigurationDesaRoute) .use(ConfigurationDesaRoute)
.use(WargaRoute)
.use(SuratRoute)
.use(TestPengaduanRoute) .use(TestPengaduanRoute)
.use(apiAuth) .use(apiAuth)
.use(ApiKeyRoute) .use(ApiKeyRoute)

View File

@@ -25,7 +25,7 @@ export const categoryPelayananSurat = [
syaratDokumen: [ syaratDokumen: [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" }, { name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "skt organisasi", desc: "Fotokopi Surat Keterangan Terdaftar (SKT) Organisasi atau Pengukuhan Kelompok" }, { 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", "alamat organisasi", "nama pemohon", "jabatan pemohon", "kontak", "penanggung jawab", "tanggal berdiri"]
}, },
@@ -36,7 +36,7 @@ export const categoryPelayananSurat = [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" }, { name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "surat lahir", desc: "Fotokopi Surat Keterangan Lahir dari Bidan/Dokter (jika ada)" } { 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", id: "skkelakuanbaik",
@@ -45,7 +45,7 @@ export const categoryPelayananSurat = [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" }, { name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" } { 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", "alamat", "polsek"]
}, },
{ {
id: "skkematian", id: "skkematian",
@@ -65,7 +65,7 @@ export const categoryPelayananSurat = [
{ name: "ktp ortu/kk", desc: "Fotokopi KTP orang tua atau Kartu Keluarga" }, { name: "ktp ortu/kk", desc: "Fotokopi KTP orang tua atau Kartu Keluarga" },
{ name: "surat pernyataan", desc: "Surat Pernyataan Penghasilan bermaterai" } { name: "surat pernyataan", desc: "Surat Pernyataan Penghasilan bermaterai" }
], ],
dataText: ["nama", "nik", "alamat", "pekerjaan", "jenis usaha", "penghasilan"] dataText: ["nama", "nik", "alamat", "pekerjaan", "jenis usaha", "penghasilan", "alasan permohonan"]
}, },
{ {
id: "sktempatusaha", id: "sktempatusaha",
@@ -95,7 +95,7 @@ export const categoryPelayananSurat = [
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" }, { 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" } { 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", id: "skyatimpiatu",

View File

@@ -47,7 +47,7 @@ export const confDesa = [
{ {
id: "perbekelNIP", id: "perbekelNIP",
name: "NIP", name: "NIP",
value: "" value: "1122334455"
}, },
{ {
id: "perbekelTTD", 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": "Antrian",
"default": true,
"children": [
{
"key": "pengaduan.antrian.tolak",
"label": "Menolak",
"default": true
},
{
"key": "pengaduan.antrian.terima",
"label": "Menerima",
"default": true
}
]
},
{
"key": "pengaduan.diterima",
"label": "Diterima",
"default": true,
"children": [
{
"key": "pengaduan.diterima.dikerjakan",
"label": "Dikerjakan",
"default": true
}
]
},
{
"key": "pengaduan.dikerjakan",
"label": "Dikerjakan",
"default": true,
"children": [
{
"key": "pengaduan.dikerjakan.selesai",
"label": "Diselesaikan",
"default": true
}
]
}
]
},
{
"key": "pelayanan",
"label": "Pelayanan",
"default": true,
"children": [
{
"key": "pelayanan.view",
"label": "Melihat List & Detail",
"default": true
},
{
"key": "pelayanan.antrian",
"label": "Antrian",
"default": true,
"children": [
{
"key": "pelayanan.antrian.tolak",
"label": "Menolak",
"default": true
},
{
"key": "pelayanan.antrian.terima",
"label": "Menerima",
"default": true
}
]
},
{
"key": "pelayanan.diterima",
"label": "Diterima",
"default": true,
"children": [
{
"key": "pelayanan.diterima.tolak",
"label": "Menolak",
"default": true
},
{
"key": "pelayanan.diterima.setujui",
"label": "Menyetujui",
"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

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

View File

@@ -1,3 +1,5 @@
import ModalSurat from "@/components/ModalSurat";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch"; import apiFetch from "@/lib/apiFetch";
import { import {
Anchor, Anchor,
@@ -9,95 +11,179 @@ import {
Flex, Flex,
Grid, Grid,
Group, Group,
List,
Modal, Modal,
Stack, Stack,
Table, Table,
Text, Text,
Textarea, Textarea,
Title, ThemeIcon,
Title
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks"; import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { import {
IconAlignJustified, IconAlignJustified,
IconCategory, IconCheck,
IconFileCertificate, IconFileCertificate,
IconInfoTriangle, IconFileCheck,
IconMapPin,
IconMessageReport, IconMessageReport,
IconPhotoScan, IconPhone,
IconUser, IconUser
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useState } from "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 { useLocation } from "react-router-dom";
import useSwr from "swr"; import useSwr from "swr";
export default function DetailPelayananPage() { export default function DetailPengajuanPage() {
const { search } = useLocation(); const { search } = useLocation();
const query = new URLSearchParams(search); const query = new URLSearchParams(search);
const id = query.get("id"); const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pelayanan.detail.get({
query: {
id: id!,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
return ( return (
<Container size="xl" py="xl" w={"100%"}> <Container size="xl" py="xl" w={"100%"}>
<Grid> <Grid>
<Grid.Col span={8}> <Grid.Col span={8}>
<Stack gap={"xl"}> <Stack gap={"xl"}>
<DetailDataPelayanan /> <DetailDataPengajuan data={data?.data?.pengajuan} syaratDokumen={data?.data?.syaratDokumen} dataText={data?.data?.dataText} onAction={() => { mutate(); }} />
<DetailDataHistori /> <DetailDataHistori data={data?.data?.history} />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={4}> <Grid.Col span={4}>
<DetailUserPelayanan /> <DetailUserPengajuan data={data?.data?.warga} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Container> </Container>
); );
} }
function DetailDataPelayanan() { function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data: any, syaratDokumen: any, dataText: any, onAction: () => void }) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak"); 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 [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("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",
});
}
}
return ( return (
<> <>
{/* MODAL KONFIRMASI */}
<Modal <Modal
opened={opened} opened={opened}
onClose={close} onClose={close}
title={"Konfirmasi"} title={"Konfirmasi"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }} overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
> >
<Stack gap="sm"> <Stack gap="sm">
{catModal === "tolak" ? ( {catModal === "tolak" ? (
<> <>
<Text> <Text>
Anda yakin ingin menolak pengaduan ini? Berikan alasan penolakan Anda yakin ingin menolak pengajuan surat ini? Berikan alasan penolakan
</Text> </Text>
<Textarea size="md" minRows={5} value={keterangan} onChange={(e) => setKeterangan(e.target.value)} />
<Textarea size="md" minRows={5} />
<Group justify="center" grow> <Group justify="center" grow>
<Button variant="light" onClick={close}> <Button variant="light" onClick={close}>
Batal Batal
</Button> </Button>
<Button variant="filled" color="red" onClick={close}> <Button variant="filled" color="red" disabled={keterangan.length < 1} onClick={() => handleKonfirmasi("tolak")}>
Tolak Tolak
</Button> </Button>
</Group> </Group>
</> </>
) : ( ) : (
<> <>
<Text>Anda yakin ingin menerima pengaduan ini?</Text> <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> <Group justify="center" grow>
<Button variant="light" onClick={close}> <Button variant="light" onClick={close}>
Batal Tidak
</Button> </Button>
<Button variant="filled" color="green" onClick={close}> <Button variant="filled" color="green" onClick={() => handleKonfirmasi("terima")} disabled={data.status == 'diterima' && noSurat.length < 1}>
Terima Ya
</Button> </Button>
</Group> </Group>
</> </>
)} )}
</Stack> </Stack>
</Modal> </Modal>
{
data?.status == "selesai" &&
(<ModalSurat open={openedPreview} onClose={() => setOpenedPreview(false)} surat={data?.idSurat} />)
}
<Card <Card
radius="md" radius="md"
@@ -114,117 +200,143 @@ function DetailDataPelayanan() {
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Group gap="xs"> <Group gap="xs">
<Title order={4} c="gray.2"> <Title order={4} c="gray.2">
Pelayanan Surat Pengajuan {data?.category}
</Title> </Title>
<Title order={4} c="dimmed"> <Title order={4} c="dimmed">
#PGf-2345-33 #{data?.noPengajuan}
</Title> </Title>
</Group> </Group>
<Badge <Badge
size="xl" size="xl"
variant="light" variant="light"
radius="sm" radius="sm"
color={"yellow"} color={
data?.status === "diterima"
? "green"
: data?.status === "ditolak"
? "red"
: data?.status === "selesai"
? "blue"
: data?.status === "dikerjakan"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }} style={{ textTransform: "none" }}
> >
antrian {data?.status}
</Badge> </Badge>
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />
<Grid> <Grid>
<Grid.Col span={6}> <Grid.Col span={12}>
<Stack gap="md"> <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 href="https://mantine.dev/" target="_blank">
{v.jenis}
</Anchor>
</List.Item>
))}
</List>
</Flex>
<Flex direction={"column"} justify="flex-start"> <Flex direction={"column"} justify="flex-start">
<Group gap="xs"> <Group gap="xs">
<IconAlignJustified size={20} /> <IconAlignJustified size={20} />
<Text size="md">Judul</Text> <Text size="md">Data Pelengkap</Text>
</Group> </Group>
<Text size="md" c={"white"}>
Judul Pelayanan Surat <Table withRowBorders={false}>
</Text> <Table.Tbody>
</Flex> {
<Flex direction={"column"} justify="flex-start"> dataText?.map((item: any) => (
<Group gap="xs"> <Table.Tr key={item.id}>
<IconMapPin size={20} /> <Table.Td style={{ whiteSpace: "nowrap", width: "10%" }}>{_.upperFirst(item.jenis)}</Table.Td>
<Text size="md">Lokasi</Text> <Table.Td>:</Table.Td>
</Group> <Table.Td style={{ width: "85%" }}>{_.upperFirst(item.value)}</Table.Td>
<Text size="md" c="white"> </Table.Tr>
fwef ))
</Text> }
</Flex> </Table.Tbody>
</Stack> </Table>
</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">
fwef
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconPhotoScan size={20} />
<Text size="md">Gambar</Text>
</Group>
<Anchor href="https://mantine.dev/" target="_blank">
Lihat Gambar
</Anchor>
</Flex> </Flex>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Stack gap="md"> {
<Flex direction={"column"} justify="flex-start"> data?.status === "antrian" ? (
<Group gap="xs"> <Group justify="center" grow>
<IconAlignJustified size={20} /> <Button
<Text size="md">Detail</Text> 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> </Group>
<Text size="md" c="white"> ) : data?.status === "diterima" ? (
Lorem ipsum dolor sit, amet consectetur adipisicing elit. <Group justify="center" grow>
Illum, corporis iusto. Suscipit veritatis quas, non nobis <Button
fuga, laudantium accusantium tempora sint aliquid architecto disabled={!permissions.includes("pelayanan.diterima.tolak")}
totam esse eum excepturi nostrum fugiat ut. variant="light"
</Text> onClick={() => {
</Flex> setCatModal("tolak");
<Flex direction={"column"} justify="flex-start"> open();
<Group gap="xs"> }}
<IconInfoTriangle size={20} /> >
<Text size="md">Keterangan</Text> Tolak
</Button>
<Button
disabled={!permissions.includes("pelayanan.diterima.setujui")}
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Setujui
</Button>
</Group> </Group>
<Text size="md" c={"white"}> ) : (
Lorem ipsum dolor, sit amet consectetur adipisicing elit. At <Group justify="center" grow>
fugiat eligendi nesciunt dolore? Maiores a cumque vitae <Button
suscipit incidunt quos beatae modi, vel, id ullam quae variant="light"
voluptas, deserunt quas placeat. onClick={() => setOpenedPreview(!openedPreview)}
</Text> >
</Flex> Surat
</Stack> </Button>
</Grid.Col> </Group>
<Grid.Col span={12}> )
<Group justify="center" grow> }
<Button
variant="light"
onClick={() => {
setCatModal("tolak");
open();
}}
>
Tolak
</Button>
<Button
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Terima
</Button>
</Group>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Stack> </Stack>
@@ -233,23 +345,7 @@ function DetailDataPelayanan() {
); );
} }
function DetailDataHistori() { function DetailDataHistori({ data }: { data: any }) {
const elements = [
{ position: 6, mass: 12.011, symbol: "C", name: "Carbon" },
{ position: 7, mass: 14.007, symbol: "N", name: "Nitrogen" },
{ position: 39, mass: 88.906, symbol: "Y", name: "Yttrium" },
{ position: 56, mass: 137.33, symbol: "Ba", name: "Barium" },
{ position: 58, mass: 140.12, symbol: "Ce", name: "Cerium" },
];
const rows = elements.map((element) => (
<Table.Tr key={element.name}>
<Table.Td>{element.position}</Table.Td>
<Table.Td>{element.name}</Table.Td>
<Table.Td>{element.symbol}</Table.Td>
<Table.Td>{element.mass}</Table.Td>
</Table.Tr>
));
return ( return (
<Card <Card
radius="md" radius="md"
@@ -265,7 +361,7 @@ function DetailDataHistori() {
<Stack gap="md"> <Stack gap="md">
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Title order={4} c="gray.2"> <Title order={4} c="gray.2">
Histori Pengaduan Histori Pengajuan Surat
</Title> </Title>
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />
@@ -278,33 +374,25 @@ function DetailDataHistori() {
<Table.Th>User</Table.Th> <Table.Th>User</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody>{rows}</Table.Tbody> <Table.Tbody>
{
data?.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.createdAt}</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> </Table>
</Stack> </Stack>
</Card> </Card>
); );
} }
function DetailUserPelayanan() { function DetailUserPengajuan({ data }: { data: any }) {
const [page, setPage] = useState(1);
const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pengaduan.list.get({
query: {
status,
search: value,
take: "",
page: "",
},
}),
);
useShallowEffect(() => {
mutate();
}, [status, value]);
const list = data?.data || [];
return ( return (
<Card <Card
radius="md" radius="md"
@@ -333,16 +421,16 @@ function DetailUserPelayanan() {
<Text size="md">Nama</Text> <Text size="md">Nama</Text>
</Group> </Group>
<Text size="md" c={"white"}> <Text size="md" c={"white"}>
Amalia Dwi Yustiani {data?.name}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Group gap="xs"> <Group gap="xs">
<IconMapPin size={20} /> <IconPhone size={20} />
<Text size="md">Telepon</Text> <Text size="md">Telepon</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
08123456789 {data?.phone}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
@@ -351,7 +439,7 @@ function DetailUserPelayanan() {
<Text size="md">Jumlah Pengaduan</Text> <Text size="md">Jumlah Pengaduan</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
10 {data?.pengaduan}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
@@ -360,7 +448,7 @@ function DetailUserPelayanan() {
<Text size="md">Jumlah Pelayanan Surat</Text> <Text size="md">Jumlah Pelayanan Surat</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
10 {data?.pelayanan}
</Text> </Text>
</Group> </Group>
</Stack> </Stack>

View File

@@ -15,16 +15,15 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { import {
IconAlignJustified,
IconClockHour3, IconClockHour3,
IconFileSad, IconFileSad,
IconMapPin,
IconSearch, IconSearch,
IconUser
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr"; import useSwr from "swr";
import { proxy } from "valtio"; import { proxy, subscribe } from "valtio";
const state = proxy({ reload: "" }); const state = proxy({ reload: "" });
function reloadState() { function reloadState() {
@@ -48,20 +47,24 @@ export default function PelayananSuratListPage() {
function TabListPelayananSurat({ status }: { status: string }) { function TabListPelayananSurat({ status }: { status: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
const dataCount = useSwr("/pelayanan-surat/count", () => const { data, mutate, isLoading } = useSwr("/pelayanan-surat/count", () =>
apiFetch.api.pengaduan.count.get().then((res) => res.data), apiFetch.api.pelayanan.count.get().then((res) => res.data),
); );
useShallowEffect(() => {
mutate();
}, []);
return ( return (
<Tabs defaultValue={status || "semua"} color="teal"> <Tabs defaultValue={status || "semua"} color="teal">
<Tabs.List grow> <Tabs.List grow>
<Tabs.Tab <Tabs.Tab
value="all" value="semua"
onClick={() => { onClick={() => {
navigate("?status=semua"); navigate("?status=semua");
}} }}
> >
Semua ({dataCount?.data?.semua || 0}) Semua ({data?.semua || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="antrian" value="antrian"
@@ -69,7 +72,7 @@ function TabListPelayananSurat({ status }: { status: string }) {
navigate("?status=antrian"); navigate("?status=antrian");
}} }}
> >
Antrian ({dataCount?.data?.antrian || 0}) Antrian ({data?.antrian || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="diterima" value="diterima"
@@ -77,15 +80,7 @@ function TabListPelayananSurat({ status }: { status: string }) {
navigate("?status=diterima"); navigate("?status=diterima");
}} }}
> >
Diterima ({dataCount?.data?.diterima || 0}) Diterima ({data?.diterima || 0})
</Tabs.Tab>
<Tabs.Tab
value="dikerjakan"
onClick={() => {
navigate("?status=dikerjakan");
}}
>
Dikerjakan ({dataCount?.data?.dikerjakan || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="selesai" value="selesai"
@@ -93,7 +88,7 @@ function TabListPelayananSurat({ status }: { status: string }) {
navigate("?status=selesai"); navigate("?status=selesai");
}} }}
> >
Selesai ({dataCount?.data?.selesai || 0}) Selesai ({data?.selesai || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="ditolak" value="ditolak"
@@ -101,7 +96,7 @@ function TabListPelayananSurat({ status }: { status: string }) {
navigate("?status=ditolak"); navigate("?status=ditolak");
}} }}
> >
Ditolak ({dataCount?.data?.ditolak || 0}) Ditolak ({data?.ditolak || 0})
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
@@ -118,21 +113,29 @@ type StatusKey =
function ListPelayananSurat({ status }: { status: StatusKey }) { function ListPelayananSurat({ status }: { status: StatusKey }) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", () => const { data, mutate, isLoading } = useSwr("/", async () => {
apiFetch.api.pengaduan.list.get({ const res = await apiFetch.api.pelayanan.list.get({
query: { query: {
status, status,
search: value, search: value,
take: "", take: "",
page: "", page: "",
}, },
}), });
);
return Array.isArray(res?.data) ? res.data : []; // ⬅ paksa return array
});
useShallowEffect(() => { useShallowEffect(() => {
mutate(); mutate();
}, [status, value]); }, [status, value]);
useShallowEffect(() => {
const unsubscribe = subscribe(state, () => mutate());
return () => unsubscribe();
}, []);
const navigate = useNavigate(); const navigate = useNavigate();
if (isLoading) if (isLoading)
@@ -147,19 +150,19 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
}} }}
> >
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Loading pengaduan... Loading pelayanan surat...
</Text> </Text>
</Card> </Card>
); );
const list = data?.data || []; const list = data || [];
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<Group grow> <Group grow>
<Input <Input
value={value} value={value}
placeholder="Cari pengaduan..." placeholder="Cari pengajuan..."
onChange={(event) => setValue(event.currentTarget.value)} onChange={(event) => setValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
rightSectionPointerEvents="all" rightSectionPointerEvents="all"
@@ -172,7 +175,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
} }
/> />
</Group> </Group>
{list?.length === 0 ? ( {Array.isArray(list) && list?.length === 0 ? (
<Flex justify="center" align="center" py={"xl"}> <Flex justify="center" align="center" py={"xl"}>
<Stack gap={4} align="center"> <Stack gap={4} align="center">
<IconFileSad size={32} color="gray" /> <IconFileSad size={32} color="gray" />
@@ -182,7 +185,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
</Stack> </Stack>
</Flex> </Flex>
) : ( ) : (
list?.map((v: any) => ( Array.isArray(list) && list?.map((v: any) => (
<Card <Card
key={v.id} key={v.id}
radius="lg" radius="lg"
@@ -204,11 +207,11 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Flex direction={"column"}> <Flex direction={"column"}>
<Title order={3} c="gray.2"> <Title order={3} c="gray.2">
{v.title} {v.category}
</Title> </Title>
<Group> <Group>
<Title order={6} c="gray.5"> <Title order={6} c="gray.5">
#{v.noPengaduan} #{v.noPengajuan}
</Title> </Title>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{v.updatedAt} {v.updatedAt}
@@ -227,7 +230,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
: v.status === "selesai" : v.status === "selesai"
? "blue" ? "blue"
: v.status === "dikerjakan" : v.status === "dikerjakan"
? "purple" ? "gray"
: "yellow" : "yellow"
} }
style={{ textTransform: "none" }} style={{ textTransform: "none" }}
@@ -241,28 +244,19 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
<Group gap="xs"> <Group gap="xs">
<IconClockHour3 size={20} color="white" /> <IconClockHour3 size={20} color="white" />
<Text size="md" c="white"> <Text size="md" c="white">
Tanggal Aduan Tanggal Ajuan
</Text> </Text>
</Group> </Group>
<Text size="md">{v.createdAt}</Text> <Text size="md">{v.createdAt}</Text>
</Flex> </Flex>
<Flex direction={"column"} justify="flex-start"> <Flex direction={"column"} justify="flex-start">
<Group gap="xs"> <Group gap="xs">
<IconMapPin size={20} color="white" /> <IconUser size={20} color="white" />
<Text size="md" c="white"> <Text size="md" c="white">
Lokasi Warga
</Text> </Text>
</Group> </Group>
<Text size="md">{v.location}</Text> <Text size="md">{v.warga}</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> </Flex>
</Stack> </Stack>
</Stack> </Stack>

View File

@@ -1,3 +1,4 @@
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch"; import apiFetch from "@/lib/apiFetch";
import { import {
Anchor, Anchor,
@@ -25,10 +26,14 @@ import {
IconInfoTriangle, IconInfoTriangle,
IconMapPin, IconMapPin,
IconMessageReport, IconMessageReport,
IconPhone,
IconPhotoScan, IconPhotoScan,
IconUser, IconUser,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useState } from "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 { useLocation } from "react-router-dom";
import useSwr from "swr"; import useSwr from "swr";
@@ -36,50 +41,101 @@ export default function DetailPengaduanPage() {
const { search } = useLocation(); const { search } = useLocation();
const query = new URLSearchParams(search); const query = new URLSearchParams(search);
const id = query.get("id"); const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pengaduan.detail.get({
query: {
id: id!,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
return ( return (
<Container size="xl" py="xl" w={"100%"}> <Container size="xl" py="xl" w={"100%"}>
<Grid> <Grid>
<Grid.Col span={8}> <Grid.Col span={8}>
<Stack gap={"xl"}> <Stack gap={"xl"}>
<DetailDataPengaduan /> <DetailDataPengaduan data={data?.data?.pengaduan} onAction={() => { mutate(); }} />
<DetailDataHistori /> <DetailDataHistori data={data?.data?.history} />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={4}> <Grid.Col span={4}>
<DetailUserPengaduan /> <DetailUserPengaduan data={data?.data?.warga} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Container> </Container>
); );
} }
function DetailDataPengaduan() { function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => void }) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak"); const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
const [imageSrc, setImageSrc] = useState<string | null>(null); const [imageSrc, setImageSrc] = useState<string | null>(null);
const [openedModalImage, { open: openModalImage, close: closeModalImage }] = const [openedModalImage, { open: openModalImage, close: closeModalImage }] =
useDisclosure(false); useDisclosure(false);
const [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
async function handleLihatGambar() { useEffect(() => {
const res = await apiFetch.api.pengaduan.image.get({ async function fetchHost() {
query: { const { data } = await apiFetch.api.user.find.get();
fileName: "57d5ce89-7d18-4244-9f4c-ca21b70adb7e", setHost(data?.user ?? null);
},
}); if (data?.permissions && Array.isArray(data.permissions)) {
console.error("client", res); const onlySetting = data.permissions.filter((p: any) => p.startsWith("pengaduan"));
// const blob = await res.data?.blob(); setPermissions(onlySetting);
// setImageSrc(URL.createObjectURL(blob!)); }
// openModalImage(); }
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 ( return (
<> <>
{/* MODAL KONFIRMASI */}
<Modal <Modal
opened={opened} opened={opened}
onClose={close} onClose={close}
title={"Konfirmasi"} title={"Konfirmasi"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }} overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
> >
<Stack gap="sm"> <Stack gap="sm">
@@ -89,25 +145,25 @@ function DetailDataPengaduan() {
Anda yakin ingin menolak pengaduan ini? Berikan alasan penolakan Anda yakin ingin menolak pengaduan ini? Berikan alasan penolakan
</Text> </Text>
<Textarea size="md" minRows={5} /> <Textarea size="md" minRows={5} value={keterangan} onChange={(e) => setKeterangan(e.target.value)} />
<Group justify="center" grow> <Group justify="center" grow>
<Button variant="light" onClick={close}> <Button variant="light" onClick={close}>
Batal Batal
</Button> </Button>
<Button variant="filled" color="red" onClick={close}> <Button variant="filled" color="red" disabled={keterangan.length < 1} onClick={() => handleKonfirmasi("tolak")}>
Tolak Tolak
</Button> </Button>
</Group> </Group>
</> </>
) : ( ) : (
<> <>
<Text>Anda yakin ingin menerima pengaduan ini?</Text> <Text>Anda yakin ingin {data?.status == 'antrian' ? 'menerima' : data.status == 'diterima' ? 'mengerjakan' : 'menyelesaikan'} pengaduan ini?</Text>
<Group justify="center" grow> <Group justify="center" grow>
<Button variant="light" onClick={close}> <Button variant="light" onClick={close}>
Batal Tidak
</Button> </Button>
<Button variant="filled" color="green" onClick={close}> <Button variant="filled" color="green" onClick={() => handleKonfirmasi("terima")}>
Terima Ya
</Button> </Button>
</Group> </Group>
</> </>
@@ -115,11 +171,12 @@ function DetailDataPengaduan() {
</Stack> </Stack>
</Modal> </Modal>
{/* MODAL GAMBAR */}
<Modal <Modal
opened={openedModalImage} opened={openedModalImage}
onClose={closeModalImage} onClose={closeModalImage}
title="Gambar Pengaduan" title="Gambar Pengaduan"
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }} overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
> >
<Image src={imageSrc!} /> <Image src={imageSrc!} />
@@ -143,17 +200,27 @@ function DetailDataPengaduan() {
Pengaduan Pengaduan
</Title> </Title>
<Title order={4} c="dimmed"> <Title order={4} c="dimmed">
#PGf-2345-33 #{data?.noPengaduan}
</Title> </Title>
</Group> </Group>
<Badge <Badge
size="xl" size="xl"
variant="light" variant="light"
radius="sm" radius="sm"
color={"yellow"} color={
data?.status === "diterima"
? "green"
: data?.status === "ditolak"
? "red"
: data?.status === "selesai"
? "blue"
: data?.status === "dikerjakan"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }} style={{ textTransform: "none" }}
> >
antrian {data?.status}
</Badge> </Badge>
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />
@@ -166,7 +233,7 @@ function DetailDataPengaduan() {
<Text size="md">Judul</Text> <Text size="md">Judul</Text>
</Group> </Group>
<Text size="md" c={"white"}> <Text size="md" c={"white"}>
Judul Pengaduan {_.upperFirst(data?.title)}
</Text> </Text>
</Flex> </Flex>
<Flex direction={"column"} justify="flex-start"> <Flex direction={"column"} justify="flex-start">
@@ -175,7 +242,7 @@ function DetailDataPengaduan() {
<Text size="md">Lokasi</Text> <Text size="md">Lokasi</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
fwef {_.upperFirst(data?.location)}
</Text> </Text>
</Flex> </Flex>
</Stack> </Stack>
@@ -188,7 +255,7 @@ function DetailDataPengaduan() {
<Text size="md">Kategori</Text> <Text size="md">Kategori</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
fwef {_.upperFirst(data?.category)}
</Text> </Text>
</Flex> </Flex>
<Flex direction={"column"} justify="flex-start"> <Flex direction={"column"} justify="flex-start">
@@ -196,7 +263,7 @@ function DetailDataPengaduan() {
<IconPhotoScan size={20} /> <IconPhotoScan size={20} />
<Text size="md">Gambar</Text> <Text size="md">Gambar</Text>
</Group> </Group>
<Anchor href="#" onClick={handleLihatGambar}> <Anchor href="#" onClick={() => { }}>
Lihat Gambar Lihat Gambar
</Anchor> </Anchor>
</Flex> </Flex>
@@ -210,47 +277,77 @@ function DetailDataPengaduan() {
<Text size="md">Detail</Text> <Text size="md">Detail</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. {_.upperFirst(data?.detail)}
Illum, corporis iusto. Suscipit veritatis quas, non nobis
fuga, laudantium accusantium tempora sint aliquid architecto
totam esse eum excepturi nostrum fugiat ut.
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconInfoTriangle size={20} />
<Text size="md">Keterangan</Text>
</Group>
<Text size="md" c={"white"}>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. At
fugiat eligendi nesciunt dolore? Maiores a cumque vitae
suscipit incidunt quos beatae modi, vel, id ullam quae
voluptas, deserunt quas placeat.
</Text> </Text>
</Flex> </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> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Group justify="center" grow> {
<Button data?.status === "antrian" ? (
variant="light" <Group justify="center" grow>
onClick={() => { <Button
setCatModal("tolak"); variant="light"
open(); disabled={!permissions.includes("pengaduan.antrian.tolak")}
}} onClick={() => {
> setCatModal("tolak");
Tolak open();
</Button> }}
<Button >
variant="filled" Tolak
onClick={() => { </Button>
setCatModal("terima"); <Button
open(); variant="filled"
}} disabled={!permissions.includes("pengaduan.antrian.terima")}
> onClick={() => {
Terima setCatModal("terima");
</Button> open();
</Group> }}
>
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.Col>
</Grid> </Grid>
</Stack> </Stack>
@@ -259,23 +356,7 @@ function DetailDataPengaduan() {
); );
} }
function DetailDataHistori() { function DetailDataHistori({ data }: { data: any }) {
const elements = [
{ position: 6, mass: 12.011, symbol: "C", name: "Carbon" },
{ position: 7, mass: 14.007, symbol: "N", name: "Nitrogen" },
{ position: 39, mass: 88.906, symbol: "Y", name: "Yttrium" },
{ position: 56, mass: 137.33, symbol: "Ba", name: "Barium" },
{ position: 58, mass: 140.12, symbol: "Ce", name: "Cerium" },
];
const rows = elements.map((element) => (
<Table.Tr key={element.name}>
<Table.Td>{element.position}</Table.Td>
<Table.Td>{element.name}</Table.Td>
<Table.Td>{element.symbol}</Table.Td>
<Table.Td>{element.mass}</Table.Td>
</Table.Tr>
));
return ( return (
<Card <Card
radius="md" radius="md"
@@ -304,33 +385,25 @@ function DetailDataHistori() {
<Table.Th>User</Table.Th> <Table.Th>User</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody>{rows}</Table.Tbody> <Table.Tbody>
{
data?.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.createdAt}</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> </Table>
</Stack> </Stack>
</Card> </Card>
); );
} }
function DetailUserPengaduan() { function DetailUserPengaduan({ data }: { data: any }) {
const [page, setPage] = useState(1);
const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pengaduan.list.get({
query: {
status,
search: value,
take: "",
page: "",
},
}),
);
useShallowEffect(() => {
mutate();
}, [status, value]);
const list = data?.data || [];
return ( return (
<Card <Card
radius="md" radius="md"
@@ -359,16 +432,16 @@ function DetailUserPengaduan() {
<Text size="md">Nama</Text> <Text size="md">Nama</Text>
</Group> </Group>
<Text size="md" c={"white"}> <Text size="md" c={"white"}>
Amalia Dwi Yustiani {data?.name}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Group gap="xs"> <Group gap="xs">
<IconMapPin size={20} /> <IconPhone size={20} />
<Text size="md">Telepon</Text> <Text size="md">Telepon</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
08123456789 {data?.phone}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
@@ -377,7 +450,7 @@ function DetailUserPengaduan() {
<Text size="md">Jumlah Pengaduan</Text> <Text size="md">Jumlah Pengaduan</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
10 {data?.pengaduan}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
@@ -386,7 +459,7 @@ function DetailUserPengaduan() {
<Text size="md">Jumlah Pelayanan Surat</Text> <Text size="md">Jumlah Pelayanan Surat</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
10 {data?.pelayanan}
</Text> </Text>
</Group> </Group>
</Stack> </Stack>

View File

@@ -24,7 +24,7 @@ import {
import { useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr"; import useSwr from "swr";
import { proxy } from "valtio"; import { proxy, subscribe } from "valtio";
const state = proxy({ reload: "" }); const state = proxy({ reload: "" });
function reloadState() { function reloadState() {
@@ -48,20 +48,24 @@ export default function PengaduanListPage() {
function TabListPengaduan({ status }: { status: string }) { function TabListPengaduan({ status }: { status: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
const dataCount = useSwr("/pengaduan/count", () => const { data, mutate, isLoading } = useSwr("/pengaduan/count", () =>
apiFetch.api.pengaduan.count.get().then((res) => res.data), apiFetch.api.pengaduan.count.get().then((res) => res.data),
); );
useShallowEffect(() => {
mutate();
}, []);
return ( return (
<Tabs defaultValue={status || "semua"} color="teal"> <Tabs defaultValue={status || "semua"} color="teal">
<Tabs.List grow> <Tabs.List grow>
<Tabs.Tab <Tabs.Tab
value="all" value="semua"
onClick={() => { onClick={() => {
navigate("?status=semua"); navigate("?status=semua");
}} }}
> >
Semua ({dataCount?.data?.semua || 0}) Semua ({data?.semua || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="antrian" value="antrian"
@@ -69,7 +73,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=antrian"); navigate("?status=antrian");
}} }}
> >
Antrian ({dataCount?.data?.antrian || 0}) Antrian ({data?.antrian || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="diterima" value="diterima"
@@ -77,7 +81,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=diterima"); navigate("?status=diterima");
}} }}
> >
Diterima ({dataCount?.data?.diterima || 0}) Diterima ({data?.diterima || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="dikerjakan" value="dikerjakan"
@@ -85,7 +89,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=dikerjakan"); navigate("?status=dikerjakan");
}} }}
> >
Dikerjakan ({dataCount?.data?.dikerjakan || 0}) Dikerjakan ({data?.dikerjakan || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="selesai" value="selesai"
@@ -93,7 +97,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=selesai"); navigate("?status=selesai");
}} }}
> >
Selesai ({dataCount?.data?.selesai || 0}) Selesai ({data?.selesai || 0})
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="ditolak" value="ditolak"
@@ -101,7 +105,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=ditolak"); navigate("?status=ditolak");
}} }}
> >
Ditolak ({dataCount?.data?.ditolak || 0}) Ditolak ({data?.ditolak || 0})
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
@@ -120,21 +124,28 @@ function ListPengaduan({ status }: { status: StatusKey }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", () => const { data, mutate, isLoading } = useSwr("/", async () => {
apiFetch.api.pengaduan.list.get({ const res = await apiFetch.api.pengaduan.list.get({
query: { query: {
status, status,
search: value, search: value,
take: "", take: "",
page: "", page: "",
}, },
}), });
);
return Array.isArray(res?.data) ? res.data : []; // ⬅ paksa return array
});
useShallowEffect(() => { useShallowEffect(() => {
mutate(); mutate();
}, [status, value]); }, [status, value]);
useShallowEffect(() => {
const unsubscribe = subscribe(state, () => mutate());
return () => unsubscribe();
}, []);
if (isLoading) if (isLoading)
return ( return (
<Card <Card
@@ -152,7 +163,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
</Card> </Card>
); );
const list = data?.data || []; const list = data || [];
return ( return (
<Stack gap="xl"> <Stack gap="xl">
@@ -186,7 +197,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
</Stack> </Stack>
</Flex> </Flex>
) : ( ) : (
list?.map((v: any) => ( Array.isArray(list) && list?.map((v: any) => (
<Card <Card
key={v.id} key={v.id}
radius="lg" radius="lg"
@@ -229,7 +240,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
: v.status === "selesai" : v.status === "selesai"
? "blue" ? "blue"
: v.status === "dikerjakan" : v.status === "dikerjakan"
? "purple" ? "gray"
: "yellow" : "yellow"
} }
style={{ textTransform: "none" }} style={{ textTransform: "none" }}

View File

@@ -2,32 +2,92 @@ import DesaSetting from "@/components/DesaSetting";
import KategoriPelayananSurat from "@/components/KategoriPelayananSurat"; import KategoriPelayananSurat from "@/components/KategoriPelayananSurat";
import KategoriPengaduan from "@/components/KategoriPengaduan"; import KategoriPengaduan from "@/components/KategoriPengaduan";
import ProfileUser from "@/components/ProfileUser"; import ProfileUser from "@/components/ProfileUser";
import UserRoleSetting from "@/components/UserRoleSetting";
import UserSetting from "@/components/UserSetting"; import UserSetting from "@/components/UserSetting";
import apiFetch from "@/lib/apiFetch";
import { import {
Button,
Card, Card,
Container, Container,
Divider,
Flex,
Grid, Grid,
NavLink, NavLink
Stack,
Table,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconBuildingBank, IconBuildingBank,
IconCategory2, IconCategory2,
IconMailSpark, IconMailSpark,
IconUserCog, IconUserCog,
IconUsersGroup, IconUserScreen,
IconUsersGroup
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
export default function DetailSettingPage() { export default function DetailSettingPage() {
const { search } = useLocation(); const { search } = useLocation();
const query = new URLSearchParams(search); const query = new URLSearchParams(search);
const type = query.get("type"); 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 ( return (
<Container size="xl" py="xl" w={"100%"}> <Container size="xl" py="xl" w={"100%"}>
@@ -44,36 +104,17 @@ export default function DetailSettingPage() {
boxShadow: "0 0 20px rgba(0,255,200,0.08)", boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}} }}
> >
<NavLink {
href={`?type=profile`} navItems.filter((item) => permissions.includes(item.key)).map((item) => (
label="Profile" <NavLink
leftSection={<IconUserCog size={16} stroke={1.5} />} key={item.key}
active={type === "profile" || !type} href={'?type=' + item.path}
/> label={item.label}
<NavLink leftSection={item.icon}
href={`?type=user`} active={type === item.path || (!type && item.path === 'profile')}
label="User" />
leftSection={<IconUsersGroup size={16} stroke={1.5} />} ))
active={type === "user"} }
/>
<NavLink
href={`?type=cat-pengaduan`}
label="Kategori Pengaduan"
leftSection={<IconCategory2 size={16} stroke={1.5} />}
active={type === "cat-pengaduan"}
/>
<NavLink
href={`?type=cat-pelayanan`}
label="Kategori Pelayanan Surat"
leftSection={<IconMailSpark size={16} stroke={1.5} />}
active={type === "cat-pelayanan"}
/>
<NavLink
href={`?type=desa`}
label="Desa"
leftSection={<IconBuildingBank size={16} stroke={1.5} />}
active={type === "desa"}
/>
</Card> </Card>
</Grid.Col> </Grid.Col>
<Grid.Col span={9}> <Grid.Col span={9}>
@@ -89,15 +130,17 @@ export default function DetailSettingPage() {
}} }}
> >
{type === "cat-pengaduan" ? ( {type === "cat-pengaduan" ? (
<KategoriPengaduan /> <KategoriPengaduan permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.kategori_pengaduan"))} />
) : type === "cat-pelayanan" ? ( ) : type === "cat-pelayanan" ? (
<KategoriPelayananSurat /> <KategoriPelayananSurat permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.kategori_pelayanan"))} />
) : type === "desa" ? ( ) : type === "desa" ? (
<DesaSetting /> <DesaSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.desa"))} />
) : type === "user" ? ( ) : type === "user" ? (
<UserSetting /> <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 /> <ProfileUser permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.profile"))} />
)} )}
</Card> </Card>
</Grid.Col> </Grid.Col>

View File

@@ -78,12 +78,12 @@ export default function ListWargaPage() {
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{ {
list?.length === 0 ? ( Array.isArray(list) && list?.length === 0 ? (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={3} align="center">Tidak ada data</Table.Td> <Table.Td colSpan={3} align="center">Tidak ada data</Table.Td>
</Table.Tr> </Table.Tr>
) : ( ) : (
list?.map((item, i) => ( Array.isArray(list) && list?.map((item, i) => (
<Table.Tr key={i}> <Table.Tr key={i}>
<Table.Td>{item.name}</Table.Td> <Table.Td>{item.name}</Table.Td>
<Table.Td>{item.phone}</Table.Td> <Table.Td>{item.phone}</Table.Td>

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,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 { } catch {
console.error('🔍 Could not read response body'); console.error('🔍 Could not read response body');
} }
process.exit(1);
} }
return response; return response;
} }
@@ -128,14 +127,18 @@ export async function listFiles(config: Config): Promise<{ name: string }[]> {
} }
} }
export async function catFile(config: Config, fileName: string): Promise<string> { export async function catFile(config: Config, folder: string, fileName: string): Promise<ArrayBuffer> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`); const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`);
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, ''); 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> { export async function uploadFile(config: Config, file: File, folder: string): Promise<string> {
const remoteName = path.basename(file.name); const remoteName = path.basename(file.name);
// 1. Dapatkan upload link (pakai Authorization) // 1. Dapatkan upload link (pakai Authorization)
@@ -148,7 +151,7 @@ export async function uploadFile(config: Config, file: File): Promise<string> {
// 2. Siapkan form-data // 2. Siapkan form-data
const formData = new FormData(); const formData = new FormData();
formData.append("parent_dir", "/"); formData.append("parent_dir", "/");
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
// 3. Upload file TANPA Authorization header, token di query param // 3. Upload file TANPA Authorization header, token di query param
@@ -159,7 +162,7 @@ export async function uploadFile(config: Config, file: File): Promise<string> {
const text = await res.text(); const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`); if (!res.ok) return 'gagal'
return `✅ Uploaded ${file.name} successfully`; return `✅ Uploaded ${file.name} successfully`;
} }
@@ -228,10 +231,10 @@ export async function uploadFileToFolder(config: Config, base64File: { name: str
} }
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';
export async function removeFile(config: Config, fileName: string): Promise<string> {
await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`, { method: 'DELETE' });
return `🗑️ Removed ${fileName}` return `🗑️ Removed ${fileName}`
} }

View File

@@ -1,5 +1,7 @@
import Elysia, { t } from "elysia" import Elysia, { t } from "elysia"
import type { StatusPengaduan } from "generated/prisma" import type { StatusPengaduan } from "generated/prisma"
import { createSurat } from "../lib/create-surat"
import { getLastUpdated } from "../lib/get-last-updated"
import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat" import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat"
import { normalizePhoneNumber } from "../lib/normalizePhone" import { normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma" import { prisma } from "../lib/prisma"
@@ -102,28 +104,190 @@ const PelayananRoute = new Elysia({
// --- PELAYANAN SURAT --- // --- PELAYANAN SURAT ---
.get("/", async () => { .get("/", async ({ query }) => {
const { phone } = query
const data = await prisma.pelayananAjuan.findMany({ const data = await prisma.pelayananAjuan.findMany({
orderBy: {
createdAt: "asc"
},
where: { where: {
isActive: true isActive: true,
Warga: {
phone
}
} }
}) })
return data return data
}, { }, {
query: t.Object({
phone: t.String({ minLength: 1, error: "phone harus diisi" }),
}),
detail: { detail: {
summary: "List Ajuan Pelayanan Surat", summary: "List Ajuan Pelayanan Surat by Phone",
description: `tool untuk mendapatkan list ajuan pelayanan surat`, description: `tool untuk mendapatkan list ajuan pelayanan surat`,
tags: ["mcp"] tags: ["mcp"]
} }
}) })
.get("/detail", async ({ query }) => { .get("/detail", async ({ query }) => {
const { id } = query const { id } = query
const data = await prisma.pelayananAjuan.findUnique({
const data = await prisma.pelayananAjuan.findFirst({
where: { where: {
id, OR: [
{
noPengajuan: id
},
{
id: id
}
]
},
select: {
id: true,
noPengajuan: true,
status: true,
createdAt: true,
updatedAt: true,
CategoryPelayanan: {
select: {
name: true,
dataText: true,
syaratDokumen: true,
}
},
Warga: {
select: {
name: true,
phone: true,
_count: {
select: {
Pengaduan: true,
PelayananAjuan: true,
}
}
}
},
} }
}) })
return data
const dataSurat = await prisma.suratPelayanan.findFirst({
where: {
idPengajuanLayanan: data?.id,
isActive: true
},
select: {
id: true,
idCategory: true,
}
})
const dataSyarat = await prisma.syaratDokumenPelayanan.findMany({
where: {
idPengajuanLayanan: data?.id,
isActive: true
},
select: {
id: true,
jenis: true,
value: true,
}
})
const dataText = await prisma.dataTextPelayanan.findMany({
where: {
idPengajuanLayanan: data?.id,
isActive: true
},
select: {
id: true,
value: true,
jenis: true,
}
})
const syaratDokumen = (data?.CategoryPelayanan?.syaratDokumen ?? []) as {
name: string;
desc: string;
}[];
const dataSyaratFix = dataSyarat.map((item) => {
const desc = syaratDokumen.find((v) => v.name == item.jenis)?.desc
return {
id: item.id,
jenis: desc,
value: item.value,
}
})
const dataTextFix = dataText.map((item) => {
const desc = data?.CategoryPelayanan?.dataText.find((v) => v == item.jenis)
return {
id: item.id,
jenis: item.jenis,
value: item.value,
}
})
const dataHistory = await prisma.historyPelayanan.findMany({
where: {
idPengajuanLayanan: data?.id,
},
select: {
id: true,
deskripsi: true,
status: true,
createdAt: true,
idUser: true,
User: {
select: {
name: true,
}
}
}
})
const dataHistoryFix = dataHistory.map((item) => {
return {
id: item.id,
deskripsi: item.deskripsi,
status: item.status,
createdAt: item.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false
}),
idUser: item.idUser,
nameUser: item.User?.name,
}
})
const warga = {
name: data?.Warga?.name,
phone: data?.Warga?.phone,
pengaduan: data?.Warga?._count.Pengaduan,
pelayanan: data?.Warga?._count.PelayananAjuan,
}
const dataPengajuan = {
id: data?.id,
noPengajuan: data?.noPengajuan,
category: data?.CategoryPelayanan.name,
status: data?.status,
createdAt: data?.createdAt,
updatedAt: data?.updatedAt,
idSurat: dataSurat?.id,
}
const datafix = {
pengajuan: dataPengajuan,
history: dataHistoryFix,
warga: warga,
syaratDokumen: dataSyaratFix,
dataText: dataTextFix,
}
return datafix
}, { }, {
query: t.Object({ query: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }), id: t.String({ minLength: 1, error: "id harus diisi" }),
@@ -135,25 +299,25 @@ const PelayananRoute = new Elysia({
} }
}) })
.post("/create", async ({ body }) => { .post("/create", async ({ body }) => {
const { idCategory, idWarga, phone, dataText, syaratDokumen } = body const { kategoriId, wargaId, noTelepon, dataText, syaratDokumen } = body
const noPengajuan = await generateNoPengajuanSurat() const noPengajuan = await generateNoPengajuanSurat()
let idCategoryFix = idCategory let idCategoryFix = kategoriId
let idWargaFix = idWarga let idWargaFix = wargaId
const category = await prisma.categoryPelayanan.findUnique({ const category = await prisma.categoryPelayanan.findUnique({
where: { where: {
id: idCategory, id: kategoriId,
} }
}) })
if (!category) { if (!category) {
const cariCategory = await prisma.categoryPelayanan.findFirst({ const cariCategory = await prisma.categoryPelayanan.findFirst({
where: { where: {
name: idCategory, name: kategoriId,
} }
}) })
if (!cariCategory) { if (!cariCategory) {
throw new Error("kategori pelayanan surat tidak ditemukan") return { success: false, message: 'kategori pelayanan surat tidak ditemukan' }
} else { } else {
idCategoryFix = cariCategory.id idCategoryFix = cariCategory.id
} }
@@ -162,12 +326,12 @@ const PelayananRoute = new Elysia({
const warga = await prisma.warga.findUnique({ const warga = await prisma.warga.findUnique({
where: { where: {
id: idWarga, id: wargaId,
} }
}) })
if (!warga) { if (!warga) {
const nomorHP = normalizePhoneNumber({ phone }) const nomorHP = normalizePhoneNumber({ phone: noTelepon })
const cariWarga = await prisma.warga.findFirst({ const cariWarga = await prisma.warga.findFirst({
where: { where: {
phone: nomorHP, phone: nomorHP,
@@ -177,7 +341,7 @@ const PelayananRoute = new Elysia({
if (!cariWarga) { if (!cariWarga) {
const wargaCreate = await prisma.warga.create({ const wargaCreate = await prisma.warga.create({
data: { data: {
name: idWarga, name: wargaId,
phone: nomorHP, phone: nomorHP,
}, },
select: { select: {
@@ -203,7 +367,7 @@ const PelayananRoute = new Elysia({
}) })
if (!pengaduan.id) { if (!pengaduan.id) {
throw new Error("gagal membuat pengajuan surat") return { success: false, message: 'gagal membuat pengajuan surat' }
} }
let dataInsertSyaratDokumen = [] let dataInsertSyaratDokumen = []
@@ -246,17 +410,81 @@ const PelayananRoute = new Elysia({
return { success: true, message: 'pengajuan surat sudah dibuat' } return { success: true, message: 'pengajuan surat sudah dibuat' }
}, { }, {
body: t.Object({ body: t.Object({
idCategory: t.String({ minLength: 1, error: "idCategory harus diisi" }), kategoriId: t.String({
idWarga: t.String({ minLength: 1, error: "idWarga harus diisi" }), minLength: 1,
phone: t.String({ minLength: 1, error: "phone harus diisi" }), description: "ID atau nama kategori pelayanan surat yang dipilih. Jika berupa nama, sistem akan mencocokkan secara otomatis.",
dataText: t.Array(t.Object({ examples: ["skusaha"],
jenis: t.String({ minLength: 1, error: "jenis harus diisi" }), error: "ID kategori harus diisi"
value: t.String({ minLength: 1, error: "value harus diisi" }), }),
})),
syaratDokumen: t.Array(t.Object({ wargaId: t.String({
jenis: t.String({ minLength: 1, error: "jenis harus diisi" }), minLength: 1,
value: t.String({ minLength: 1, error: "value harus diisi" }), description: "ID warga atau nama warga. Jika ID tidak ditemukan, sistem akan mencari berdasarkan nama.",
})), examples: ["Budi Santoso"],
error: "ID warga harus diisi"
}),
noTelepon: t.String({
minLength: 8,
description: "Nomor HP warga yang akan dinormalisasi. Jika data warga tidak ditemukan berdasarkan idWarga, pencarian dilakukan via nomor ini.",
examples: ["081234567890"],
error: "Nomor telepon harus diisi"
}),
dataText: t.Array(
t.Object({
jenis: t.String({
minLength: 1,
description: "Jenis field yang dibutuhkan oleh kategori pelayanan. Biasanya dinamis.",
examples: ["nama", "alamat", "pekerjaan", "keperluan"],
error: "jenis harus diisi"
}),
value: t.String({
minLength: 1,
description: "Isi atau nilai dari jenis field terkait.",
examples: ["Budi Santoso", "Jl. Mawar No. 10", "Karyawan Swasta"],
error: "value harus diisi"
}),
}),
{
description: "Kumpulan data text dinamis sesuai kategori layanan.",
examples: [
[
{ jenis: "jenis usaha", value: "usaha makanan" },
{ jenis: "alamat usaha", value: "Jl. Melati No. 21" },
]
],
error: "dataText harus berupa array"
}
),
syaratDokumen: t.Array(
t.Object({
jenis: t.String({
minLength: 1,
description: "Jenis dokumen persyaratan yang diminta oleh kategori layanan.",
examples: ["ktp", "kk", "surat_pengantar_rt"],
error: "jenis harus diisi"
}),
value: t.String({
minLength: 1,
description: "Nama file atau identifier file dokumen yang diupload.",
examples: ["ktp_budi.png", "kk_budi.png"],
error: "value harus diisi"
}),
}),
{
description: "Kumpulan dokumen yang wajib diupload sesuai persyaratan layanan.",
examples: [
[
{ jenis: "pengantar kelian", value: "pengantar_kelurahan_budi.png" },
{ jenis: "ktp/kk", value: "kk_budi.png" },
{ jenis: "foto lokasi", value: "foto_lokasi_budi.png" }
]
],
error: "syaratDokumen harus berupa array"
}
),
}), }),
detail: { detail: {
summary: "Create Pengajuan Pelayanan Surat", summary: "Create Pengajuan Pelayanan Surat",
@@ -265,7 +493,9 @@ const PelayananRoute = new Elysia({
} }
}) })
.post("/update-status", async ({ body }) => { .post("/update-status", async ({ body }) => {
const { id, status, keterangan, idUser } = body const { id, status, keterangan, idUser, noSurat } = body
let deskripsi = ""
const pengajuan = await prisma.pelayananAjuan.update({ const pengajuan = await prisma.pelayananAjuan.update({
where: { where: {
@@ -273,28 +503,48 @@ const PelayananRoute = new Elysia({
}, },
data: { data: {
status: status as StatusPengaduan, status: status as StatusPengaduan,
},
select: {
id: true,
idCategory: true,
idWarga: true,
} }
}) })
if (!pengajuan) { if (!pengajuan) {
throw new Error("gagal membuat pengajuan") return { success: false, message: 'gagal update status pengajuan surat' }
}
if (status === "diterima") {
deskripsi = "Pengajuan surat diterima"
} else if (status === "ditolak") {
deskripsi = "Pengajuan surat ditolak dengan keterangan " + keterangan
} else if (status === "selesai") {
deskripsi = "Pengajuan surat disetujui"
} }
await prisma.historyPelayanan.create({ await prisma.historyPelayanan.create({
data: { data: {
idPengajuanLayanan: pengajuan.id, idPengajuanLayanan: pengajuan.id,
deskripsi: "Pengajuan surat diperbarui", deskripsi: deskripsi,
status: status as StatusPengaduan,
idUser,
keteranganAlasan: keterangan, keteranganAlasan: keterangan,
} }
}) })
if (status === "selesai") {
await createSurat({ idPengajuan: pengajuan.id, idCategory: pengajuan.idCategory, idWarga: pengajuan.idWarga, noSurat })
}
return { success: true, message: 'pengajuan surat sudah diperbarui' } return { success: true, message: 'pengajuan surat sudah diperbarui' }
}, { }, {
body: t.Object({ body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }), id: t.String({ minLength: 1, error: "id harus diisi" }),
status: t.String({ minLength: 1, error: "status harus diisi" }), status: t.String({ minLength: 1, error: "status harus diisi" }),
keterangan: t.String({ minLength: 1, error: "keterangan harus diisi" }), keterangan: t.String({ optional: true }),
idUser: t.String({ minLength: 1, error: "idUser harus diisi" }), idUser: t.String({ optional: true }),
noSurat: t.String({ optional: true }),
}), }),
detail: { detail: {
summary: "Update Status Pengajuan Pelayanan Surat", summary: "Update Status Pengajuan Pelayanan Surat",
@@ -302,5 +552,128 @@ const PelayananRoute = new Elysia({
tags: ["mcp"] tags: ["mcp"]
} }
}) })
.get("/list", async ({ query }) => {
const { take, page, search, status } = query
const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take))
let where: any = {
isActive: true,
OR: [
{
CategoryPelayanan: {
name: {
contains: search ?? "",
mode: "insensitive"
},
},
},
{
noPengajuan: {
contains: search ?? "",
mode: "insensitive"
},
},
{
Warga: {
phone: {
contains: search ?? "",
mode: "insensitive"
},
},
}
]
}
if (status && status !== "semua") {
where = {
...where,
status: status
}
}
const data = await prisma.pelayananAjuan.findMany({
skip,
take: !take ? 10 : Number(take),
orderBy: {
createdAt: "desc"
},
where,
select: {
id: true,
noPengajuan: true,
status: true,
createdAt: true,
updatedAt: true,
CategoryPelayanan: {
select: {
name: true
}
},
Warga: {
select: {
name: true,
}
}
}
})
const dataFix = data.map((item) => {
return {
noPengajuan: item.noPengajuan,
id: item.id,
category: item.CategoryPelayanan.name,
warga: item.Warga.name,
status: item.status,
createdAt: item.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
}
})
return dataFix
}, {
query: t.Object({
take: t.String({ optional: true }),
page: t.String({ optional: true }),
search: t.String({ optional: true }),
status: t.String({ optional: true }),
}),
detail: {
summary: "List Pengajuan Pelayanan Surat Warga",
description: `tool untuk mendapatkan list pengajuan pelayanan surat warga`,
}
})
.get("/count", async ({ query }) => {
const counts = await prisma.pelayananAjuan.groupBy({
by: ['status'],
where: {
isActive: true,
},
_count: {
status: true,
},
});
const grouped = Object.fromEntries(
counts.map(c => [c.status, c._count.status])
);
const total = await prisma.pelayananAjuan.count({
where: { isActive: true },
});
return {
antrian: grouped?.antrian || 0,
diterima: grouped?.diterima || 0,
dikerjakan: grouped?.dikerjakan || 0,
ditolak: grouped?.ditolak || 0,
selesai: grouped?.selesai || 0,
semua: total,
};
}, {
detail: {
summary: "Jumlah Pengajuan Pelayanan Surat Warga",
description: `tool untuk mendapatkan jumlah pengajuan pelayanan surat warga`,
}
})
export default PelayananRoute export default PelayananRoute

View File

@@ -6,7 +6,8 @@ import { mimeToExtension } from "../lib/mimetypeToExtension"
import { generateNoPengaduan } from "../lib/no-pengaduan" import { generateNoPengaduan } from "../lib/no-pengaduan"
import { normalizePhoneNumber } from "../lib/normalizePhone" import { normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma" import { prisma } from "../lib/prisma"
import { catFile, defaultConfigSF, testConnection, uploadFile, uploadFileBase64 } from "../lib/seafile" import { renameFile } from "../lib/rename-file"
import { catFile, defaultConfigSF, removeFile, uploadFile, uploadFileBase64 } from "../lib/seafile"
const PengaduanRoute = new Elysia({ const PengaduanRoute = new Elysia({
prefix: "pengaduan", prefix: "pengaduan",
@@ -29,7 +30,7 @@ const PengaduanRoute = new Elysia({
}) })
return {data} return { data }
}, { }, {
detail: { detail: {
summary: "List Kategori Pengaduan", summary: "List Kategori Pengaduan",
@@ -111,27 +112,34 @@ const PengaduanRoute = new Elysia({
const noPengaduan = await generateNoPengaduan() const noPengaduan = await generateNoPengaduan()
let idCategoryFix = kategoriId let idCategoryFix = kategoriId
let idWargaFix = wargaId let idWargaFix = wargaId
const category = await prisma.categoryPengaduan.findUnique({
where: {
id: kategoriId,
}
})
if (!category) { if (idCategoryFix) {
const cariCategory = await prisma.categoryPengaduan.findFirst({ const category = await prisma.categoryPengaduan.findUnique({
where: { where: {
name: kategoriId, id: idCategoryFix,
} }
}) })
if (!cariCategory) { if (!category) {
idCategoryFix = "lainnya" const cariCategory = await prisma.categoryPengaduan.findFirst({
} else { where: {
idCategoryFix = cariCategory.id name: kategoriId,
} }
})
if (!cariCategory) {
idCategoryFix = "lainnya"
} else {
idCategoryFix = cariCategory.id
}
}
} else {
idCategoryFix = "lainnya"
} }
const warga = await prisma.warga.findUnique({ const warga = await prisma.warga.findUnique({
where: { where: {
id: wargaId, id: wargaId,
@@ -168,7 +176,7 @@ const PengaduanRoute = new Elysia({
title: judulPengaduan, title: judulPengaduan,
detail: detailPengaduan, detail: detailPengaduan,
idCategory: idCategoryFix, idCategory: idCategoryFix,
idWarga: idWargaFix, idWarga: idWargaFix || "",
location: lokasi, location: lokasi,
image: imageFix, image: imageFix,
noPengaduan, noPengaduan,
@@ -193,48 +201,39 @@ const PengaduanRoute = new Elysia({
}, { }, {
body: t.Object({ body: t.Object({
judulPengaduan: t.String({ judulPengaduan: t.String({
minLength: 3, error: "Judul pengaduan harus diisi",
error: "Judul pengaduan harus diisi dan minimal 3 karakter",
examples: ["Sampah menumpuk di depan rumah"], examples: ["Sampah menumpuk di depan rumah"],
description: "Judul singkat dari pengaduan warga" description: "Judul singkat dari pengaduan warga"
}), }),
detailPengaduan: t.String({ detailPengaduan: t.String({
minLength: 5, error: "Deskripsi pengaduan harus diisi",
error: "Deskripsi pengaduan harus diisi dan minimal 10 karakter",
examples: ["Terdapat sampah yang menumpuk selama seminggu di depan rumah saya"], examples: ["Terdapat sampah yang menumpuk selama seminggu di depan rumah saya"],
description: "Penjelasan lebih detail mengenai pengaduan" description: "Penjelasan lebih detail mengenai pengaduan"
}), }),
lokasi: t.String({ lokasi: t.String({
minLength: 5,
error: "Lokasi pengaduan harus diisi", error: "Lokasi pengaduan harus diisi",
examples: ["Jl. Raya No. 1, RT 01 RW 02, Darmasaba"], examples: ["Jl. Raya No. 1, RT 01 RW 02, Darmasaba"],
description: "Alamat atau titik lokasi pengaduan" description: "Alamat atau titik lokasi pengaduan"
}), }),
namaGambar: t.String({ namaGambar: t.Optional(t.String({
optional: true,
examples: ["sampah.jpg"], examples: ["sampah.jpg"],
description: "Nama file gambar yang telah diupload (opsional)" description: "Nama file gambar yang telah diupload (opsional)"
}), })),
kategoriId: t.String({ kategoriId: t.Optional(t.String({
minLength: 1,
error: "ID kategori pengaduan harus diisi",
examples: ["kebersihan"], examples: ["kebersihan"],
description: "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)" description: "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)"
}), })),
wargaId: t.String({ wargaId: t.Optional(t.String({
minLength: 1,
error: "ID warga harus diisi",
examples: ["budiman"], examples: ["budiman"],
description: "ID unik warga yang melapor (jika sudah terdaftar)" description: "ID unik warga yang melapor (jika sudah terdaftar)"
}), })),
noTelepon: t.String({ noTelepon: t.String({
minLength: 1,
error: "Nomor telepon harus diisi", error: "Nomor telepon harus diisi",
examples: ["08123456789", "+628123456789"], examples: ["08123456789", "+628123456789"],
description: "Nomor telepon warga pelapor" description: "Nomor telepon warga pelapor"
@@ -278,7 +277,7 @@ Respon:
}) })
if (!pengaduan) { if (!pengaduan) {
throw new Error("gagal membuat pengaduan") return { success: false, message: 'gagal update status pengaduan' }
} }
if (status === "diterima") { if (status === "diterima") {
@@ -316,12 +315,14 @@ Respon:
}) })
.get("/detail", async ({ query }) => { .get("/detail", async ({ query }) => {
const { id } = query const { id } = query
const data = await prisma.pengaduan.findUnique({
const data = await prisma.pengaduan.findFirst({
where: { where: {
id,
OR: [ OR: [
{ {
noPengaduan: id noPengaduan: id
}, {
id: id
} }
] ]
}, },
@@ -346,6 +347,13 @@ Respon:
Warga: { Warga: {
select: { select: {
name: true, name: true,
phone: true,
_count: {
select: {
Pengaduan: true,
PelayananAjuan: true,
}
}
} }
} }
} }
@@ -374,27 +382,44 @@ Respon:
id: item.id, id: item.id,
deskripsi: item.deskripsi, deskripsi: item.deskripsi,
status: item.status, status: item.status,
createdAt: item.createdAt, createdAt: item.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false
}),
idUser: item.idUser, idUser: item.idUser,
nameUser: item.User?.name, nameUser: item.User?.name,
} }
}) })
const datafix = { const warga = {
name: data?.Warga?.name,
phone: data?.Warga?.phone,
pengaduan: data?.Warga?._count.Pengaduan,
pelayanan: data?.Warga?._count.PelayananAjuan,
}
const dataPengaduan = {
id: data?.id, id: data?.id,
noPengaduan: data?.noPengaduan, noPengaduan: data?.noPengaduan,
title: data?.title, title: data?.title,
detail: data?.detail, detail: data?.detail,
location: data?.location, location: data?.location,
image: data?.image, image: data?.image,
CategoryPengaduan: data?.CategoryPengaduan.name, category: data?.CategoryPengaduan.name,
idWarga: data?.idWarga,
nameWarga: data?.Warga?.name,
status: data?.status, status: data?.status,
keterangan: data?.keterangan, keterangan: data?.keterangan,
createdAt: data?.createdAt, createdAt: data?.createdAt,
updatedAt: data?.updatedAt, updatedAt: data?.updatedAt,
}
const datafix = {
pengaduan: dataPengaduan,
history: dataHistoryFix, history: dataHistoryFix,
warga: warga,
} }
return datafix return datafix
@@ -489,27 +514,35 @@ Respon:
} }
}) })
.post("/upload", async ({ body }) => { .post("/upload", async ({ body }) => {
const { file } = body; const { file, folder } = body;
// Validasi file // Validasi file
if (!file) { if (!file) {
return { success: false, message: "File tidak ditemukan" }; return { success: false, message: "File tidak ditemukan" };
} }
// Rename file
const renamedFile = renameFile({ oldFile: file, newName: 'random' });
// Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer) // Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer)
// const buffer = await file.arrayBuffer(); // const buffer = await file.arrayBuffer();
const result = await uploadFile(defaultConfigSF, file); const result = await uploadFile(defaultConfigSF, renamedFile, folder);
if (result == 'gagal') {
return { success: false, message: "Upload gagal" };
}
return { return {
success: true, success: true,
message: "Upload berhasil", message: "Upload berhasil",
filename: file.name, filename: renamedFile.name,
size: file.size, size: renamedFile.size,
seafileResult: result seafileResult: result
}; };
}, { }, {
body: t.Object({ body: t.Object({
file: t.File({ format: "binary" }) file: t.Any(),
folder: t.String(),
}), }),
detail: { detail: {
summary: "Upload File", summary: "Upload File",
@@ -688,33 +721,63 @@ Respon:
} }
}) })
.get("/image", async ({ query, set }) => { .get("/image", async ({ query, set }) => {
const { fileName } = query const { fileName, folder } = query;
const connect = await testConnection(defaultConfigSF) const hasil = await catFile(defaultConfigSF, folder, fileName);
console.log({ connect })
const hasil = await catFile(defaultConfigSF, fileName)
console.log('hasilnya', hasil)
// Tentukan tipe MIME berdasarkan ekstensi
const ext = fileName.split(".").pop()?.toLowerCase(); const ext = fileName.split(".").pop()?.toLowerCase();
const mime = let mime = "application/octet-stream"; // default
ext === "jpg" || ext === "jpeg"
? "image/jpeg" if (["jpg", "jpeg"].includes(ext!)) mime = "image/jpeg";
: ext === "png" if (["png"].includes(ext!)) mime = "image/png";
? "image/png" if (["gif"].includes(ext!)) mime = "image/gif";
: "application/octet-stream"; if (["webp"].includes(ext!)) mime = "image/webp";
if (["svg"].includes(ext!)) mime = "image/svg+xml";
if (["pdf"].includes(ext!)) mime = "application/pdf";
set.headers["Content-Type"] = mime; set.headers["Content-Type"] = mime;
set.headers["Content-Length"] = hasil.byteLength.toString();
return new Response(hasil); return new Response(hasil);
}, { }, {
query: t.Object({ query: t.Object({
fileName: t.String(), fileName: t.String(),
folder: t.String()
}), }),
detail: { detail: {
summary: "Gambar Pengaduan Warga", summary: "View Gambar",
description: `tool untuk mendapatkan gambar pengaduan warga`, description: "tool untuk mendapatkan gambar",
} }
}) })
.post("/delete-image", async ({ body }) => {
const { file, folder } = body;
// Validasi file
if (!file) {
return { success: false, message: "File tidak ditemukan" };
}
const result = await removeFile(defaultConfigSF, file, folder);
if (result == 'gagal') {
return { success: false, message: "Delete gagal" };
}
return {
success: true,
message: "Delete berhasil",
};
}, {
body: t.Object({
file: t.String(),
folder: t.String(),
}),
detail: {
summary: "Delete File",
description: "Tool untuk delete file Seafile",
},
})
; ;
export default PengaduanRoute export default PengaduanRoute

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

@@ -4,7 +4,8 @@ import { generateNoPengaduan } from "../lib/no-pengaduan";
import { normalizePhoneNumber } from "../lib/normalizePhone"; import { normalizePhoneNumber } from "../lib/normalizePhone";
const TestPengaduanRoute = new Elysia({ const TestPengaduanRoute = new Elysia({
prefix: "online-pengaduan" prefix: "online-pengaduan",
tags: ["test"]
}) })
.get("/category", async () => { .get("/category", async () => {
const data = await prisma.categoryPengaduan.findMany({ const data = await prisma.categoryPengaduan.findMany({
@@ -20,7 +21,6 @@ const TestPengaduanRoute = new Elysia({
} }
}) })
return { data } return { data }
}, { }, {
detail: { detail: {
@@ -31,71 +31,61 @@ const TestPengaduanRoute = new Elysia({
}) })
.post("/create", async ({ body }) => { .post("/create", async ({ body }) => {
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId, wargaId, noTelepon } = body const { judulPengaduan, detailPengaduan, lokasi, kategoriId, noTelepon, image } = body
let imageFix = namaGambar
const noPengaduan = await generateNoPengaduan() const noPengaduan = await generateNoPengaduan()
let idCategoryFix = kategoriId let idCategoryFix = kategoriId
let idWargaFix = wargaId
const category = await prisma.categoryPengaduan.findUnique({
where: {
id: kategoriId,
}
})
if (!category) { if (idCategoryFix) {
const cariCategory = await prisma.categoryPengaduan.findFirst({ const category = await prisma.categoryPengaduan.findUnique({
where: { where: {
name: kategoriId, id: idCategoryFix,
} }
}) })
if (!cariCategory) { if (!category) {
idCategoryFix = "lainnya" const cariCategory = await prisma.categoryPengaduan.findFirst({
} else { where: {
idCategoryFix = cariCategory.id name: kategoriId,
}
}
const warga = await prisma.warga.findUnique({
where: {
id: wargaId,
}
})
if (!warga) {
const nomorHP = normalizePhoneNumber({ phone: noTelepon })
const cariWarga = await prisma.warga.findUnique({
where: {
phone: nomorHP,
}
})
if (!cariWarga) {
const wargaCreate = await prisma.warga.create({
data: {
name: wargaId,
phone: nomorHP,
},
select: {
id: true
} }
}) })
idWargaFix = wargaCreate.id
} else {
idWargaFix = cariWarga.id
}
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({ const pengaduan = await prisma.pengaduan.create({
data: { data: {
title: judulPengaduan, title: judulPengaduan,
detail: detailPengaduan, detail: detailPengaduan,
idCategory: idCategoryFix, idCategory: idCategoryFix,
idWarga: idWargaFix, idWarga: cariWarga.id,
location: lokasi, location: lokasi,
image: imageFix, image: body.image || "",
noPengaduan, noPengaduan,
}, },
select: { select: {
@@ -117,69 +107,18 @@ const TestPengaduanRoute = new Elysia({
return { success: true, message: 'pengaduan sudah dibuat dengan nomer ' + noPengaduan + ', nomer ini akan digunakan untuk mengakses pengaduan ini' } return { success: true, message: 'pengaduan sudah dibuat dengan nomer ' + noPengaduan + ', nomer ini akan digunakan untuk mengakses pengaduan ini' }
}, { }, {
body: t.Object({ body: t.Object({
judulPengaduan: t.String({ judulPengaduan: t.String(),
error: "Judul pengaduan harus diisi dan minimal 3 karakter", detailPengaduan: t.String(),
examples: ["Sampah menumpuk di depan rumah"], lokasi: t.String(),
description: "Judul singkat dari pengaduan warga" kategoriId: t.String(),
}), noTelepon: t.Optional(t.String()),
image: t.Optional(t.String()),
detailPengaduan: t.String({
error: "Deskripsi pengaduan harus diisi dan minimal 10 karakter",
examples: ["Terdapat sampah yang menumpuk selama seminggu di depan rumah saya"],
description: "Penjelasan lebih detail mengenai pengaduan"
}),
lokasi: t.String({
error: "Lokasi pengaduan harus diisi",
examples: ["Jl. Raya No. 1, RT 01 RW 02, Darmasaba"],
description: "Alamat atau titik lokasi pengaduan"
}),
namaGambar: t.String({
optional: true,
examples: ["sampah.jpg"],
description: "Nama file gambar yang telah diupload (opsional)"
}),
kategoriId: t.String({
error: "ID kategori pengaduan harus diisi",
examples: ["kebersihan"],
description: "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)"
}),
wargaId: t.String({
error: "ID warga harus diisi",
examples: ["budiman"],
description: "ID unik warga yang melapor (jika sudah terdaftar)"
}),
noTelepon: t.String({
error: "Nomor telepon harus diisi",
examples: ["08123456789", "+628123456789"],
description: "Nomor telepon warga pelapor"
}),
}), }),
detail: { detail: {
summary: "Buat Pengaduan Warga", summary: "Buat Pengaduan Warga",
description: ` description: `
Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga. Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga.`
Alur proses:
1. Sistem memvalidasi kategori pengaduan berdasarkan ID.
- Jika ID kategori tidak ditemukan, sistem akan mencari berdasarkan nama kategori.
- Jika tetap tidak ditemukan, kategori akan diset menjadi "lainnya".
2. Sistem memvalidasi data warga berdasarkan ID.
- Jika warga tidak ditemukan, sistem akan mencari berdasarkan nomor telepon.
- Jika tetap tidak ditemukan, data warga baru akan dibuat secara otomatis.
3. Sistem menghasilkan nomor pengaduan unik (noPengaduan).
4. Data pengaduan akan disimpan ke database, termasuk judul, detail, lokasi, gambar (opsional), dan data warga.
5. Sistem juga membuat catatan riwayat awal pengaduan dengan deskripsi "Pengaduan dibuat".
Respon:
- success: true jika pengaduan berhasil dibuat.
- message: berisi pesan sukses dan nomor pengaduan yang dapat digunakan untuk melacak status pengaduan.`,
tags: ["test"]
} }
}) })

View File

@@ -6,10 +6,16 @@ const UserRoute = new Elysia({
prefix: "user", prefix: "user",
tags: ["user"], tags: ["user"],
}) })
.get('/find', (ctx) => { .get('/find', async (ctx) => {
const { user } = ctx as any const { user } = ctx as any
const permissions = await prisma.role.findFirst({
where: { id: user?.roleId },
select: { permissions: true }
});
return { return {
user: user as User user: user as User,
permissions: permissions?.permissions || []
} }
}, { }, {
detail: { detail: {
@@ -150,7 +156,11 @@ const UserRoute = new Elysia({
} }
}) })
.get("/role", async () => { .get("/role", async () => {
const data = await prisma.role.findMany() const data = await prisma.role.findMany({
where: {
isActive: true
}
})
return data return data
}, { }, {
detail: { detail: {
@@ -182,5 +192,80 @@ const UserRoute = new Elysia({
description: "delete user", description: "delete user",
} }
}) })
.post("role-create", async ({ body }) => {
const { name, permission } = body;
const create = await prisma.role.create({
data: {
name,
permissions: permission
}
});
return {
success: true,
message: "Role created successfully",
};
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name is required" }),
permission: t.Array(t.Any(), { minItems: 1, error: "permission is required" })
}),
detail: {
summary: "create-role",
description: "create role",
}
})
.post("/role-update", async ({ body }) => {
const { id, name, permission } = body;
const update = await prisma.role.update({
where: {
id
},
data: {
name,
permissions: permission
}
});
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" }),
permission: t.Array(t.String(), { minItems: 1, error: "permission is required" })
}),
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 export default UserRoute

View File

@@ -62,8 +62,8 @@ const WargaRoute = new Elysia({
phone: t.String({ minLength: 1 }) phone: t.String({ minLength: 1 })
}), }),
detail: { detail: {
summary: "edit konfigurasi desa", summary: "Edit Warga",
description: `tool untuk edit konfigurasi desa` description: `tool untuk edit warga`
} }
}) })
.get("/detail", async ({ query }) => { .get("/detail", async ({ query }) => {